Redesign NFC screen

This commit is contained in:
minibits-cash
2026-02-02 16:20:09 +01:00
parent 66e08d532a
commit c938d17446
7 changed files with 420 additions and 324 deletions
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.3.4-beta.22",
"version": "0.3.4-beta.23",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
@@ -64,7 +64,6 @@
"react-native-flash-message": "^0.4.2",
"react-native-gesture-handler": "^2.28.0",
"react-native-hce": "^0.3.0",
"react-native-indicators": "^0.17.0",
"react-native-json-tree": "^1.5.0",
"react-native-keychain": "^10.0.0",
"react-native-localize": "^3.5.2",
@@ -1,86 +0,0 @@
diff --git a/node_modules/react-native-indicators/src/components/indicator/index.js b/node_modules/react-native-indicators/src/components/indicator/index.js
index b2de0a8..feb3c21 100644
--- a/node_modules/react-native-indicators/src/components/indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/indicator/index.js
@@ -175,6 +175,7 @@ export default class Indicator extends PureComponent {
render() {
let { hideAnimation } = this.state;
let { count, hidesWhenStopped, ...props } = this.props;
+ const { key, ...rest } = props;
if (hidesWhenStopped) {
props.style = []
@@ -182,7 +183,7 @@ export default class Indicator extends PureComponent {
}
return (
- <Animated.View {...props}>
+ <Animated.View key={key} {...rest}>
{Array.from(new Array(count), this.renderComponent, this)}
</Animated.View>
);
diff --git a/node_modules/react-native-indicators/src/components/material-indicator/index.js b/node_modules/react-native-indicators/src/components/material-indicator/index.js
index bf7edc4..0bde608 100644
--- a/node_modules/react-native-indicators/src/components/material-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/material-indicator/index.js
@@ -107,7 +107,7 @@ export default class MaterialIndicator extends PureComponent {
};
return (
- <Animated.View style={styles.layer} {...{ key: index }}>
+ <Animated.View style={styles.layer} key={index}>
<Animated.View style={layerStyle}>
<Animated.View style={[containerStyle, offsetStyle]} collapsable={false}>
<Animated.View style={viewportStyle}>
diff --git a/node_modules/react-native-indicators/src/components/pulse-indicator/index.js b/node_modules/react-native-indicators/src/components/pulse-indicator/index.js
index 53bcf92..e452df8 100644
--- a/node_modules/react-native-indicators/src/components/pulse-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/pulse-indicator/index.js
@@ -51,7 +51,7 @@ export default class PulseIndicator extends PureComponent {
};
return (
- <Animated.View style={styles.layer} {...{ key: index }}>
+ <Animated.View style={styles.layer} key={index}>
<Animated.View style={pulseStyle} />
</Animated.View>
);
diff --git a/node_modules/react-native-indicators/src/components/skype-indicator/index.js b/node_modules/react-native-indicators/src/components/skype-indicator/index.js
index 8a988df..1a0304f 100644
--- a/node_modules/react-native-indicators/src/components/skype-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/skype-indicator/index.js
@@ -68,7 +68,7 @@ export default class SkypeIndicator extends PureComponent {
};
return (
- <Animated.View style={[styles.layer, layerStyle]} {...{ key: index }}>
+ <Animated.View style={[styles.layer, layerStyle]} key={index}>
<Animated.View style={ballStyle} />
</Animated.View>
);
diff --git a/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js b/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js
index dcde1e5..468e560 100644
--- a/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js
@@ -61,7 +61,7 @@ export default class UIActivityIndicator extends PureComponent {
};
return (
- <Animated.View style={[styles.layer, layerStyle]} {...{ key: index }}>
+ <Animated.View style={[styles.layer, layerStyle]} key={index}>
<Animated.View style={barStyle} />
</Animated.View>
);
diff --git a/node_modules/react-native-indicators/src/components/wave-indicator/index.js b/node_modules/react-native-indicators/src/components/wave-indicator/index.js
index 2de37d5..1fbe5ce 100644
--- a/node_modules/react-native-indicators/src/components/wave-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/wave-indicator/index.js
@@ -64,7 +64,7 @@ export default class WaveIndicator extends PureComponent {
};
return (
- <Animated.View style={styles.layer} {...{ key: index }}>
+ <Animated.View style={styles.layer} key={index}>
<Animated.View style={waveStyle} />
</Animated.View>
);
+5 -1
View File
@@ -1 +1,5 @@
export const NfcIcon = `<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 120.39 122.88" style="enable-background:new 0 0 120.39 122.88" xml:space="preserve"><g><path d="M5.92,82.19C2.56,77.14,0.56,69.46,0.1,61.82c-0.46-7.6,0.61-15.42,3.37-20.81l2.8-5.47l4.79,3.84L56.59,75.9 c1.86-6.84,2.43-13.69,1.88-20.17c-0.75-8.76-4.56-18.25-9.11-24.46l8.62-5.03c5.64,7.7,9.58,18.07,10.49,28.66 c0.86,9.97-0.6,20.73-4.77,31.24l-2.5,6.3l-5.29-4.24L10.51,51.78c-0.47,2.9-0.6,6.16-0.4,9.45c0.36,6.02,1.8,11.87,4.17,15.43 L5.92,82.19L5.92,82.19z M92.57,116.75c11.23-17.56,17.82-39.92,17.81-57.3c-0.01-21.83-6.85-41.56-15.56-54.46L103.56,0 c9.52,14.26,16.82,35.8,16.82,59.45c0.01,19.12-7.47,44.1-19.84,63.43L92.57,116.75L92.57,116.75z M71.78,100.52 c8.01-11.5,12.79-27.12,12.71-41.63c-0.08-14.45-4.23-29.01-12.43-40.72l8.68-5.03C89.9,26.39,94.4,42.69,94.49,58.85 c0.09,16.53-5.59,34.62-14.78,47.83L71.78,100.52L71.78,100.52z"/></g></svg>`
export const NfcIcon = `<svg class="w-4 h-4" viewBox="0 -1 15 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" d="M14.1019 11.6159C14.1019 7.48502 12.8129 3.63737 10.5179 0.453773C9.81482-0.532555 8.37928 0.32682 9.141 1.35221C11.2797 4.29166 12.4222 7.83659 12.4222 11.6159C12.4222 15.3952 11.2699 18.9303 9.141 21.8698C8.39881 22.8854 9.78553 23.7936 10.5179 22.778C12.8129 19.5846 14.1019 15.737 14.1019 11.6159Z" ></path>
<path stroke-linecap="round" d="M8.77967 11.6159C8.77967 8.54948 7.81287 5.66862 6.07459 3.33463C5.29334 2.28971 3.93592 3.29557 4.66834 4.2526C6.26014 6.34245 7.10975 8.89127 7.10975 11.6159C7.10975 14.3405 6.26014 16.8893 4.66834 18.9792C3.93592 19.9362 5.29334 20.9421 6.07459 19.8874C7.81287 17.5534 8.77967 14.6823 8.77967 11.6159Z" ></path>
<path stroke-linecap="round" d="M3.47693 11.6159C3.47693 9.60416 2.78357 7.72916 1.55311 6.26432C0.742558 5.30729-0.439082 6.39127 0.166386 7.17252C1.34803 8.6569 1.79725 9.89713 1.79725 11.6159C1.79725 13.3346 1.34803 14.5749 0.166386 16.0592C-0.429317 16.8307 0.752324 17.9049 1.55311 16.9577C2.78357 15.5026 3.47693 13.6276 3.47693 11.6159Z"></path>
</svg>`
+308 -172
View File
@@ -1,35 +1,27 @@
import React, { FC, useState, useEffect, useRef, useCallback } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import {
ViewStyle,
View,
TextStyle,
Alert,
ColorValue,
Platform,
} from 'react-native'
import {
PulseIndicator,
WaveIndicator,
} from 'react-native-indicators'
import { PaymentRequest as CashuPaymentRequest, MeltQuoteResponse, PaymentRequestTransportType, decodePaymentRequest, getDecodedToken } from '@cashu/cashu-ts'
import NfcManager, { NfcTech, Ndef, NfcEvents, TagEvent } from 'react-native-nfc-manager'
import { PaymentRequest as CashuPaymentRequest, MeltQuoteBolt11Response, MeltQuoteResponse, PaymentRequestTransportType, decodePaymentRequest, getDecodedToken } from '@cashu/cashu-ts'
import NfcManager, { Ndef, NfcEvents } from 'react-native-nfc-manager'
import { colors, spacing, typography, useThemeColor } from '../theme'
import EventEmitter from '../utils/eventEmitter'
import { log } from '../services/logService'
import { IncomingDataType, IncomingParser } from '../services/incomingParser'
import AppError, { Err } from '../utils/AppError'
import { AmountInput, BottomModal, Button, Card, ErrorModal, Icon, InfoModal, ListItem, ScanIcon, Screen, Text } from '../components'
import { infoMessage } from '../utils/utils'
import { SvgXml } from 'react-native-svg'
import { formatCurrency, getCurrency, MintUnit, MintUnits } from '../services/wallet/currency'
import { useStores } from '../models'
import { MintHeader } from './Mints/MintHeader'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import { moderateScale } from '@gocodingnow/rn-size-matters'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { translate } from '../i18n'
import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
import { observer } from 'mobx-react-lite'
import { Mint, MintBalance } from '../models/Mint'
import { MintBalance } from '../models/Mint'
import { MinibitsClient, NostrClient, SYNC_STATE_WITH_MINT_TASK, SyncStateTaskResult, TransactionTaskResult, WalletTask } from '../services'
import { Transaction, TransactionStatus } from '../models/Transaction'
import { Proof } from '../models/Proof'
@@ -42,72 +34,17 @@ import { LIGHTNING_FEE_PERCENT, MIN_LIGHTNING_FEE } from '../models/NwcStore'
import { ResultModalInfo } from './Wallet/ResultModalInfo'
import Animated, {
useSharedValue,
withRepeat,
withTiming,
useAnimatedStyle,
withRepeat,
Easing,
} from 'react-native-reanimated';
import { NfcService } from '../services/nfcService'
import { TranItem } from './TranDetailScreen'
import { ProfilePointer } from 'nostr-tools/nip19'
import { Contact } from '../models/Contact'
//import Animated from 'react-native-reanimated'
import { NfcIcon } from '../components/NfcIcon'
import FastImage from 'react-native-fast-image'
const ContactlessIcon = (color: ColorValue | string) => `<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="-5 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.3 19.5002C17.4 17.2002 18 14.7002 18 12.0002C18 9.30024 17.4 6.70024 16.3 4.50024M12.7 17.8003C13.5 16.0003 14 14.0003 14 12.0003C14 10.0003 13.5 7.90034 12.7 6.10034M9.1001 16.1001C9.7001 14.8001 10.0001 13.4001 10.0001 12.0001C10.0001 10.6001 9.7001 9.10015 9.1001 7.90015M5.5 14.3003C5.8 13.6003 6 12.8003 6 12.0003C6 11.2003 5.8 10.3003 5.5 9.60034"
stroke="${String(color)}"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>`
interface PulsingContactlessIconProps {
isNfcEnabled: boolean;
}
export const PulsingContactlessIcon: React.FC<PulsingContactlessIconProps> = ({
isNfcEnabled,
}) => {
const scale = useSharedValue(1);
// Start/stop pulse based on isNfcEnabled
useEffect(() => {
if (isNfcEnabled) {
scale.value = withRepeat(
withTiming(1.22, {
duration: 1200,
easing: Easing.out(Easing.quad),
}),
-1, // infinite
true // reverse (so it goes 1 → 1.22 → 1 → 1.22...)
);
} else {
scale.value = withTiming(1, { duration: 300 });
}
}, [isNfcEnabled, scale]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const color = isNfcEnabled
? colors.palette.success200
: colors.palette.neutral400
return (
<Animated.View style={[animatedStyle, { alignSelf: 'center' }]}>
<SvgXml
xml={ContactlessIcon(color)}
width={150}
height={150}
style={{ marginVertical: 24 }} // or your spacing.large
/>
</Animated.View>
);
};
type Props = StaticScreenProps<{
unit: MintUnit
@@ -131,7 +68,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
// const [encodedInvoice, setEncodedInvoice] = useState<string>('')
const [invoice, setInvoice] = useState<DecodedLightningInvoice | undefined>()
const [invoiceExpiry, setInvoiceExpiry] = useState<Date | undefined>()
const [meltQuote, setMeltQuote] = useState<MeltQuoteResponse | undefined>()
const [meltQuote, setMeltQuote] = useState<MeltQuoteBolt11Response | undefined>()
const [memo, setMemo] = useState('')
const [mintBalanceToSendFrom, setMintBalanceToSendFrom] = useState<MintBalance | undefined>()
@@ -144,14 +81,12 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
// NFC state
const [isNfcEnabled, setIsNfcEnabled] = useState(false)
const [isNfcSupported, setIsNfcSupported] = useState(false)
const [isNfcEnabled, setIsNfcEnabled] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [isPaid, setIsPaid] = useState(false)
const [isError, setIsError] = useState(false)
const [nfcInfo, setNfcInfo] = useState<string | undefined>()
const [readNfcData, setReadNfcData] = useState<string | undefined>()
const [mint, setMint] = useState<Mint | undefined>()
//const [readNfcData, setReadNfcData] = useState<string | undefined>()
const [error, setError] = useState<AppError | undefined>()
const [info, setInfo] = useState<string>('')
@@ -161,9 +96,22 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const initNfc = async () => {
try {
const supported = await NfcService.init() // runs NfcManager.start()
setIsNfcSupported(supported)
log.trace({isInternetReachable})
const balance = proofsStore.getMintBalanceWithMaxBalance(unitRef.current)
if (balance) {
setMintBalanceToSendFrom(balance)
}
/*setNfcInfo('Hold your device close to the NFC reader.')
setIsNfcEnabled(true)
return
const tx = transactionsStore.findById(1)
setTransaction(tx || undefined)
setAmountToPay(formatDisplayAmount(tx?.amount || 0, unitRef.current))*/
if (!supported) {
setNfcInfo('NFC is not supported on this device')
return
@@ -405,7 +353,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
log.info('NFC tag decoded', { decoded })
setReadNfcData(decoded)
//setReadNfcData(decoded)
await handleIncomingData(decoded)
} catch (e: any) {
@@ -493,6 +441,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
})
}
// re-set selected mint balance if default is not accepted by this PR
setMintBalanceToSendFrom(selectedBalance)
setAmountToPay(formatDisplayAmount(requiredAmount, unit))
@@ -564,8 +513,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
log.trace('[NfcScreen] handleSendTaskResult: Write completed.')
setNfcInfo('Ecash token sent successfully.')
setIsPaid(true)
expandHeader()
// Show explanatory modal immediately when offline
if (!isOnline.current) {
setResultModalInfo({
@@ -650,7 +598,6 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
setNfcInfo('Ecash token sent successfully.')
setIsPaid(true)
expandHeader()
}
@@ -664,6 +611,10 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
})
}
if(!mintBalanceToSendFrom) {
throw new AppError(Err.NOTFOUND_ERROR, 'No mint balance selected for payment')
}
log.trace('[handleLightningInvoice] decoding invoice')
const decoded = LightningUtils.decodeInvoice(encodedInvoice)
@@ -676,9 +627,11 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const expiresAt = addSeconds(new Date((timestamp as number) * 1000), expiry as number)
const feeReserve = Math.max(MIN_LIGHTNING_FEE, Math.round(invoiceAmount * LIGHTNING_FEE_PERCENT / 100))
const totalRequired = invoiceAmount + feeReserve
const balances = proofsStore.getMintBalancesWithEnoughBalance(totalRequired, unitRef.current)
// const balances = proofsStore.getMintBalancesWithEnoughBalance(totalRequired, unitRef.current)
const availableBalance = mintBalanceToSendFrom.balances[unitRef.current] || 0
if (balances.length === 0) {
if (availableBalance < totalRequired) {
throw new AppError(Err.NOTFOUND_ERROR, 'Not enough funds', {
message: translate('transferScreen_insufficientFunds', {
currency: getCurrency(unitRef.current).code,
@@ -687,15 +640,15 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
})
}
const selectedBalance = balances[0]
setMintBalanceToSendFrom(selectedBalance)
// const selectedBalance = balances[0]
// setMintBalanceToSendFrom(selectedBalance)
let msgQuote = 'Creating Lightning payment quote...'
log.trace('[handleLightningInvoice]', msgQuote)
setNfcInfo(msgQuote)
const quote = await walletStore.createLightningMeltQuote(
selectedBalance.mintUrl,
mintBalanceToSendFrom.mintUrl,
unitRef.current,
encodedInvoice
)
@@ -711,7 +664,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
setNfcInfo('Paying Lightning invoice...')
const result = await WalletTask.transferQueueAwaitable(
selectedBalance,
mintBalanceToSendFrom,
quote.amount,
unitRef.current,
quote,
@@ -754,7 +707,6 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
title: status === TransactionStatus.COMPLETED ? 'Payment sent!' : undefined,
});
setIsPaid(true)
expandHeader()
setNfcInfo(transaction.memo || 'Lightning payment settled.')
}
} else {
@@ -807,7 +759,6 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
title: status === TransactionStatus.COMPLETED ? 'Ecash received!' : undefined,
});
setIsPaid(true)
expandHeader()
setNfcInfo(transaction.memo || 'Receive over NFC completed.')
setAmountToReceive(formatDisplayAmount(transaction.amount, transaction.unit))
}
@@ -925,44 +876,46 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const toggleResultModal = () => setIsResultModalVisible(previousState => !previousState)
const headerHeight = useSharedValue(spacing.screenHeight * 0.15); // Initial height
const collapseHeader = () => {
headerHeight.value = spacing.screenHeight * 0.15
}
const expandHeader = () => {
headerHeight.value = spacing.screenHeight * 0.20
}
const animatedHeader = useAnimatedStyle(() => {
return {
height: withTiming(headerHeight.value, { duration: 300 }),
// opacity: withTiming(isVisible.value ? 1 : 0, { duration: 300 }),
}
})
// Theme
const hintText = useThemeColor('textDim')
const headerBg = useThemeColor('header')
const headerBg = useThemeColor('background')
const headerTitle = useThemeColor('headerTitle')
const scanIcon = useThemeColor('text')
const nfcText = useThemeColor('text')
const indicatorStandby = colors.palette.primary400
const indicatorProcessing = useThemeColor('button')
const indicatorProcessing = useThemeColor('button')
// Metallic card colors - subtle gradient effect via borders
const isLightTheme = headerBg === colors.light.background
const metalHighlight = isLightTheme ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.12)'
const metalShadow = isLightTheme ? 'rgba(0, 0, 0, 0.15)' : 'rgba(0, 0, 0, 0.5)'
const metalBase = isLightTheme ? colors.palette.neutral200 : colors.palette.neutral700
const metalShineTop = isLightTheme ? 'rgba(255, 255, 255, 0.6)' : 'rgba(255, 255, 255, 0.08)'
const metalShineBottom = isLightTheme ? 'rgba(255, 255, 255, 0)' : 'rgba(255, 255, 255, 0)'
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<MintHeader mint={mint} unit={unitRef.current} />
<Animated.View style={[animatedHeader, $headerContainer, {backgroundColor: headerBg}]}>
<View style={{}}>
<MintHeader
mint={undefined}
unit={unitRef.current}
onBackPress={gotoWallet}
textColor={nfcText as string}
backgroundColor={headerBg as string}
leftIconColor={nfcText as string}
/>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
{!isOnline.current && !isPaid && (
<>
<Text
tx="commonOffline"
style={$warning}
size="xxs"
/>
/>
</>
)}
{isPaid && (
{isPaid ? (
<AmountInput
//ref={amountInputRef}
value={`${amountToPay || amountToReceive}`}
@@ -970,9 +923,41 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
unit={unitRef.current}
editable={false}
/>
) : (
<>
<View
style={{
alignSelf: 'center',
alignItems: 'center',
justifyContent: 'center',
width: moderateScale(80),
height: moderateScale(80),
backgroundColor:isProcessing ? indicatorProcessing : isNfcEnabled ? indicatorStandby : hintText,
borderRadius: moderateScale(80) / 2,
marginVertical: spacing.medium,
}}
>
<PulsingContactlessIcon
isNfcEnabled={isNfcEnabled}
size={moderateScale(40)}
/>
</View>
<Text
text={nfcInfo}
style={{
textAlign: 'center',
marginBottom: spacing.medium,
paddingHorizontal: spacing.medium,
//fontFamily: typography.code,
color: isProcessing ? indicatorProcessing : isNfcEnabled ? indicatorStandby : hintText,
}}
preset='heading'
size='md'
/>
</>
)}
</View>
</Animated.View>
</View>
<View style={$contentContainer}>
{isPaid && transaction ? (
<Card
@@ -1007,63 +992,98 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
}
/>
) : (
<Card
HeadingComponent={<Icon icon='faNfcSymbol' size={spacing.medium} containerStyle={{alignSelf: 'center'}}/>}
ContentComponent={
<>
{isProcessing ? (
<WaveIndicator
color={indicatorProcessing}
size={verticalScale(120)}
animating={resultModalInfo ? false : true}
animationDuration={2000}
/>
) : isNfcEnabled ? (
<PulseIndicator
color={indicatorStandby}
size={verticalScale(120)}
animating={resultModalInfo ? false : true}
hidesWhenStopped={false}
animationDuration={2000}
/>
) : (
<PulseIndicator
color={hintText}
size={verticalScale(120)}
animating={false}
useNativeDriver={true}
hidesWhenStopped={false}
//animationDuration={2000}
/>
)}
</>
}
FooterComponent={
<Text
text={
nfcInfo ? nfcInfo : ''
}
style={{
textAlign: 'center',
marginBottom: spacing.medium,
//fontFamily: typography.code,
color: isProcessing ? indicatorProcessing : isNfcEnabled ? nfcText : hintText,
fontSize: 18,
}}
/>
}
style={{height: spacing.screenHeight * 0.3}}
/>
)}
<>
{mintBalanceToSendFrom && (() => {
const mint = mintsStore.findByUrl(mintBalanceToSendFrom.mintUrl)
const balance = mintBalanceToSendFrom.balances[unitRef.current] || 0
const currency = getCurrency(unitRef.current)
const formattedBalance = formatCurrency(balance, currency.code)
return (
<View style={[
$mintCardOuter,
{
backgroundColor: metalBase,
borderTopColor: metalHighlight,
borderLeftColor: metalHighlight,
borderBottomColor: metalShadow,
borderRightColor: metalShadow,
}
]}>
{/* Metallic shine overlay */}
<View
style={[
$metalShine,
{
backgroundColor: metalShineTop,
borderColor: metalShineBottom,
}
]}
pointerEvents="none"
/>
<Card
ContentComponent={
<View style={$cardContent}>
<View style={$cardTopRow}>
{mint?.mintInfo?.icon_url ? (
<FastImage
style={{
width: moderateScale(28),
height: moderateScale(28),
borderRadius: moderateScale(14),
}}
source={{ uri: mint.mintInfo.icon_url }}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<View style={[$mintIconFallback, { backgroundColor: mint?.color || colors.palette.primary300 }]} />
)}
<Text
text={mint?.shortname || 'Mint'}
style={{ color: nfcText, marginLeft: spacing.small }}
size="xs"
/>
</View>
<View style={$cardBalanceRow}>
<Text
text={formattedBalance}
style={{
fontFamily: typography.code?.normal,
color: nfcText,
fontSize: moderateScale(28),
}}
/>
<Text
text={currency.code}
style={{
color: hintText,
marginLeft: spacing.extraSmall,
alignSelf: 'flex-end',
marginBottom: spacing.tiny,
}}
size="xs"
/>
</View>
<View style={$cardBottomRow}>
<Text
text={mint?.hostname || mintBalanceToSendFrom.mintUrl}
style={{ color: hintText }}
size="xxs"
numberOfLines={1}
/>
</View>
</View>
}
style={$mintCard}
/>
</View>
)
})()}
</>
)}
<View style={$bottomContainer}>
<View style={$buttonContainer}>
{!isProcessing && (<Button
{!isProcessing && !isPaid && (<Button
preset='tertiary'
LeftAccessory={() => (
<SvgXml
@@ -1077,7 +1097,21 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
onPress={gotoScan}
//style={{backgroundColor: scanButtonColor}}
text='Scan QR instead'
/>)}
/>)}
{isPaid && (<Button
preset='tertiary'
LeftAccessory={() => (
<Icon
icon="faXmark"
size={spacing.medium}
color={scanIcon as string}
style={{marginHorizontal: spacing.extraSmall}}
/>
)}
onPress={gotoWallet}
//style={{backgroundColor: scanButtonColor}}
text='Close'
/>)}
</View>
</View>
</View>
@@ -1172,11 +1206,59 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
)
})
interface PulsingContactlessIconProps {
isNfcEnabled: boolean;
size?: number;
}
const $screen: ViewStyle = { flex: 1 }
const $contentContainer: ViewStyle = { flex: 1, marginTop: -spacing.extraLarge * 2, padding: spacing.extraSmall }
const $headerContainer: TextStyle = { alignItems: 'center', paddingBottom: spacing.medium, height: spacing.screenHeight * 0.15 }
const $buttonContainer: ViewStyle = { marginTop: spacing.large, flexDirection: 'row', alignSelf: 'center' }
export const PulsingContactlessIcon: React.FC<PulsingContactlessIconProps> = ({
isNfcEnabled,
size,
}) => {
const scale = useSharedValue(1);
// Start/stop pulse based on nfcBroadcast
useEffect(() => {
if (isNfcEnabled) {
scale.value = withRepeat(
withTiming(1.15, {
duration: 1000,
easing: Easing.out(Easing.quad),
}),
-1, // infinite
true // reverse (so it goes 1 → 1.22 → 1 → 1.22...)
);
} else {
scale.value = withTiming(1, { duration: 300 });
}
}, [isNfcEnabled, scale]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const color = isNfcEnabled
? colors.palette.success300
: colors.light.text
return (
<Animated.View style={[animatedStyle, { alignSelf: 'center' }]}>
<SvgXml
width={size || spacing.medium}
height={size || spacing.medium}
xml={NfcIcon}
stroke={'white'}
fill={'white'}
/>
</Animated.View>
);
};
const $screen: ViewStyle = { }
const $contentContainer: ViewStyle = { flex: 1, padding: spacing.extraSmall }
const $headerContainer: TextStyle = { alignItems: 'center', paddingVertical: spacing.medium,}
const $buttonContainer: ViewStyle = { flex: 1, marginTop: spacing.large, flexDirection: 'row', alignSelf: 'center' }
const $bottomContainer: ViewStyle = {
position: 'absolute',
bottom: 0,
@@ -1188,8 +1270,62 @@ const $bottomContainer: ViewStyle = {
alignSelf: 'stretch',
}
const $warning: TextStyle = {
position: 'absolute',
top: 0,
backgroundColor: colors.palette.orange400,
borderRadius: spacing.extraSmall,
paddingHorizontal: spacing.tiny,
marginTop: spacing.medium
alignSelf: 'center',
zIndex: 1,
marginVertical: spacing.small,
}
const $mintCardOuter: ViewStyle = {
marginHorizontal: spacing.small,
borderRadius: spacing.medium + 2,
borderWidth: 1.5,
shadowColor: colors.palette.neutral900,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 6,
overflow: 'hidden',
}
const $metalShine: ViewStyle = {
position: 'absolute',
top: -50,
left: -50,
width: '150%',
height: '70%',
borderBottomWidth: 1,
transform: [{ rotate: '-15deg' }],
}
const $mintCard: ViewStyle = {
height: spacing.screenHeight * 0.25,
borderRadius: spacing.medium,
padding: spacing.medium,
backgroundColor: 'transparent',
shadowOpacity: 0,
elevation: 0,
}
const $cardContent: ViewStyle = {
marginVertical: spacing.small,
flex: 1,
justifyContent: 'space-between',
}
const $cardTopRow: ViewStyle = {
flexDirection: 'row',
alignItems: 'center',
}
const $mintIconFallback: ViewStyle = {
width: moderateScale(28),
height: moderateScale(28),
borderRadius: moderateScale(14),
}
const $cardBalanceRow: ViewStyle = {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: spacing.medium,
}
const $cardBottomRow: ViewStyle = {
marginTop: spacing.small,
}
+96 -47
View File
@@ -13,6 +13,9 @@ import { translate, TxKeyPath } from "../../i18n"
import { log } from "../../services"
import { Token, getDecodedToken, getEncodedToken } from '@cashu/cashu-ts'
import { NfcService } from '../../services/nfcService';
import { SvgXml } from 'react-native-svg';
import { NfcIcon } from '../../components/NfcIcon';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest'
@@ -203,44 +206,63 @@ export const QRCodeBlock = function (props: {
// NFC Functions
const startNFCSimulation = async () => {
const dataToBroadcast = encodedV3Token || qrCodeData
const tag = new NFCTagType4({
type: NFCTagType4NDEFContentType.Text,
content: dataToBroadcast,
writable: false,
})
const session = await HCESession.getInstance()
session.setApplication(tag)
await session.setEnabled(true)
log.trace('[startNFCSimulation] Session enabled')
simulationRef.current = session
const dataToBroadcast = encodedV3Token || qrCodeData
log.trace('[startNFCSimulation] Starting with data length:', dataToBroadcast.length)
const tag = new NFCTagType4({
type: NFCTagType4NDEFContentType.Text,
content: dataToBroadcast,
writable: false,
})
const session = await HCESession.getInstance()
if (!session) {
throw new Error('Failed to get HCE session instance')
}
session.setApplication(tag)
await session.setEnabled(true)
simulationRef.current = session
log.trace('[startNFCSimulation] Session enabled')
}
const stopNFCSimulation = async () => {
if (simulationRef.current) {
try {
await simulationRef.current.setEnabled(false)
log.trace('[stopNFCSimulation] Session closed')
} catch (error) {
console.warn('Failed to terminate NFC session:', error)
}
if (!simulationRef.current) {
log.trace('[stopNFCSimulation] No active session to stop')
return
}
try {
await simulationRef.current.setEnabled(false)
log.trace('[stopNFCSimulation] Session closed')
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
log.warn('[stopNFCSimulation] Failed to terminate session:', message)
} finally {
simulationRef.current = null
}
}
const toggleNFC = async () => {
if (nfcBroadcast) {
await stopNFCSimulation()
setNfcBroadcast(prev => !prev)
try {
await stopNFCSimulation()
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to stop NFC'
log.error('[toggleNFC] Stop failed:', message)
} finally {
setNfcBroadcast(false)
}
} else {
try {
await startNFCSimulation()
setNfcBroadcast(prev => !prev)
} catch(e: any) {
log.error('NFC simulation failed to start: ', e.message, {stack: e.stack})
infoMessage(e.message)
//await startNFCSimulation()
setNfcBroadcast(true)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to start NFC'
log.error('[toggleNFC] Start failed:', message, e instanceof Error ? { stack: e.stack } : {})
infoMessage(message)
}
}
}
}
const qrCodeSize = size || spacing.screenWidth - spacing.large * 2
@@ -308,17 +330,6 @@ export const QRCodeBlock = function (props: {
pressedStyle={{backgroundColor: colors.light.buttonTertiaryPressed}}
style={{ minHeight: verticalScale(40), paddingVertical: verticalScale(spacing.tiny) }}
/>
{/*type === 'EncodedV4Token' && keysetFormat === 'hex' && !isAnimating && (
<Button
preset="tertiary"
tx={encodedV3Token ? "qrCodeNewFormatButton" : "qrCodeOldFormatButton"}
onPress={switchTokenEncoding}
LeftAccessory={() => <Icon icon='faMoneyBill1' size={spacing.small} color={colors.light.text}/>}
textStyle={{color: colors.light.text, fontSize: 14}}
pressedStyle={{backgroundColor: colors.light.buttonTertiaryPressed}}
style={{ minHeight: verticalScale(40), paddingVertical: verticalScale(spacing.tiny) }}
/>
)*/}
{type === 'EncodedV4Token' && !isQRCodeError && (
<Button
preset="tertiary"
@@ -334,20 +345,12 @@ export const QRCodeBlock = function (props: {
{Platform.OS === 'android' && isStringSafeForNfc && isNfcSupported && (
<Button
preset="tertiary"
//text={nfcBroadcast ? "NFC active" : "NFC"}
onPress={isNfcEnabled ? toggleNFC : () => Alert.alert('Enable NFC in device settings')}
LeftAccessory={() => (
<Icon
icon='faNfcSymbol'
size={spacing.medium}
color={nfcBroadcast ? colors.palette.success300 : colors.light.text}
<PulsingContactlessIcon
nfcBroadcast={nfcBroadcast}
/>
)}
textStyle={{
color: nfcBroadcast ? colors.palette.success300 : colors.light.text,
fontSize: 14,
fontWeight: nfcBroadcast ? 'bold' : 'normal'
}}
pressedStyle={{ backgroundColor: colors.light.buttonTertiaryPressed }}
style={{
minHeight: verticalScale(40),
@@ -361,6 +364,52 @@ export const QRCodeBlock = function (props: {
)
}
interface PulsingContactlessIconProps {
nfcBroadcast: boolean;
}
export const PulsingContactlessIcon: React.FC<PulsingContactlessIconProps> = ({
nfcBroadcast,
}) => {
const scale = useSharedValue(1);
// Start/stop pulse based on nfcBroadcast
useEffect(() => {
if (nfcBroadcast) {
scale.value = withRepeat(
withTiming(1.25, {
duration: 1000,
easing: Easing.out(Easing.quad),
}),
-1, // infinite
true // reverse (so it goes 1 → 1.22 → 1 → 1.22...)
);
} else {
scale.value = withTiming(1, { duration: 300 });
}
}, [nfcBroadcast, scale]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const color = nfcBroadcast
? colors.palette.success300
: colors.light.text
return (
<Animated.View style={[animatedStyle, { alignSelf: 'center' }]}>
<SvgXml
width={spacing.medium}
height={spacing.medium}
xml={NfcIcon}
stroke={color}
fill={color}
/>
</Animated.View>
);
};
const $qrCodeContainer: ViewStyle = {
alignItems: 'center',
backgroundColor: 'white',
+9 -2
View File
@@ -55,6 +55,7 @@ import { LeftProfileHeader } from './ContactsScreen'
import { getUnixTime } from 'date-fns/getUnixTime'
import FastImage from 'react-native-fast-image'
import { HotUpdater, getUpdateSource } from '@hot-updater/react-native'
import { NfcIcon } from '../components/NfcIcon'
const MINT_CHECK_INTERVAL = 60
@@ -700,7 +701,7 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
/>}
TitleActionComponent={<HeaderTitle />}
RightActionComponent={
<View style={{justifyContent: 'flex-end', flexDirection: 'row'}}>
<View style={{justifyContent: 'flex-end', flexDirection: 'row', alignItems: 'center'}}>
{pendingCount > 0 && (
<Pressable
style={{flexDirection: 'row', alignItems:'center', marginRight: spacing.small}}
@@ -714,7 +715,13 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
style={{marginRight: spacing.medium}}
onPress={gotoNfcPay}
>
<Icon icon='faNfcSymbol' color={headerTitleColor}/>
<SvgXml
width={spacing.medium}
height={spacing.medium}
xml={NfcIcon}
stroke={headerTitleColor}
fill={headerTitleColor}
/>
</Pressable>
</View>
}
+1 -14
View File
@@ -14503,7 +14503,6 @@ __metadata:
react-native-flash-message: ^0.4.2
react-native-gesture-handler: ^2.28.0
react-native-hce: ^0.3.0
react-native-indicators: ^0.17.0
react-native-json-tree: ^1.5.0
react-native-keychain: ^10.0.0
react-native-localize: ^3.5.2
@@ -15948,7 +15947,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:^15.5.10, prop-types@npm:^15.7.2, prop-types@npm:^15.8.0, prop-types@npm:^15.8.1":
"prop-types@npm:^15.7.2, prop-types@npm:^15.8.0, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
@@ -16407,18 +16406,6 @@ __metadata:
languageName: node
linkType: hard
"react-native-indicators@npm:^0.17.0":
version: 0.17.0
resolution: "react-native-indicators@npm:0.17.0"
dependencies:
prop-types: ^15.5.10
peerDependencies:
react: "*"
react-native: "*"
checksum: 69d0d485b6a4d81f2daaa98ca9029d0ed760470a5ae9c5cce2b81132224b5116ea9bd6829c6bc0d7b0203db4c43e876020005122c306f1a5734c4629c4bb7933
languageName: node
linkType: hard
"react-native-iphone-screen-helper@npm:^2.0.2":
version: 2.1.2
resolution: "react-native-iphone-screen-helper@npm:2.1.2"