Files
com.blitzwallet/app/components/admin/homeComponents/contacts/createSplitBill.js
T
Blake KaufmanandGitHub 6760060083 Simplify confirm split payment (#778)
* fix small style issues

* fix touch gap

* fixing podfile

* creating standardized amountIn function

* show tx ids not amounts

* use standardized amount in

* use standardized amount in

* fixed no payment information crash

* downgrade rn and react-native-quick-crypto

* only show margin if two elements exist

* adding backup amount

* wait till hompage to call analytics data

* fix restore bug

* updating firebase

* fix check logic

* wrap to handle thrown execptions

* use utils package directly

* adding error handling

* group notification payments insted of handling them sequentually

* handle payment requset on confirm page

* improving split payment ux

* adding translations

* adding firebase sdk warning
2026-04-19 11:43:00 -04:00

662 lines
19 KiB
JavaScript

import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { useNavigation } from '@react-navigation/native';
import {
CustomKeyboardAvoidingView,
ThemeText,
} from '../../../../functions/CustomElements';
import CustomButton from '../../../../functions/CustomElements/button';
import GetThemeColors from '../../../../hooks/themeColors';
import { useGlobalThemeContext } from '../../../../../context-store/theme';
import { useGlobalContacts } from '../../../../../context-store/globalContacts';
import { useGlobalContextProvider } from '../../../../../context-store/context';
import { useKeysContext } from '../../../../../context-store/keys';
import { useServerTimeOnly } from '../../../../../context-store/serverTime';
import {
FONT,
HIDDEN_OPACITY,
INSET_WINDOW_WIDTH,
SIZES,
} from '../../../../constants/theme';
import { bulkPaymentRequest } from '../../../../functions/spark/bulkPaymentFunctions';
import CustomSettingsTopBar from '../../../../functions/CustomElements/settingsTopBar';
import { getDocsByIds } from '../../../../../db';
import { CENTER, CONTENT_KEYBOARD_OFFSET } from '../../../../constants';
import CustomSearchInput from '../../../../functions/CustomElements/searchInput';
import FormattedSatText from '../../../../functions/CustomElements/satTextDisplay';
import WordsQrToggle from '../../../../functions/CustomElements/wordsQrToggle';
import { dollarsToSats } from '../../../../functions/spark/flashnet';
import { validateSplitPayment } from '../../../../functions/payments/validateSplitPayment';
import { useFlashnet } from '../../../../../context-store/flashnetContext';
import { useUserBalanceContext } from '../../../../../context-store/userBalanceContext';
import { keyboardNavigate } from '../../../../functions/customNavigation';
import ContactProfileImage from './internalComponents/profileImage';
import { useImageCache } from '../../../../../context-store/imageCache';
import { useNodeContext } from '../../../../../context-store/nodeContext';
export default function CreateSplitBill(props) {
const navigate = useNavigation();
const {
selectedContacts = [],
paymentType,
amount,
paymentCurrency = 'BTC',
} = props.route.params || {};
const { t } = useTranslation();
const { theme, darkModeType } = useGlobalThemeContext();
const { backgroundOffset, textInputBackground, textInputColor, textColor } =
GetThemeColors();
const { globalContactsInformation } = useGlobalContacts();
const { masterInfoObject } = useGlobalContextProvider();
const { contactsPrivateKey } = useKeysContext();
const { cache } = useImageCache();
const { fiatStats } = useNodeContext();
const getServerTime = useServerTimeOnly();
const { poolInfoRef, poolInfo, swapLimits, swapUSDPriceDollars } =
useFlashnet();
const { bitcoinBalance, dollarBalanceSat } = useUserBalanceContext();
const [memo, setMemo] = useState('');
const [totalSats, setTotalSats] = useState(0); //BTC
const [totalCents, setTotalCents] = useState(0); //USD
const [splitMode, setSplitMode] = useState('even'); // 'even' | 'custom'
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
const [customAmounts, setCustomAmounts] = useState({});
const [isLoading, setIsLoading] = useState(false);
const n = selectedContacts.length;
const isUSD = paymentCurrency === 'USD';
useEffect(() => {
const {
amount: returnedAmount,
type: returnedType,
amountValue: returnedDollarValue,
} = props.route.params || {};
if (returnedType) {
if (isUSD) {
const cents = Math.round(parseFloat(returnedDollarValue || '0') * 100);
setCustomAmounts(prev => ({ ...prev, [returnedType]: cents }));
} else {
setCustomAmounts(prev => ({
...prev,
[returnedType]: parseInt(returnedAmount || 0, 10),
}));
}
navigate.setParams({ type: undefined });
} else if (returnedAmount !== undefined && returnedAmount !== totalSats) {
// Total amount input returned
if (isUSD) {
const cents = Math.round(parseFloat(returnedDollarValue || '0') * 100);
setTotalCents(cents);
} else {
setTotalSats(parseInt(returnedAmount, 10) || 0);
}
}
}, [
props.route.params?.type,
props.route.params?.amount,
props.route.params?.amountValue,
]);
useEffect(() => {
if (splitMode === 'even') {
navigate.setParams({ amount: undefined, amountValue: undefined });
setTotalSats(0);
setCustomAmounts({});
setTotalCents(0);
}
}, [splitMode]);
const chipProgress = useSharedValue(splitMode === 'even' ? 0 : 1);
useEffect(() => {
chipProgress.value = withTiming(splitMode === 'even' ? 0 : 1, {
duration: 200,
});
}, [splitMode]);
const animatedChipStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
chipProgress.value,
[0, 1],
['transparent', textInputBackground],
),
}));
const totalSatsInt = useMemo(() => parseInt(totalSats, 10) || 0, [totalSats]);
const totalCentsInt = useMemo(
() => parseInt(totalCents, 10) || 0,
[totalCents],
);
const customTotal = useMemo(
() =>
Object.values(customAmounts).reduce(
(sum, v) => sum + (typeof v === 'number' ? v : parseInt(v, 10) || 0),
0,
),
[customAmounts],
);
const totalNative =
splitMode === 'even' ? (isUSD ? totalCentsInt : totalSatsInt) : customTotal;
const perPersonNative = useMemo(
() => (n > 0 ? Math.floor(totalNative / n) : 0),
[totalNative, n],
);
const price = poolInfo?.currentPriceAInB ?? 0;
const totalAmountSats = useMemo(() => {
if (!isUSD) return totalNative;
if (price <= 0) return 0;
return Math.round(dollarsToSats(totalNative / 100, price));
}, [isUSD, totalNative, price]);
const {
canPayBTC,
canPayUSD,
errorMessage: balanceErrorMessage,
} = useMemo(() => {
if (paymentType !== 'send' || totalAmountSats <= 0) {
return { canPayBTC: true, canPayUSD: true, errorMessage: null };
}
return validateSplitPayment({
totalSats: totalAmountSats,
paymentCurrency,
bitcoinBalance,
dollarBalanceSat,
swapLimits,
price,
masterInfoObject,
swapUSDPriceDollars,
t,
});
}, [
paymentType,
totalAmountSats,
paymentCurrency,
bitcoinBalance,
dollarBalanceSat,
swapLimits,
price,
masterInfoObject,
swapUSDPriceDollars,
t,
]);
const canConfirm = useMemo(() => {
if (totalNative <= 0) return false;
if (!memo.trim()) return false;
if (paymentType === 'send' && !canPayBTC && !canPayUSD) return false;
if (splitMode === 'even') return perPersonNative > 0;
// custom: every contact must have a valid positive integer and sum must equal total
const allSet = selectedContacts.every(c => {
const v = customAmounts[c.uuid];
return (typeof v === 'number' ? v : parseInt(v || '0', 10)) > 0;
});
return allSet && customTotal === totalNative;
}, [
totalSatsInt,
memo,
splitMode,
perPersonNative,
selectedContacts,
customAmounts,
customTotal,
paymentType,
canPayBTC,
canPayUSD,
]);
const buildRecipients = useCallback(() => {
return selectedContacts.map((contact, index) => {
const isLast = index === selectedContacts.length - 1;
if (isUSD) {
let amountCents;
if (splitMode === 'even') {
amountCents = isLast
? totalNative - perPersonNative * (selectedContacts.length - 1)
: perPersonNative;
} else {
amountCents = parseInt(customAmounts[contact.uuid] || '0', 10);
}
const amountSat = dollarsToSats(
amountCents / 100,
poolInfoRef.currentPriceAInB,
);
return { contact, amountSats: amountSat, amountCents, currency: 'USD' };
}
let amountSats;
if (splitMode === 'even') {
amountSats = isLast
? totalNative - perPersonNative * (selectedContacts.length - 1)
: perPersonNative;
} else {
amountSats = parseInt(customAmounts[contact.uuid] || '0', 10);
}
return { contact, amountSats, amountCents: null, currency: 'BTC' };
});
}, [
selectedContacts,
isUSD,
splitMode,
perPersonNative,
totalNative,
customAmounts,
]);
const handleConfirm = useCallback(async () => {
if (totalNative <= 0) {
navigate.navigate('ErrorScreen', {
errorMessage: t('contacts.splitBill.errors.noAmount', {
context: splitMode,
}),
});
return;
}
if (!memo.trim()) {
navigate.navigate('ErrorScreen', {
errorMessage: t('contacts.splitBill.errors.noDescription'),
});
return;
}
if ((splitMode === 'custom' && !canConfirm) || perPersonNative <= 0) {
navigate.navigate('ErrorScreen', {
errorMessage: t('contacts.splitBill.errors.noAmount', {
context: 'custom',
}),
});
return;
}
if (isLoading) return;
const recipients = buildRecipients();
if (paymentType === 'request') {
setIsLoading(true);
try {
const senderInfo = {
globalContactsInformation,
privateKey: contactsPrivateKey,
masterInfoObject,
currentTime: getServerTime(),
};
const result = await bulkPaymentRequest(
recipients,
memo.trim(),
senderInfo,
);
keyboardNavigate(() =>
navigate.reset({
index: 0,
routes: [
{ name: 'HomeAdmin', params: { screen: 'Home' } },
{
name: 'ConfirmTxPage',
params: {
bulkResults: result,
isSplitPayment: true,
isRequset: true,
},
},
],
}),
);
} catch (err) {
console.log('CreateSplitBill: request error', err);
} finally {
setIsLoading(false);
}
} else {
if (balanceErrorMessage) {
navigate.navigate('ErrorScreen', { errorMessage: balanceErrorMessage });
return;
}
const docIds = recipients.map(r => r.contact.uuid);
const users = await getDocsByIds('blitzWalletUsers', docIds);
const existingUsers = users.filter(Boolean);
// Combine recipients with their sparkAddress from Firestore docs
const recipientsWithAddress = recipients
.map((recipient, index) => {
const user = existingUsers[index];
if (!user) return null;
return {
...recipient,
contactFull: user,
contact: {
...recipient.contact,
receiveAddress: user.contacts?.myProfile?.sparkAddress ?? null,
},
};
})
.filter(Boolean);
keyboardNavigate(() =>
navigate.navigate('ConfirmSplitPayment', {
splitRecipients: recipientsWithAddress,
enteredPaymentInfo: {
amount: isUSD ? totalCentsInt : totalSatsInt,
description: memo.trim(),
fromContacts: true,
},
fromPage: 'contacts',
paymentCurrency,
selectedPaymentMethod: paymentCurrency,
}),
);
}
}, [
isLoading,
buildRecipients,
paymentType,
globalContactsInformation,
contactsPrivateKey,
masterInfoObject,
getServerTime,
memo,
navigate,
t,
totalSatsInt,
totalCentsInt,
splitMode,
canConfirm,
balanceErrorMessage,
perPersonNative,
]);
const contactElements = useMemo(() => {
return selectedContacts.map(contact => {
const uniqueName = contact.uniqueName || '';
const contactName = contact.name || t('contacts.splitBill.noName') || '';
const contactAmount =
splitMode === 'even'
? perPersonNative
: typeof customAmounts[contact.uuid] === 'number'
? customAmounts[contact.uuid]
: parseInt(customAmounts[contact.uuid] || '0', 10);
const amountChip = isUSD ? (
<ThemeText
styles={[
styles.amountChip,
{
color: splitMode === 'custom' ? textInputColor : textColor,
},
]}
content={
contactAmount > 0 ? `$${(contactAmount / 100).toFixed(2)}` : '$0.00'
}
/>
) : (
<FormattedSatText
autoAdjustFontSize
styles={{
...styles.amountChip,
color: splitMode === 'custom' ? textInputColor : textColor,
}}
balance={contactAmount}
/>
);
return (
<View key={contact.uuid} style={styles.contactRow}>
<View style={styles.contactAvatar}>
<ContactProfileImage
uri={cache[contact.uuid]?.localUri}
updated={cache[contact.uuid]?.updated}
theme={theme}
darkModeType={darkModeType}
/>
</View>
<View style={{ flex: 1 }}>
<ThemeText
styles={styles.contactUniqueName}
content={uniqueName}
CustomNumberOfLines={1}
/>
<ThemeText
styles={styles.contactName}
content={contactName}
CustomNumberOfLines={1}
/>
</View>
<Animated.View style={[styles.chipWrapper, animatedChipStyle]}>
{splitMode === 'custom' ? (
<TouchableOpacity
onPress={() => {
keyboardNavigate(() =>
navigate.navigate('CustomHalfModal', {
wantedContent: 'customInputText',
returnLocation: 'CreateSplitBill',
sliderHight: 0.5,
type: contact.uuid,
forceUSD: isUSD,
}),
);
}}
>
{amountChip}
</TouchableOpacity>
) : (
amountChip
)}
</Animated.View>
</View>
);
});
}, [
selectedContacts,
splitMode,
perPersonNative,
customAmounts,
backgroundOffset,
cache,
isUSD,
textInputBackground,
t,
textColor,
textInputColor,
]);
return (
<CustomKeyboardAvoidingView
useLocalPadding={true}
isKeyboardActive={isKeyboardActive}
useStandardWidth={true}
>
<CustomSettingsTopBar label={t('contacts.splitBill.createBillTitle')} />
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.scrollContent}
>
<ThemeText
styles={[
styles.totalAmountLabel,
{ textAlign: 'center', marginBottom: 0 },
]}
content={t('contacts.splitBill.totalAmountLabel')}
/>
{/* Big total amount display — tappable in even, grayed in custom */}
<TouchableOpacity
onPress={() => {
navigate.navigate('CustomHalfModal', {
wantedContent: 'customInputText',
returnLocation: 'CreateSplitBill',
sliderHight: 0.5,
forceUSD: isUSD,
});
}}
disabled={splitMode === 'custom' || isKeyboardActive}
activeOpacity={splitMode === 'custom' ? 1 : 0.7}
>
{isUSD ? (
<ThemeText
styles={[
styles.balanceText,
{ opacity: splitMode === 'custom' ? HIDDEN_OPACITY : 1 },
]}
content={
totalNative > 0 ? `$${(totalNative / 100).toFixed(2)}` : '$0.00'
}
/>
) : (
<FormattedSatText
autoAdjustFontSize
styles={{
...styles.balanceText,
opacity: splitMode === 'custom' ? HIDDEN_OPACITY : 1,
}}
balance={totalNative}
/>
)}
</TouchableOpacity>
{/* Even/Custom pill toggle */}
<WordsQrToggle
option1Text={t('contacts.splitBill.evenSplit')}
option2Text={t('contacts.splitBill.customSplit')}
option1Value="even"
option2Value="custom"
setSelectedDisplayOption={setSplitMode}
selectedDisplayOption={splitMode}
containerStyle={{ marginTop: 16, alignSelf: 'center' }}
/>
{/* Divider */}
{/* <View style={[styles.divider, { backgroundColor: backgroundOffset }]} /> */}
{/* Contact rows — always shown, mode-aware */}
<View style={styles.contactsContainer}>{contactElements}</View>
{/* Divider */}
{/* <View style={[styles.divider, { backgroundColor: backgroundOffset }]} /> */}
{/* Memo input */}
<View style={styles.fieldGroup}>
<ThemeText
styles={styles.fieldLabel}
content={t('contacts.splitBill.memoLabel')}
/>
<CustomSearchInput
placeholderText={t('contacts.splitBill.memoPlaceholder')}
inputText={memo}
setInputText={setMemo}
maxLength={100}
onFocusFunction={() => setIsKeyboardActive(true)}
onBlurFunction={() => setIsKeyboardActive(false)}
/>
<ThemeText
styles={styles.charCounter}
content={`${memo.length}/100`}
/>
</View>
</ScrollView>
{/* Confirm button */}
<CustomButton
buttonStyles={[styles.confirmButton, { opacity: canConfirm ? 1 : 0.5 }]}
actionFunction={handleConfirm}
disabled={isLoading}
useLoading={isLoading}
textContent={
paymentType === 'request'
? t('constants.request')
: t('constants.review')
}
/>
</CustomKeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
scrollContent: {
width: INSET_WINDOW_WIDTH,
...CENTER,
paddingBottom: 24,
},
balanceText: {
width: '100%',
fontSize: SIZES.huge,
textAlign: 'center',
includeFontPadding: false,
},
divider: {
height: 1,
marginVertical: 20,
},
contactsContainer: {
width: '100%',
marginVertical: 25,
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 15,
gap: 12,
},
contactAvatar: {
width: 40,
height: 40,
borderRadius: 25,
alignItems: 'center',
justifyContent: 'center',
},
contactUniqueName: {
includeFontPadding: false,
flexShrink: 1,
},
contactName: {
fontSize: SIZES.smedium,
opacity: HIDDEN_OPACITY,
includeFontPadding: false,
flexShrink: 1,
},
chipWrapper: {
borderRadius: 8,
},
amountChip: {
minWidth: 100,
paddingVertical: 8,
paddingHorizontal: 12,
fontSize: SIZES.medium,
includeFontPadding: false,
textAlign: 'right',
},
fieldGroup: {
width: '100%',
},
fieldLabel: {
includeFontPadding: false,
marginBottom: 8,
},
totalAmountLabel: { includeFontPadding: false, marginTop: 16 },
charCounter: {
textAlign: 'right',
includeFontPadding: false,
marginTop: 10,
},
confirmButton: {
width: INSET_WINDOW_WIDTH,
...CENTER,
marginTop: CONTENT_KEYBOARD_OFFSET,
},
});