diff --git a/.github/skills/frontend-design/SKILL_MEMORY.md b/.github/skills/frontend-design/SKILL_MEMORY.md index 1d6ab232..faae02d7 100644 --- a/.github/skills/frontend-design/SKILL_MEMORY.md +++ b/.github/skills/frontend-design/SKILL_MEMORY.md @@ -35,9 +35,39 @@ Action: [How SKILL.md should be updated, if at all] --- -**Awaiting first feedback entry...** +[2026-07-03] - SUCCESS - Impact: HIGH +Context: Brand/color unification pass across the whole app (light-mode +gradients, error boundary, toasts, skeletons, default wallet color) plus a +new transaction review sheet and shared button primitive. +Outcome: The app previously shipped two palettes at once — a Bitcoin-orange +theme in constants/themes.ts and a legacy cyan/coral set hardcoded in +components. Deriving every surface from theme tokens (and mirroring the +dark-branch gradient structure in light mode) made light and dark feel like +one product. System-appearance-driven theming (light/dark/system, default +system) replaced the manual-only toggle. +Learning: Hardcoded hex "hotspots" cluster in three places: loading/empty +fallbacks copy-pasted across screens, error/warning cards, and orphaned +"delight" components that were built against an older brand and never +imported. A grep for the legacy hex values is a fast completeness check. +Action: Emphasize "no raw hex where a theme token exists" and recommend a +single ScreenLoading-style fallback component per app. -When the frontend-design skill is used to create mobile UIs, outcomes should be recorded here. Track what works, what doesn't, and how mobile design thinking evolves. +--- + +[2026-07-03] - SUCCESS - Impact: MEDIUM +Context: Interaction-polish follow-up: unified press feedback across the +whole app and finished the animation-system consolidation. +Outcome: Two-tier press-feedback system — AppButton (spring scale + +haptics) for real CTAs, PressableOpacity (opacity dim) for rows and icon +buttons — gives every tap target consistent feedback without redesigning +bespoke layouts. Migrating the last legacy Animated code to Reanimated +means one animation vocabulary (shared values + withTiming/withSpring) +across splash, tab transitions, charts, and list rows. +Learning: A mechanical drop-in wrapper (PressableOpacity) converts a large +legacy surface safely; reserving the expressive primitive (AppButton) for +deliberate CTAs keeps visual hierarchy meaningful. +Action: Document the two-tier press-feedback convention as the default +pattern for new screens. --- diff --git a/.github/skills/react-native-skills/SKILL_MEMORY.md b/.github/skills/react-native-skills/SKILL_MEMORY.md new file mode 100644 index 00000000..7066b110 --- /dev/null +++ b/.github/skills/react-native-skills/SKILL_MEMORY.md @@ -0,0 +1,89 @@ +# React Native Skills Memory + +This file captures learnings and feedback from applying the react-native-skills rules. It serves as a living memory that informs future updates to SKILL.md (see AGENTS.md for the feedback loop). + +## Feedback Log + +### Template for New Entries +``` +[YYYY-MM-DD] - [SUCCESS/FAILURE/PARTIAL] - [Impact: HIGH/MEDIUM/LOW] +Context: [What was being built/changed] +Outcome: [What happened] +Learning: [Insights gained] +Action: [How SKILL.md should be updated, if at all] +``` + +### Entries + +--- + +[2026-07-03] - SUCCESS - Impact: HIGH +Context: App-wide visual + performance revamp (theme extraction, formSheet +modals, AppButton primitive, send-flow review sheet, list virtualization). +Outcome: Rules §9.3 (Pressable over TouchableOpacity) and §9.7 (native +formSheet modals) were directly applicable and produced noticeably better +UX: the new `components/AppButton.tsx` is Pressable-based with Reanimated +spring feedback, and five bare `animationType="slide"` JS modals (settings +currency/auto-lock/wallet pickers, home + manage-wallets edit dialogs) plus +the new send review sheet now use `presentationStyle="formSheet"`. +Learning: When converting a transparent JS overlay modal to formSheet, the +`transparent` prop and the manual dark overlay View must both be removed, +and the content container becomes the sheet root (flex: 1, themed +background). Selection rows benefit from a HapticService.light() call. +Legacy screens still use TouchableOpacity extensively; converting only +high-traffic CTAs first kept the diff reviewable. +Action: SKILL.md could add a short "converting existing JS modals to +formSheet" checklist (remove transparent + overlay wrapper; flex: 1 root; +onRequestClose still required for Android back). + +--- + +[2026-07-03] - SUCCESS - Impact: HIGH +Context: Deferred-items pass — store re-render fix, N+1 request reduction, +legacy Animated migration, app-wide Pressable sweep. +Outcome: (1) Splitting a single-context "God store" into churn-domain +contexts (one context per already-memoized slice) delivered selector-like +subscriptions without moving any query/mutation logic — a much safer path +than a Zustand bridge, which would have introduced one-commit lag between +data slices. (2) Rule §9.3 was completed repo-wide via a drop-in +`PressableOpacity` (Pressable mimicking activeOpacity), converting 32 files +mechanically with zero type errors; only `createAnimatedComponent(TouchableOpacity)` +bases remain. (3) All legacy `Animated` code migrated to Reanimated — +notably, an unmemoized `useFocusEffect(fn)` (no useCallback) had been +replaying the tab entrance animation on every re-render while focused; +the Reanimated rewrite with a memoized callback fixed it. +Learning: For context-hook stores, publish each memoized slice through its +own context and keep the legacy hook as a compat shim; convert consumers +incrementally. Never place whole React Query objects in a slice memo's +value/deps — identity churns every render. +Action: Consider adding a rule about churn-domain context splitting for +large context stores, and a note that useFocusEffect callbacks must be +memoized. + +--- + +[2026-07-04] - SUCCESS - Impact: MEDIUM +Context: Final remainder pass — completed the narrow-hook migration +(deleted the compat all-slices hook) and added Esplora confirmed-history +pagination to the data pipeline. +Outcome: Removing the combined hook after full conversion prevents new +code from silently reintroducing app-wide re-render fanout. Pagination +required a "raw page" mode in the fetch layer because the existing +cache-merge on /txs responses would have poisoned the continuation +cursor; raw requests also needed a distinct dedupe key. +Learning: When a fetch layer transparently merges cache into responses, +any cursor-based pagination built on top MUST bypass the merge for the +page it derives cursors from. An early-stop on "full page of already +cached items" is sound for append-only (prepend-only) histories. +Action: Worth a data-layer rule: pagination cursors must come from raw +provider responses, never cache-augmented ones. + +--- + +## Pending SKILL.md Updates + +- Consider a migration checklist for JS `` → `presentationStyle="formSheet"` (see 2026-07-03 entry). + +## Changelog of Memory-Driven Updates + +**None yet** - This is the initial version diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 7789b3e7..b8941998 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,26 +1,17 @@ -import { lightTheme } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import Ionicons from '@expo/vector-icons/Ionicons'; import { Icon, Label, NativeTabs, VectorIcon } from 'expo-router/unstable-native-tabs'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Platform } from 'react-native'; export default function TabLayout() { - // Use theme from WalletProvider context for consistency - // Fallback to lightTheme if context is not available yet - const walletContext = useWallet(); - const theme = walletContext?.theme ?? lightTheme; - const [themeKey, setThemeKey] = useState(0); - - // Force re-render when theme changes - useEffect(() => { - // Increment key to force NativeTabs remount with new theme - setThemeKey(prev => prev + 1); - }, [theme]); + const { theme, isDark } = useTheme(); return ( - Loading... - - ); + if (!walletData) { + return ; } - + return ; } -// Main component with all hooks +// Main component with all hooks. This screen legitimately renders balance and +// transactions (keeps that churn) but sheds address/utxo/feedback-state churn. function WalletScreenContent() { const { animatedStyle } = useTabAnimation(0); // Wallet tab = index 0 + const { wallets, currentWallet, currentWalletId, isLoading } = useWallets(); + const { balance, balanceUSD, bitcoinPrice, hasBalanceError, hasPriceError } = useWalletBalance(); + const { transactions, hasTransactionsError } = useWalletTransactions(); + const { switchWallet, editWallet, refreshData, formatCurrency, setHideBalanceSetting } = useWalletActions(); + const { hideBalance } = useWalletSettings(); const { - wallets, - currentWallet, - currentWalletId, - switchWallet, - editWallet, - balance, - balanceUSD, - bitcoinPrice, - transactions, - theme, - refreshData, - hasBalanceError, - hasTransactionsError, - hasPriceError, - isLoading, - formatCurrency, - hideBalance, - setHideBalanceSetting, shouldShowFeedbackPrompt, markFeedbackPromptShown, markFeedbackPromptDismissed, markFeedbackSubmitted, incrementUsageCount, - } = useWallet()!; // Non-null assertion is safe here because wrapper checked + } = useFeedback(); + const { theme } = useTheme(); // Initialize all state hooks const [refreshing, setRefreshing] = useState(false); @@ -196,7 +192,7 @@ function WalletScreenContent() { const renderCarouselItem = useCallback(({ item }: { item: CarouselItem }) => { if (item.type === 'add') { return ( - Add new wallet - + ); } return ( @@ -262,12 +258,12 @@ function WalletScreenContent() { Create or import a wallet to get started - router.push('/wallet-setup')} > Setup Wallet - + @@ -351,8 +347,13 @@ function WalletScreenContent() { snapToInterval={CARD_SNAP_INTERVAL} snapToAlignment="start" data={walletDataForList} - keyExtractor={(item, index) => `${item.type}-${index}`} + keyExtractor={(item) => item.type === 'wallet' ? item.wallet.id : 'add'} renderItem={renderCarouselItem} + getItemLayout={(_, index) => ({ + length: CARD_SNAP_INTERVAL, + offset: CARD_SNAP_INTERVAL * index, + index, + })} contentContainerStyle={styles.carouselContent} removeClippedSubviews={true} maxToRenderPerBatch={3} @@ -391,17 +392,17 @@ function WalletScreenContent() { Bitcoin APIs are temporarily unavailable. Your wallet is safe. - Retry - + ) : ( <> - { HapticService.light(); @@ -414,7 +415,7 @@ function WalletScreenContent() { ) : ( )} - + {hideBalance ? '••••••••' : (hasPriceError ? `${balance.toFixed(8)} BTC` : formatCurrency(balanceUSD))} @@ -445,7 +446,7 @@ function WalletScreenContent() { ]}> {(['1D', '1W', '1M', '1Y', 'All'] as TimePeriod[]).map((period) => ( - {period} - + ))} @@ -492,35 +493,27 @@ function WalletScreenContent() { {/* Action Buttons */} - } onPress={() => { HapticService.medium(); router.push('/(tabs)/send'); }} - activeOpacity={0.85} - > - - Send - - - + } onPress={() => { HapticService.medium(); router.push('/(tabs)/receive'); }} - activeOpacity={0.85} - > - - Receive - + style={styles.actionButton} + testID="home-receive-button" + /> {/* Recent Transactions */} @@ -536,11 +529,11 @@ function WalletScreenContent() { Recent Transactions - router.push('/transaction-history')}> + router.push('/transaction-history')}> View All - + @@ -553,12 +546,12 @@ function WalletScreenContent() { Bitcoin APIs are temporarily unavailable. Your transactions are safe. - Retry - + ) : transactions.length === 0 ? ( @@ -582,155 +575,82 @@ function WalletScreenContent() { {/* Edit Wallet Modal */} - {isIOS26OrHigher() ? ( - - - - Edit Wallet - - - - - - - Wallet Name - - - Color - - {WALLET_COLOR_PALETTE.map((colorOption) => { - const gradientColors = colorOption.gradient; - return ( - setEditColor(colorOption.base)} - > - - {editColor === colorOption.base && ( - - - - )} - - ); - })} - - - - - - Cancel - - - - Save - - - - - ) : ( - - - - Edit Wallet - - - - - - - Wallet Name - - - Color - - {WALLET_COLOR_PALETTE.map((colorOption) => { - const gradientColors = colorOption.gradient; - return ( - setEditColor(colorOption.base)} - > - - {editColor === colorOption.base && ( - - - - )} - - ); - })} - - - - - - Cancel - - - - Save - - + + + Edit Wallet + + + + + + + Wallet Name + + + Color + + {WALLET_COLOR_PALETTE.map((colorOption) => { + const gradientColors = colorOption.gradient; + return ( + { + HapticService.light(); + setEditColor(colorOption.base); + }} + > + + {editColor === colorOption.base && ( + + + + )} + + ); + })} - )} + + + + + + {/* Feedback Popup */} @@ -893,36 +813,9 @@ const styles = StyleSheet.create({ marginTop: platformStyles.spacing.xxxl, // Increased from xxl gap: platformStyles.spacing.lg, }, - sendButton: { + actionButton: { flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: platformStyles.spacing.lg + 2, // Increased - borderRadius: platformStyles.borderRadius.xxl, // Increased from xl - ...platformStyles.buttonShadow, - }, - receiveButton: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: platformStyles.spacing.lg + 2, // Increased - borderRadius: platformStyles.borderRadius.xxl, // Increased from xl - ...platformStyles.shadow, - }, - actionButtonText: { - color: 'white', - fontSize: 18, // Increased from 17 - fontWeight: '700', // Increased from 600 - marginLeft: 10, // Increased from 8 - letterSpacing: 0.2, - }, - receiveButtonText: { - fontSize: 18, // Increased from 17 - fontWeight: '700', // Increased from 600 - marginLeft: 10, // Increased from 8 - letterSpacing: 0.2, + borderRadius: platformStyles.borderRadius.xxl, }, transactionsSection: { marginTop: platformStyles.spacing.xl, @@ -1065,27 +958,14 @@ const styles = StyleSheet.create({ fontWeight: '600', // Increased from 500 letterSpacing: 0.2, }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.6)', // Increased opacity from 0.5 for better focus - justifyContent: 'center', - alignItems: 'center', - padding: platformStyles.spacing.xxl, // Increased from xl - }, editModal: { - width: '100%', - maxWidth: 420, // Increased from 400 - borderRadius: platformStyles.borderRadius.xxxl, // Increased from xxl - padding: 0, - ...platformStyles.cardShadow, + flex: 1, }, editModalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - padding: platformStyles.spacing.xxl, // Increased from xl - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', + padding: platformStyles.spacing.xxl, }, editModalTitle: { fontSize: 22, // Increased from 19 @@ -1145,36 +1025,11 @@ const styles = StyleSheet.create({ }, editModalActions: { flexDirection: 'row', - padding: platformStyles.spacing.xxl, // Increased from xl - gap: platformStyles.spacing.lg, // Increased from md - }, - editCancelButton: { - flex: 1, - paddingVertical: platformStyles.spacing.md + 4, // Increased - borderRadius: platformStyles.borderRadius.xl, // Increased from large - borderWidth: 2, // Increased from 1.5 - alignItems: 'center', - // borderColor will be set dynamically via theme.colors.border - }, - editCancelText: { - fontSize: 18, // Increased from 17 - fontWeight: '600', // Increased from 500 - letterSpacing: 0.2, - // color will be set dynamically via theme.colors.textSecondary + padding: platformStyles.spacing.xxl, + gap: platformStyles.spacing.lg, }, - editSaveButton: { + editActionButton: { flex: 1, - paddingVertical: platformStyles.spacing.md + 4, // Increased - borderRadius: platformStyles.borderRadius.xl, // Increased from large - // backgroundColor will be set dynamically via theme.colors.primary - alignItems: 'center', - ...platformStyles.buttonShadow, - }, - editSaveText: { - fontSize: 18, // Increased from 17 - fontWeight: '700', // Increased from 600 - color: 'white', - letterSpacing: 0.3, }, chartContainer: { marginTop: platformStyles.spacing.xl, diff --git a/app/(tabs)/receive.tsx b/app/(tabs)/receive.tsx index 1f1a81fe..ce30971a 100644 --- a/app/(tabs)/receive.tsx +++ b/app/(tabs)/receive.tsx @@ -1,28 +1,37 @@ +import { AppButton } from '@/components/AppButton'; +import { ScreenLoading } from '@/components/ScreenLoading'; import { GradientBackground } from '@/components/GradientBackground'; import { toast } from '@/components/Toast'; import WalletSelector from '@/components/WalletSelector'; import { ADDRESS_GENERATION_COOLDOWN_MS, GAP_LIMIT_WARNING_THRESHOLD } from '@/constants/cache'; -import { createButtonStyle, platformStyles } from '@/constants/themes'; +import { platformStyles } from '@/constants/themes'; +import { useTheme } from '@/hooks/theme-store'; import { useTabAnimation } from '@/hooks/use-tab-animation'; -import { useWallet } from '@/hooks/wallet-store'; +import { WalletsContext, useFeedback, useWalletActions, useWallets } from '@/hooks/wallet-contexts'; import { HapticService } from '@/services/haptic-service'; import { loadWalletService } from '@/utils/wallet-service-loader'; import * as Clipboard from 'expo-clipboard'; import { Stack, router } from 'expo-router'; import { Copy, RefreshCw, Share as ShareIcon } from 'lucide-react-native'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { Alert, - Animated, Platform, SafeAreaView, Share, StyleSheet, Text, - TouchableOpacity, View } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; +import Animated, { + Easing, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; // Define Android-specific bottom padding for action buttons area const ANDROID_BOTTOM_PADDING = 120; @@ -33,27 +42,28 @@ const walletService = loadWalletService([ 'clearAddressCache', ]); -// Wrapper component that checks for context availability +// Wrapper component that checks for context availability. Subscribes only to +// the low-churn wallets slice so the gate itself doesn't re-render on polls. export default function ReceiveScreen() { - const walletContext = useWallet(); - + const walletData = useContext(WalletsContext); + // Safety check: if context is not available yet, show loading - if (!walletContext) { - return ( - - Loading... - - ); + if (!walletData) { + return ; } - - return ; + + return ; } -// Main component with all hooks -function ReceiveScreenContent({ walletContext }: { walletContext: ReturnType }) { +// Main component with all hooks. Narrow subscriptions: this screen renders no +// polled data, so it stays idle across the 30s balance/tx/utxo refreshes. +function ReceiveScreenContent() { const { animatedStyle } = useTabAnimation(2); // Receive tab = index 2 - const { currentWallet, generateNewAddress, theme, incrementUsageCount } = walletContext; // Context is guaranteed non-null by wrapper - const spinValue = useRef(new Animated.Value(0)).current; + const { currentWallet } = useWallets(); + const { generateNewAddress } = useWalletActions(); + const { incrementUsageCount } = useFeedback(); + const { theme } = useTheme(); + const spinDeg = useSharedValue(0); // Initialize state hooks with empty string, will be loaded async const [currentAddress, setCurrentAddress] = useState(''); @@ -129,33 +139,27 @@ function ReceiveScreenContent({ walletContext }: { walletContext: ReturnType { - let spinAnimation: Animated.CompositeAnimation | null = null; - if (isGeneratingAddress) { - spinAnimation = Animated.loop( - Animated.timing(spinValue, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }) + spinDeg.value = 0; + spinDeg.value = withRepeat( + withTiming(360, { duration: 1000, easing: Easing.linear }), + -1, + false ); - spinAnimation.start(); } else { - spinValue.setValue(0); + cancelAnimation(spinDeg); + spinDeg.value = 0; } - + // Cleanup: stop the animation when component unmounts or isGeneratingAddress changes return () => { - if (spinAnimation) { - spinAnimation.stop(); - } + cancelAnimation(spinDeg); }; - }, [isGeneratingAddress, spinValue]); + }, [isGeneratingAddress, spinDeg]); - const spin = spinValue.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '360deg'], - }); + const spinStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${spinDeg.value}deg` }], + })); const hasValidAddress = currentAddress.trim().length > 0 && currentAddress !== 'No address available'; @@ -167,16 +171,13 @@ function ReceiveScreenContent({ walletContext }: { walletContext: ReturnType 1 ? 's' : ''} before generating another address.` - ); + toast.info('Please wait', `Wait ${waitTime} second${waitTime > 1 ? 's' : ''} before generating another address`); return; } - + if (!currentWallet) { console.error('❌ No current wallet available'); - Alert.alert('Error', 'No wallet selected'); + toast.error('No wallet selected'); return; } @@ -198,10 +199,14 @@ function ReceiveScreenContent({ walletContext }: { walletContext: ReturnType= GAP_LIMIT_WARNING_THRESHOLD) { + // Kept as an alert: the gap-limit explanation is too long for a toast + // and the user should read it before generating more addresses Alert.alert( 'Address Limit Warning', `You have generated ${addressCount} addresses. For wallet recovery, Bitcoin wallets typically scan only the first 20 addresses without transactions. Consider using existing addresses or funding some addresses before generating more.`, @@ -210,11 +215,11 @@ function ReceiveScreenContent({ walletContext }: { walletContext: ReturnType Create or import a wallet to receive funds - router.push('/wallet-setup')} - > - Setup Wallet - + style={styles.setupButton} + testID="receive-setup-wallet-button" + /> @@ -357,67 +362,49 @@ function ReceiveScreenContent({ walletContext }: { walletContext: ReturnType {/* New Address Button */} - + + + } onPress={() => { handleNewAddress(); incrementUsageCount('receive_interaction'); }} disabled={isGeneratingAddress} - activeOpacity={0.8} - > - - - - - {isGeneratingAddress ? 'Generating...' : 'New Address'} - - + style={styles.newAddressButton} + testID="receive-new-address-button" + /> {/* Action Buttons - Positioned at bottom */} - } onPress={() => { handleCopy(); incrementUsageCount('receive_interaction'); }} disabled={!currentAddress || currentAddress.length === 0} - activeOpacity={0.7} - > - - - Copy - - - - + } onPress={() => { handleShare(); incrementUsageCount('receive_interaction'); }} disabled={!currentAddress || currentAddress.length === 0} - activeOpacity={0.7} - > - - - Share - - + style={styles.actionButton} + testID="receive-share-button" + /> @@ -470,20 +457,10 @@ const styles = StyleSheet.create({ letterSpacing: 0.5, }, newAddressButton: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: platformStyles.spacing.xxxl, // Increased from xxl - paddingVertical: platformStyles.spacing.lg, // Increased from md - borderRadius: platformStyles.borderRadius.xxl, // Increased from large + alignSelf: 'center', + paddingHorizontal: platformStyles.spacing.xxxl, + borderRadius: platformStyles.borderRadius.xxl, marginBottom: platformStyles.spacing.xxxl, - ...platformStyles.buttonShadow, - }, - newAddressText: { - color: 'white', - fontSize: 18, // Increased from 17 - fontWeight: '700', // Increased from 600 - marginLeft: 10, // Increased from 8 - letterSpacing: 0.3, }, bottomActionButtons: { flexDirection: 'row', @@ -494,18 +471,7 @@ const styles = StyleSheet.create({ }, actionButton: { flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: platformStyles.spacing.lg + 2, // Increased - borderRadius: platformStyles.borderRadius.xl, // Increased from large - ...platformStyles.shadow, - }, - actionButtonText: { - fontSize: 18, // Increased from 17 - fontWeight: '700', // Increased from 600 - marginLeft: 10, // Increased from 8 - letterSpacing: 0.2, + borderRadius: platformStyles.borderRadius.xl, }, emptyState: { flex: 1, @@ -527,15 +493,8 @@ const styles = StyleSheet.create({ letterSpacing: 0.2, }, setupButton: { - paddingHorizontal: platformStyles.spacing.huge, // Increased from xxxl - paddingVertical: platformStyles.spacing.lg + 4, // Increased - borderRadius: platformStyles.borderRadius.xxl, // Increased from large - }, - setupButtonText: { - color: 'white', - fontSize: 18, // Increased from 17 - fontWeight: '700', // Increased from 600 - letterSpacing: 0.3, + paddingHorizontal: platformStyles.spacing.huge, + borderRadius: platformStyles.borderRadius.xxl, }, qrPlaceholder: { width: 240, // Increased from 220 diff --git a/app/(tabs)/send.tsx b/app/(tabs)/send.tsx index b82816ab..fc1e9c1a 100644 --- a/app/(tabs)/send.tsx +++ b/app/(tabs)/send.tsx @@ -1,22 +1,41 @@ +import { AppButton } from '@/components/AppButton'; +import { PressableOpacity } from '@/components/PressableOpacity'; +import { ScreenLoading } from '@/components/ScreenLoading'; import { GradientBackground } from '@/components/GradientBackground'; import { LiquidGlassView } from '@/components/LiquidGlassView'; import QRScanner from '@/components/QRScanner'; import { ThemedSwitch } from '@/components/ThemedSwitch'; +import { + TransactionReviewDetails, + TransactionReviewSheet, + TransactionSuccessDetails, +} from '@/components/TransactionReviewSheet'; import WalletSelector from '@/components/WalletSelector'; -import { createButtonStyle, createInputStyle, platformStyles } from '@/constants/themes'; +import { createInputStyle, platformStyles } from '@/constants/themes'; import { useAutoLock } from '@/hooks/auto-lock-store'; +import { useTheme } from '@/hooks/theme-store'; import { useTabAnimation } from '@/hooks/use-tab-animation'; -import { useWallet } from '@/hooks/wallet-store'; +import { + WalletsContext, + useCoinControl, + useFeedback, + useWalletActions, + useWalletBalance, + useWalletMeta, + useWalletSettings, + useWalletUtxos, + useWallets, +} from '@/hooks/wallet-contexts'; import { isValidBitcoinAddress, sendTransaction } from '@/services/bitcoin-service'; import { feeEstimationService } from '@/services/fee-service'; import { HapticService } from '@/services/haptic-service'; import type { UTXO } from '@/types/wallet'; import { Stack, router } from 'expo-router'; import { AlertCircle, ArrowUpRight, CheckCircle, ChevronRight, Coins, QrCode } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { Alert, - Animated, + KeyboardAvoidingView, Modal, Platform, SafeAreaView, @@ -24,48 +43,39 @@ import { StyleSheet, Text, TextInput, - TouchableOpacity, View, } from 'react-native'; +import Animated from 'react-native-reanimated'; -// Wrapper component that checks for context availability +// Wrapper component that checks for context availability. Subscribes only to +// the low-churn wallets slice so the gate itself doesn't re-render on polls. export default function SendScreen() { if (__DEV__) { console.log('🔍 Send screen: Component mounted/rendered'); } - const walletContext = useWallet(); + const walletData = useContext(WalletsContext); // Safety check: if context is not available yet, show loading - if (!walletContext) { - return ( - - Loading... - - ); + if (!walletData) { + return ; } return ; } -// Main component with all hooks +// Main component with all hooks. Narrow subscriptions: the form no longer +// re-renders mid-typing when unrelated slices (transactions, addresses) poll. function SendScreenContent() { const { animatedStyle } = useTabAnimation(1); - const { - currentWallet, - balance, - theme, - coinControl, - selectedCurrency, - getCurrencySymbol, - bitcoinPrice: walletBitcoinPrice, - feeSettings, - setFeeSettings, - feeSettingsLoading, - incrementUsageCount, - utxos: walletUtxos, - refreshData, - getMnemonic, - } = useWallet()!; // Non-null assertion is safe here because wrapper checked + const { currentWallet } = useWallets(); + const { balance, bitcoinPrice: walletBitcoinPrice } = useWalletBalance(); + const coinControl = useCoinControl(); + const { selectedCurrency, feeSettings, setFeeSettings, feeSettingsLoading } = useWalletSettings(); + const { getCurrencySymbol, refreshData } = useWalletActions(); + const { incrementUsageCount } = useFeedback(); + const { utxos: walletUtxos } = useWalletUtxos(); + const { getMnemonic } = useWalletMeta(); + const { theme } = useTheme(); const { authenticateForTransactionEnhanced, isEnhancedSecurityRequired } = useAutoLock(); // Initialize all state hooks @@ -92,6 +102,9 @@ function SendScreenContent() { isValid: boolean; message: string | null; }>({ isValid: true, message: null }); + const [reviewDetails, setReviewDetails] = useState(null); + const [successDetails, setSuccessDetails] = useState(null); + const [showReviewSheet, setShowReviewSheet] = useState(false); // Memoize computed values from feeSettings to prevent unnecessary re-renders const selectedFeeType = useMemo(() => @@ -538,25 +551,29 @@ function SendScreenContent() { } console.log('🚀 Starting real Bitcoin transaction send process...'); - const truncatedAddress = recipientAddress.substring(0, 20) + '...'; - console.log('Transaction details:', { - from: currentWallet?.name, - to: truncatedAddress, - amount: amountInBTC, - feeRate, - enableRBF, - network: 'mainnet', - enhancedSecurity: isEnhancedSecurityRequired - }); + if (__DEV__) { + const truncatedAddress = recipientAddress.substring(0, 20) + '...'; + console.log('Transaction details:', { + from: currentWallet?.name, + to: truncatedAddress, + amount: amountInBTC, + feeRate, + enableRBF, + network: 'mainnet', + enhancedSecurity: isEnhancedSecurityRequired + }); + } // Send the real transaction const selected = availableUtxos.filter(u => selectedUtxoIds.includes(`${u.txid}:${u.vout}`)); // Validate wallet addresses and current index - console.log('🔍 Wallet validation - addresses count:', currentWallet.addresses?.length || 0); - console.log('🔍 Wallet validation - current address index:', currentWallet.currentAddressIndex); - console.log('🔍 Wallet validation - available UTXOs count:', availableUtxos.length); - console.log('🔍 Wallet validation - selected UTXOs count:', selected.length); + if (__DEV__) { + console.log('🔍 Wallet validation - addresses count:', currentWallet.addresses?.length || 0); + console.log('🔍 Wallet validation - current address index:', currentWallet.currentAddressIndex); + console.log('🔍 Wallet validation - available UTXOs count:', availableUtxos.length); + console.log('🔍 Wallet validation - selected UTXOs count:', selected.length); + } if (!currentWallet.addresses || currentWallet.addresses.length === 0) { console.error('❌ No addresses available in wallet'); @@ -581,21 +598,23 @@ function SendScreenContent() { const currentAddress = currentWallet.addresses[currentWallet.currentAddressIndex]; const addressIndex = currentWallet.currentAddressIndex; - console.log('🔍 Using current address:', currentAddress.substring(0, 10) + '...'); - console.log('🔍 Send screen: Wallet addresses being passed to sendTransaction:', currentWallet.addresses.length); - console.log('🔍 Send screen: availableUtxos.length:', availableUtxos.length); - console.log('🔍 Send screen: selected.length:', selected.length); - console.log('🔍 Send screen: availableUtxos details:', availableUtxos.map(u => ({ - txid: u.txid.substring(0, 10) + '...', - vout: u.vout, - value: u.value, - address: u.address?.substring(0, 10) + '...', - addressIndex: u.addressIndex, - frozen: u.frozen - }))); - console.log('🔍 Send screen: selectedUtxoIds:', selectedUtxoIds); - console.log('🔍 Send screen: coinControl.getWalletUtxos result:', coinControl.getWalletUtxos(currentWallet.id)); - console.log('🔍 Send screen: coinControl.isUtxosLoading result:', coinControl.isUtxosLoading(currentWallet.id)); + if (__DEV__) { + console.log('🔍 Using current address:', currentAddress.substring(0, 10) + '...'); + console.log('🔍 Send screen: Wallet addresses being passed to sendTransaction:', currentWallet.addresses.length); + console.log('🔍 Send screen: availableUtxos.length:', availableUtxos.length); + console.log('🔍 Send screen: selected.length:', selected.length); + console.log('🔍 Send screen: availableUtxos details:', availableUtxos.map(u => ({ + txid: u.txid.substring(0, 10) + '...', + vout: u.vout, + value: u.value, + address: u.address?.substring(0, 10) + '...', + addressIndex: u.addressIndex, + frozen: u.frozen + }))); + console.log('🔍 Send screen: selectedUtxoIds:', selectedUtxoIds); + console.log('🔍 Send screen: coinControl.getWalletUtxos result:', coinControl.getWalletUtxos(currentWallet.id)); + console.log('🔍 Send screen: coinControl.isUtxosLoading result:', coinControl.isUtxosLoading(currentWallet.id)); + } // If no UTXOs are selected via coin control, automatically select the most efficient UTXOs let utxosToUse: UTXO[]; @@ -610,14 +629,16 @@ function SendScreenContent() { } utxosToUse = unfrozenSelected; - console.log('🔍 Send screen: Using manually selected unfrozen UTXOs:', utxosToUse.length); - if (unfrozenSelected.length < selected.length) { - console.log('🔍 Send screen: Filtered out', selected.length - unfrozenSelected.length, 'frozen/unconfirmed UTXOs'); + if (__DEV__) { + console.log('🔍 Send screen: Using manually selected unfrozen UTXOs:', utxosToUse.length); + if (unfrozenSelected.length < selected.length) { + console.log('🔍 Send screen: Filtered out', selected.length - unfrozenSelected.length, 'frozen/unconfirmed UTXOs'); + } } } else { // Automatic UTXO selection: choose confirmed UTXOs (not frozen) - BlueWallet approach const unfrozenUtxos = availableUtxos.filter(utxo => !utxo.frozen && utxo.status?.confirmed); - console.log('🔍 Send screen: Available confirmed unfrozen UTXOs:', unfrozenUtxos.length); + if (__DEV__) console.log('🔍 Send screen: Available confirmed unfrozen UTXOs:', unfrozenUtxos.length); if (unfrozenUtxos.length === 0) { console.warn('⚠️ No confirmed unfrozen UTXOs available for automatic selection'); @@ -632,9 +653,11 @@ function SendScreenContent() { const estimatedFeeSatoshis = Math.floor((estimatedFee || 0.0001) * 1e8); const totalNeeded = amountSatoshis + estimatedFeeSatoshis; - console.log('🔍 Send screen: Amount needed:', amountSatoshis, 'sats'); - console.log('🔍 Send screen: Estimated fee:', estimatedFeeSatoshis, 'sats'); - console.log('🔍 Send screen: Total needed:', totalNeeded, 'sats'); + if (__DEV__) { + console.log('🔍 Send screen: Amount needed:', amountSatoshis, 'sats'); + console.log('🔍 Send screen: Estimated fee:', estimatedFeeSatoshis, 'sats'); + console.log('🔍 Send screen: Total needed:', totalNeeded, 'sats'); + } // Greedy selection: pick UTXOs until we have enough - BlueWallet approach const selectedUtxos: UTXO[] = []; @@ -655,39 +678,28 @@ function SendScreenContent() { } utxosToUse = selectedUtxos; - console.log('🔍 Send screen: Automatically selected', utxosToUse.length, 'UTXOs'); - console.log('🔍 Send screen: Total selected value:', totalSelected, 'sats'); - console.log('🔍 Send screen: Change amount:', totalSelected - totalNeeded, 'sats'); - console.log('🔍 Send screen: Sample selected UTXO:', utxosToUse[0] ? { - txid: utxosToUse[0].txid.substring(0, 10) + '...', - vout: utxosToUse[0].vout, - value: utxosToUse[0].value, - address: utxosToUse[0].address?.substring(0, 10) + '...' - } : 'No UTXOs'); + if (__DEV__) { + console.log('🔍 Send screen: Automatically selected', utxosToUse.length, 'UTXOs'); + console.log('🔍 Send screen: Total selected value:', totalSelected, 'sats'); + console.log('🔍 Send screen: Change amount:', totalSelected - totalNeeded, 'sats'); + console.log('🔍 Send screen: Sample selected UTXO:', utxosToUse[0] ? { + txid: utxosToUse[0].txid.substring(0, 10) + '...', + vout: utxosToUse[0].vout, + value: utxosToUse[0].value, + address: utxosToUse[0].address?.substring(0, 10) + '...' + } : 'No UTXOs'); + } } - console.log('🔍 Send screen: UTXOs to use for transaction:', utxosToUse.length); - - // If we have UTXOs available, use them directly instead of fetching fresh - if (utxosToUse.length > 0) { - console.log('✅ Using pre-loaded UTXOs for transaction (automatic selection)'); - } else { - console.log('⚠️ No UTXOs available in send screen, will let sendTransaction fetch fresh data'); - } - console.log('🔍 Available UTXOs:', availableUtxos.length); - console.log('🔍 Selected UTXOs:', selected.length); - console.log('🔍 Sample UTXO:', utxosToUse[0] ? { - txid: utxosToUse[0].txid.substring(0, 10) + '...', - vout: utxosToUse[0].vout, - value: utxosToUse[0].value, - address: utxosToUse[0].address?.substring(0, 10) + '...' - } : 'No UTXOs'); - - // Debug: Check if we're passing UTXOs to sendTransaction - if (utxosToUse.length > 0) { - console.log('✅ Send screen: Passing', utxosToUse.length, 'UTXOs to sendTransaction'); - } else { - console.log('⚠️ Send screen: No UTXOs to pass to sendTransaction, will let it fetch fresh'); + if (__DEV__) { + console.log('🔍 Send screen: UTXOs to use for transaction:', utxosToUse.length); + console.log('🔍 Available UTXOs:', availableUtxos.length); + console.log('🔍 Selected UTXOs:', selected.length); + if (utxosToUse.length > 0) { + console.log('✅ Send screen: Passing', utxosToUse.length, 'UTXOs to sendTransaction'); + } else { + console.log('⚠️ Send screen: No UTXOs to pass to sendTransaction, will let it fetch fresh'); + } } const result = await sendTransaction( @@ -705,58 +717,16 @@ function SendScreenContent() { console.log('✅ Real Bitcoin transaction sent successfully:', result); HapticService.transactionSuccess(); - // Show success message with transaction details - const feeFiat = bitcoinPrice && bitcoinPrice > 0 ? (result.fee * bitcoinPrice).toFixed(2) : 'N/A'; - const amountFiat = bitcoinPrice && bitcoinPrice > 0 ? (amountInBTC * bitcoinPrice).toFixed(2) : 'N/A'; - - const amountBTC = amountInBTC.toFixed(8); - const feeBTC = result.fee.toFixed(8); - const feeRateText = feeRate.toString(); - - const successMessage = `Your Bitcoin transaction has been broadcast to the mainnet network.\n\n` + - `Transaction ID: ${result.txid}\n\n` + - `Amount: ${amountBTC} BTC (${getCurrencySymbol()}${amountFiat})\n` + - `Fee: ${feeBTC} BTC (${getCurrencySymbol()}${feeFiat})\n` + - `Fee Rate: ${feeRateText} sat/vB\n\n` + - `The transaction will appear in your wallet once it receives confirmations. ` + - `This typically takes 10-60 minutes depending on network congestion.`; - // Refresh transaction history and balance to show the new transaction console.log('🔄 Refreshing wallet data after successful transaction...'); await refreshData(); - Alert.alert( - 'Transaction Broadcast Successfully! 🎉', - successMessage, - [{ - text: 'View Transaction', - onPress: () => { - // Clear form - setRecipientAddress(''); - setAmount(''); - setEstimatedFee(null); - setAddressValidation({ isValid: false, message: null }); - - // Navigate directly to transaction explorer with the txid - router.push({ - pathname: '/transaction-explorer', - params: { txid: result.txid }, - }); - } - }, { - text: 'Done', - onPress: () => { - // Clear form - setRecipientAddress(''); - setAmount(''); - setEstimatedFee(null); - setAddressValidation({ isValid: false, message: null }); - - // Navigate to home to see updated balance - router.push('/'); - } - }] - ); + // Flip the review sheet to its success state (copyable txid + actions) + setSuccessDetails({ + txid: result.txid, + amountBTC: `${amountInBTC.toFixed(8)} BTC`, + feeBTC: `${result.fee.toFixed(8)} BTC`, + }); } catch (error) { console.error('❌ Error sending Bitcoin transaction:', error); @@ -809,26 +779,15 @@ function SendScreenContent() { // Convert amount to BTC for display let amountInBTC: number; - let displayAmount: string; if (isAmountInBTC) { amountInBTC = parseFloat(amount); - const btcText = `${amount} BTC`; - displayAmount = btcText; - if (bitcoinPrice && bitcoinPrice > 0) { - const fiatValue = (amountInBTC * bitcoinPrice).toFixed(2); - const fiatDisplay = ` (${getCurrencySymbol()}${fiatValue})`; - displayAmount += fiatDisplay; - } } else { if (!bitcoinPrice || bitcoinPrice <= 0) { Alert.alert('Error', 'Unable to get current Bitcoin price. Please try again.'); return; } amountInBTC = parseFloat(amount) / bitcoinPrice; - const btcAmount = amountInBTC.toFixed(8); - const fiatBtcText = `${getCurrencySymbol()}${amount} (${btcAmount} BTC)`; - displayAmount = fiatBtcText; } // Validate amount @@ -868,77 +827,92 @@ function SendScreenContent() { } } - // Format fee display - const feeDisplay = estimatedFee ? (() => { - const feeAmount = estimatedFee.toFixed(8); - const feeText = `${feeAmount} BTC`; - return feeText; - })() : 'Calculating...'; - const feeFiatDisplay = estimatedFee && bitcoinPrice && bitcoinPrice > 0 ? (() => { - const fiatAmount = (estimatedFee * bitcoinPrice).toFixed(2); - const fiatText = ` (${getCurrencySymbol()}${fiatAmount})`; - return fiatText; - })() : ''; - - // Show comprehensive transaction review - const feeDisplayText = feeFiatDisplay ? (() => { - const combinedText = `${feeDisplay}${feeFiatDisplay}`; - return combinedText; - })() : feeDisplay; - - const recipientPreview = recipientAddress.slice(0, 30); - const feeRateText = feeRate.toString(); - const rbfStatus = enableRBF ? 'Enabled' : 'Disabled'; - - const reviewMessage = `You are about to send a REAL Bitcoin transaction on MAINNET:\n\n` + - `📤 Send: ${displayAmount}\n` + - `📍 To: ${recipientPreview}...\n\n` + - `💰 Network Fee: ${feeDisplayText}\n` + - `⚡ Fee Rate: ${feeRateText} sat/vB\n` + - `🔄 RBF: ${rbfStatus}\n\n` + - `⚠️ WARNING: This transaction cannot be reversed once broadcast!`; - - Alert.alert( - '⚠️ Review Bitcoin Transaction', - reviewMessage, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Send Bitcoin', - style: 'destructive', - onPress: async () => { - // Calculate amount for authentication - const amountInBTC = isAmountInBTC ? parseFloat(amount) : (bitcoinPrice && bitcoinPrice > 0 ? parseFloat(amount) / bitcoinPrice : 0); - - // Always use enhanced security service to ensure MFA enforcement - console.log('🔐 Checking if biometric authentication is required before final confirmation...'); - - const requireBiometricAuth = await isEnhancedSecurityRequired(amountInBTC); - - if (requireBiometricAuth) { - const biometricSuccess = await authenticateForTransactionEnhanced(true); - - if (!biometricSuccess) { - Alert.alert( - 'Biometric Required', - 'Biometric authentication is required to send funds. Please complete biometric verification and try again.', - [{ text: 'OK' }] - ); - return; - } - } - - handleSendTransaction(); - } - }, - ] - ); + // Open the native review sheet with the full transaction summary + const amountFiat = bitcoinPrice && bitcoinPrice > 0 + ? `${getCurrencySymbol()}${(amountInBTC * bitcoinPrice).toFixed(2)}` + : null; + const feeFiat = estimatedFee && bitcoinPrice && bitcoinPrice > 0 + ? `${getCurrencySymbol()}${(estimatedFee * bitcoinPrice).toFixed(2)}` + : null; + + setReviewDetails({ + amountBTC: `${amountInBTC.toFixed(8)} BTC`, + amountFiat, + recipient: recipientAddress.trim(), + feeBTC: estimatedFee ? `${estimatedFee.toFixed(8)} BTC` : 'Calculating...', + feeFiat, + feeRate, + timeEstimate: getTimeEstimateForFeeRate(feeRate), + rbfEnabled: enableRBF, + totalBTC: `${(amountInBTC + (estimatedFee || 0)).toFixed(8)} BTC`, + }); + setSuccessDetails(null); + setShowReviewSheet(true); } catch (error) { console.error('Error reviewing transaction:', error); Alert.alert('Error', 'Failed to review transaction. Please try again.'); } }; + // Confirm handler for the review sheet. The biometric gate below is the + // same sequence the old Alert confirmation ran before handleSendTransaction. + const handleConfirmSend = async () => { + // Calculate amount for authentication + const amountInBTC = isAmountInBTC ? parseFloat(amount) : (bitcoinPrice && bitcoinPrice > 0 ? parseFloat(amount) / bitcoinPrice : 0); + + // Always use enhanced security service to ensure MFA enforcement + console.log('🔐 Checking if biometric authentication is required before final confirmation...'); + + const requireBiometricAuth = await isEnhancedSecurityRequired(amountInBTC); + + if (requireBiometricAuth) { + const biometricSuccess = await authenticateForTransactionEnhanced(true); + + if (!biometricSuccess) { + Alert.alert( + 'Biometric Required', + 'Biometric authentication is required to send funds. Please complete biometric verification and try again.', + [{ text: 'OK' }] + ); + return; + } + } + + handleSendTransaction(); + }; + + const closeSheetAndClearForm = () => { + setShowReviewSheet(false); + setReviewDetails(null); + setSuccessDetails(null); + setRecipientAddress(''); + setAmount(''); + setEstimatedFee(null); + setAddressValidation({ isValid: false, message: null }); + }; + + const handleViewTransaction = () => { + const txid = successDetails?.txid; + closeSheetAndClearForm(); + if (txid) { + router.push({ + pathname: '/transaction-explorer', + params: { txid }, + }); + } + }; + + const handleDoneAfterSend = () => { + closeSheetAndClearForm(); + router.push('/'); + }; + + const handleCancelReview = () => { + if (isLoading) return; + setShowReviewSheet(false); + setReviewDetails(null); + }; + const getTimeEstimateForFeeRate = useCallback((rate: number): string => { if (!feeEstimates) return 'Calculating...'; @@ -973,12 +947,12 @@ function SendScreenContent() { No Wallet Found Create or import a wallet to send funds - router.push('/wallet-setup')} > Setup Wallet - + @@ -1018,10 +992,15 @@ function SendScreenContent() { /> + {/* From Section */} @@ -1044,7 +1023,7 @@ function SendScreenContent() { multiline underlineColorAndroid="transparent" /> - { setShowQRScanner(true); @@ -1052,7 +1031,7 @@ function SendScreenContent() { }} > - + {/* Address Validation Indicator */} @@ -1060,13 +1039,13 @@ function SendScreenContent() { {addressValidation.isValid ? ( - - {addressValidation.message} + + {addressValidation.message} ) : ( - - {addressValidation.message} + + {addressValidation.message} )} @@ -1121,13 +1100,13 @@ function SendScreenContent() { ); })()} - Send Max - + {/* Fee Section */} @@ -1152,7 +1131,7 @@ function SendScreenContent() { - {(feeEstimates?.economyFee || 1)} sat/vB - + - {(feeEstimates?.halfHourFee || 5)} sat/vB - + - {(feeEstimates?.fastestFee || 15)} sat/vB - + - Custom - + {/* Custom Fee Input */} @@ -1324,15 +1303,15 @@ function SendScreenContent() { {customFeeValidation.isValid ? ( - - + + {customFeeValidation.message} ) : ( - - + + {customFeeValidation.message} @@ -1407,7 +1386,7 @@ function SendScreenContent() { backgroundColor: theme.colors.surface, } ]}> - { router.push('/coin-control'); @@ -1440,32 +1419,21 @@ function SendScreenContent() { - + {/* Review Button */} - - - {isLoading ? 'Broadcasting Transaction...' : 'Review & Send Bitcoin'} - - + disabled={!recipientAddress || !amount || !addressValidation.isValid} + loading={isLoading} + style={styles.reviewButton} + testID="review-send-button" + /> + {/* QR Scanner Modal */} @@ -1479,6 +1447,18 @@ function SendScreenContent() { onClose={() => setShowQRScanner(false)} /> + + {/* Transaction Review / Success Sheet */} + ); @@ -1743,6 +1723,9 @@ const styles = StyleSheet.create({ fontSize: 15, fontWeight: '600', }, + keyboardAvoiding: { + flex: 1, + }, reviewButton: { marginTop: platformStyles.spacing.xl, marginBottom: platformStyles.spacing.xxxl, @@ -1751,20 +1734,6 @@ const styles = StyleSheet.create({ alignItems: 'center', ...platformStyles.buttonShadow, }, - reviewButtonDisabled: { - // backgroundColor will be set dynamically via theme.colors.border - shadowOpacity: 0, - elevation: 0, - opacity: 0.5, - }, - reviewButtonText: { - color: 'white', - fontSize: 19, - fontWeight: '600', - }, - reviewButtonTextDisabled: { - color: '#6B7280', - }, emptyState: { flex: 1, justifyContent: 'center', diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 23c4011f..56933cfb 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,10 +1,13 @@ +import { ScreenLoading } from '@/components/ScreenLoading'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { LiquidGlassView } from '@/components/LiquidGlassView'; import { ThemedSwitch } from '@/components/ThemedSwitch'; import { platformStyles } from '@/constants/themes'; import { useAutoLock } from '@/hooks/auto-lock-store'; +import { useTheme } from '@/hooks/theme-store'; import { useTabAnimation } from '@/hooks/use-tab-animation'; -import { useWallet } from '@/hooks/wallet-store'; +import { WalletsContext, useWalletActions, useWalletSettings, useWallets } from '@/hooks/wallet-contexts'; import { HapticService } from '@/services/haptic-service'; import { APP_VERSION } from '@/constants/app-version'; @@ -31,17 +34,17 @@ import { Scale, Settings, Shield, + Smartphone, Sun, UserX, Wallet, X } from 'lucide-react-native'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { Alert, Modal, Platform, - Animated as RNAnimated, SafeAreaView, ScrollView, StyleSheet, @@ -55,26 +58,27 @@ import ReanimatedAnimated, { withSpring, } from 'react-native-reanimated'; -// Wrapper component that checks for context availability +// Wrapper component that checks for context availability. Subscribes only to +// the low-churn wallets slice so the gate itself doesn't re-render on polls. export default function SettingsScreen() { - const walletContext = useWallet(); - + const walletData = useContext(WalletsContext); + // Safety check: if context is not available yet, show loading - if (!walletContext) { - return ( - - Loading... - - ); + if (!walletData) { + return ; } - + return ; } -// Main component with all hooks +// Main component with all hooks. Narrow subscriptions: settings renders no +// polled data, so it stays idle across the 30s balance/tx/utxo refreshes. function SettingsScreenContent() { const { animatedStyle } = useTabAnimation(3); // Settings tab = index 3 - const { theme, toggleTheme, logoutAndEraseWallet, currentWallet, wallets, switchWallet, selectedCurrency, setCurrency, getCurrencyName, hideBalance, setHideBalanceSetting } = useWallet()!; // Non-null assertion is safe here because wrapper checked + const { currentWallet, wallets } = useWallets(); + const { toggleTheme, logoutAndEraseWallet, switchWallet, setCurrency, getCurrencyName, setHideBalanceSetting } = useWalletActions(); + const { selectedCurrency, hideBalance } = useWalletSettings(); + const { theme, themeMode, setThemeMode } = useTheme(); const { autoLockTimeout, setAutoLockTimeout } = useAutoLock(); // Initialize all state hooks @@ -176,7 +180,7 @@ function SettingsScreenContent() { showDivider?: boolean; }) => ( <> - { if (onPress) { @@ -201,7 +205,7 @@ function SettingsScreenContent() { )} {rightElement || (onPress && )} - + {showDivider && ( )} @@ -236,7 +240,7 @@ function SettingsScreenContent() { }} /> - + + + + setThemeMode(followSystem ? 'system' : (theme.isDark ? 'dark' : 'light')) + } + theme={theme} + testID="system-theme-switch" + /> + + } + /> + - + {/* Currency Selection Modal */} setShowCurrencyModal(false)} > - - - + + Select Currency - setShowCurrencyModal(false)} style={styles.modalCloseButton} > - + {(['USD', 'EUR', 'GBP'] as FiatCurrency[]).map((currency) => ( - { + HapticService.light(); setCurrency(currency); setShowCurrencyModal(false); }} @@ -484,32 +506,30 @@ function SettingsScreenContent() { {selectedCurrency === currency && ( )} - + ))} - {/* Auto-Lock Selection Modal */} setShowAutoLockModal(false)} > - - - + + Auto-Lock Timer - setShowAutoLockModal(false)} style={styles.modalCloseButton} > - + @@ -520,7 +540,7 @@ function SettingsScreenContent() { { value: 60, label: '1 hour' }, { value: -1, label: 'Never' }, ].map((option) => ( - { try { + HapticService.light(); await setAutoLockTimeout(option.value); setShowAutoLockModal(false); } catch (error) { @@ -552,37 +573,35 @@ function SettingsScreenContent() { {autoLockTimeout === option.value && ( )} - + ))} - {/* Wallet Selection Modal */} setShowWalletModal(false)} > - - - + + Select Wallet - setShowWalletModal(false)} style={styles.modalCloseButton} > - + {wallets.map((wallet) => ( - { + HapticService.light(); switchWallet(wallet.id); setShowWalletModal(false); }} @@ -610,11 +630,10 @@ function SettingsScreenContent() { {currentWallet?.id === wallet.id && ( )} - + ))} - @@ -735,25 +754,17 @@ const styles = StyleSheet.create({ color: '#FFFFFF', // Always white on colored logout button letterSpacing: 0.5, // Increased from 0.3 }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.6)', // Increased from 0.5 - justifyContent: 'flex-end', - }, modalContent: { - borderTopLeftRadius: platformStyles.borderRadius.xxxl, // Increased from xxl - borderTopRightRadius: platformStyles.borderRadius.xxxl, - paddingTop: platformStyles.spacing.xxl, // Increased from xl - maxHeight: '70%', + flex: 1, + paddingTop: platformStyles.spacing.xxl, }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingHorizontal: platformStyles.spacing.xxl, // Increased from xl - paddingBottom: platformStyles.spacing.xxl, // Increased from xl + paddingHorizontal: platformStyles.spacing.xxl, + paddingBottom: platformStyles.spacing.xxl, borderBottomWidth: 1, - borderBottomColor: 'rgba(0, 0, 0, 0.1)', }, modalTitle: { fontSize: 24, // Increased from 21 diff --git a/app/+not-found.tsx b/app/+not-found.tsx index 5c3a6b60..87490b49 100644 --- a/app/+not-found.tsx +++ b/app/+not-found.tsx @@ -1,54 +1,55 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { platformStyles } from '@/constants/themes'; +import { useTheme } from '@/hooks/theme-store'; import { Link, Stack } from "expo-router"; import { ArrowLeft, Bot } from 'lucide-react-native'; -import { StyleSheet, Text, TouchableOpacity, View, useColorScheme } from "react-native"; +import { StyleSheet, Text, View } from "react-native"; export default function NotFoundScreen() { - const colorScheme = useColorScheme(); - const isDark = colorScheme === 'dark'; - + const { theme } = useTheme(); + return ( - - + - + - - + + 404 - Page Not Found - - + + Oh sleuth!!! It seems BitSleuth bot got lost in the digital ether. The page you're looking for might have been moved or never existed. - - + Return to Dashboard - + @@ -114,6 +115,6 @@ const styles = StyleSheet.create({ buttonText: { fontSize: 16, fontWeight: '600', - color: '#2a2a2a', + color: '#FFFFFF', }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index 4162c867..085c993f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,5 +1,6 @@ // CRITICAL: Polyfills must be imported first, before any other imports import '../polyfills'; +import { PressableOpacity } from '@/components/PressableOpacity'; // CRITICAL: Crypto must be initialized before any ECC libs import { initializeCrypto } from '../services/crypto-polyfill'; @@ -13,8 +14,9 @@ import SplashScreen from '@/components/SplashScreen'; import { ToastProvider } from '@/components/Toast'; import { platformStyles } from '@/constants/themes'; import { AutoLockProvider, useAutoLock } from '@/hooks/auto-lock-store'; +import { ThemeProvider, useTheme } from '@/hooks/theme-store'; import { useSplashScreen } from '@/hooks/use-splash-screen'; -import { WalletProvider, useWallet } from '@/hooks/wallet-store'; +import { WalletProvider } from '@/hooks/wallet-store'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { LinearGradient } from 'expo-linear-gradient'; @@ -22,7 +24,7 @@ import { Stack, router } from 'expo-router'; import * as ExpoSplashScreen from 'expo-splash-screen'; import { AlertCircle, ArrowLeft } from 'lucide-react-native'; import React, { Component, ReactNode, useEffect, useState } from 'react'; -import { Appearance, Platform, StyleSheet, Text, TouchableOpacity, View, NativeEventSubscription, NativeModules } from 'react-native'; +import { Appearance, Platform, StyleSheet, Text, View, NativeEventSubscription, NativeModules } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; @@ -108,17 +110,18 @@ class ErrorBoundary extends Component< // Use color scheme from state (reactive to changes) const isDark = this.state.colorScheme === 'dark'; - // Theme-aware colors + // Brand palette, hardcoded because this boundary renders outside providers + // (values mirror lightTheme/darkTheme in constants/themes.ts) const colors = { - background: isDark ? '#0A0A0F' : '#FEFEFE', - surface: isDark ? '#1F1F33' : '#FFFFFF', - text: isDark ? '#F7FAFC' : '#1A1A1A', - textSecondary: isDark ? '#A0AEC0' : '#6B7280', - border: isDark ? '#2D3748' : '#FFE5DB', - gradientStart: isDark ? '#26F5FE' : '#FF8A65', - gradientEnd: isDark ? '#00BCD4' : '#FF6B6B', - iconBg: isDark ? '#252538' : '#FFF5F2', - iconColor: isDark ? '#26F5FE' : '#FF8A65', + background: isDark ? '#09090B' : '#F2F2F7', + surface: isDark ? '#18181B' : '#FFFFFF', + text: isDark ? '#FAFAFA' : '#18181B', + textSecondary: isDark ? '#A1A1AA' : '#8E8E93', + border: isDark ? '#27272A' : '#E5E5EA', + gradientStart: '#F7931A', + gradientEnd: '#FFAB40', + iconBg: isDark ? '#27272A' : '#FFF3E0', + iconColor: '#F7931A', }; return ( @@ -139,7 +142,7 @@ class ErrorBoundary extends Component< The app encountered an error. You can try again, or if the issue persists, please force close and restart BitSleuth Wallet. - { this.setState({ hasError: false }); @@ -155,7 +158,7 @@ class ErrorBoundary extends Component< > Try Again - + ); @@ -234,7 +237,7 @@ const rootStyles = StyleSheet.create({ }); function AppContent() { - const { theme } = useWallet(); + const { theme } = useTheme(); return ( ( - router.back()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > - + ), }} > @@ -352,13 +355,15 @@ function RootLayoutNav() { return ( - - - - - - - + + + + + + + + + ); } diff --git a/app/about.tsx b/app/about.tsx index 6f854b61..d788b113 100644 --- a/app/about.tsx +++ b/app/about.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { View, Text, StyleSheet, - TouchableOpacity, ScrollView, Linking, Alert, @@ -11,7 +11,7 @@ import { } from 'react-native'; import { Stack, router } from 'expo-router'; import { ChevronDown, ChevronRight, ArrowLeft, AlertTriangle, Bug } from 'lucide-react-native'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import { GradientBackground } from '@/components/GradientBackground'; import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; import crashlyticsService from '@/services/crashlytics-service'; @@ -29,11 +29,11 @@ const DropdownSection: React.FC = ({ defaultExpanded = false }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); - const { theme } = useWallet(); + const { theme } = useTheme(); return ( - setIsExpanded(!isExpanded)} > @@ -45,7 +45,7 @@ const DropdownSection: React.FC = ({ ) : ( )} - + {isExpanded && ( {children} @@ -56,7 +56,7 @@ const DropdownSection: React.FC = ({ }; export default function AboutScreen() { - const { theme } = useWallet(); + const { theme } = useTheme(); const handleBack = () => { router.back(); @@ -82,13 +82,13 @@ export default function AboutScreen() { {/* Custom Header */} - - + About BitSleuth Wallet @@ -178,7 +178,7 @@ export default function AboutScreen() { {/* Test Non-Fatal Error */} - { Alert.alert( @@ -207,10 +207,10 @@ export default function AboutScreen() { Test Non-Fatal Error - + {/* Test Fatal Crash */} - { Alert.alert( @@ -235,7 +235,7 @@ export default function AboutScreen() { Test Fatal Crash - + {/* Crashlytics Status */} diff --git a/app/address-details.tsx b/app/address-details.tsx index 64115914..7c74840e 100644 --- a/app/address-details.tsx +++ b/app/address-details.tsx @@ -1,8 +1,10 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; -import { getAddressStats, getAddressTransactions } from '@/services/esplora-service'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletActions, useWalletBalance, useWalletMeta, useWalletSettings, useWallets } from '@/hooks/wallet-contexts'; +import { getAddressStats, getAddressTransactionsPaginated } from '@/services/esplora-service'; import { useQuery } from '@tanstack/react-query'; import * as Clipboard from 'expo-clipboard'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; @@ -15,7 +17,6 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; @@ -49,7 +50,12 @@ interface Transaction { } export default function AddressDetailsScreen() { - const { theme, currentWallet, getAddressStatsCacheValue, setAddressStatsCache, priceQuery, selectedCurrency, getCurrencySymbol } = useWallet(); + const { theme } = useTheme(); + const { currentWallet } = useWallets(); + const { getAddressStatsCacheValue, setAddressStatsCache } = useWalletMeta(); + const { bitcoinPrice } = useWalletBalance(); + const { selectedCurrency } = useWalletSettings(); + const { getCurrencySymbol } = useWalletActions(); const router = useRouter(); const { address } = useLocalSearchParams<{ address: string }>(); const [showCopiedModal, setShowCopiedModal] = useState(false); @@ -88,7 +94,7 @@ export default function AddressDetailsScreen() { if (!address) return []; console.log('📜 Fetching address transactions using Esplora service...'); - const result = await getAddressTransactions(address, currentWallet?.xpub); + const result = await getAddressTransactionsPaginated(address, currentWallet?.xpub); if (result.error || !result.data) { console.warn('❌ Address transactions fetch failed:', result.error); @@ -169,8 +175,8 @@ export default function AddressDetailsScreen() { const formatFiat = (satoshis: number) => { // Use real exchange rate from wallet store based on selected currency const btcAmount = satoshis / 100000000; - // Get rate for selected currency from price query, fallback to USD if specific currency not available - const priceData = priceQuery?.data as any; + // Get rate for selected currency from price data, fallback to USD if specific currency not available + const priceData = bitcoinPrice as any; const rate = priceData?.[selectedCurrency]?.last || priceData?.USD?.last || 0; return rate > 0 ? (btcAmount * rate).toFixed(2) : '0.00'; }; @@ -226,13 +232,13 @@ export default function AddressDetailsScreen() { {/* Custom Header */} - router.back()} testID="back-button" > - + Address @@ -242,12 +248,12 @@ export default function AddressDetailsScreen() { {/* Address Header */} - + {truncateAddress(address)} - + {`Balance: ${formatBTC(balance)} BTC • ${processedTransactions.length} transactions`} @@ -368,7 +374,7 @@ export default function AddressDetailsScreen() { animationType="fade" onRequestClose={() => setShowCopiedModal(false)} > - setShowCopiedModal(false)} @@ -376,14 +382,14 @@ export default function AddressDetailsScreen() { Copied Copied to clipboard - setShowCopiedModal(false)} > OK - + - + diff --git a/app/biometric-setup.tsx b/app/biometric-setup.tsx index a73c56ea..424e9338 100644 --- a/app/biometric-setup.tsx +++ b/app/biometric-setup.tsx @@ -1,6 +1,8 @@ import { GradientBackground } from '@/components/GradientBackground'; +import { AppButton } from '@/components/AppButton'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { useAutoLock } from '@/hooks/auto-lock-store'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import * as LocalAuthentication from 'expo-local-authentication'; import { Stack, router } from 'expo-router'; import { ArrowLeft, Check, Fingerprint, Shield } from 'lucide-react-native'; @@ -11,12 +13,11 @@ import { SafeAreaView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; export default function BiometricSetupScreen() { - const { theme } = useWallet(); + const { theme } = useTheme(); const { biometricEnabled, enableBiometric } = useAutoLock(); const [isSupported, setIsSupported] = useState(false); const [biometricType, setBiometricType] = useState(''); @@ -242,13 +243,12 @@ export default function BiometricSetupScreen() { Your wallet is now secured with {Platform.OS === 'android' ? 'biometric authentication' : biometricType} and PIN protection. - router.replace('/(tabs)')} - activeOpacity={0.7} - > - Continue to Wallet - + style={styles.continueButton} + testID="biometric-continue-button" + /> @@ -261,13 +261,13 @@ export default function BiometricSetupScreen() { - router.back()} activeOpacity={0.7} > - + @@ -316,7 +316,7 @@ export default function BiometricSetupScreen() { {isSupported ? ( - {isLoading ? 'Setting up...' : getBiometricButtonText()} - + ) : ( )} - Skip for now - + @@ -472,16 +472,7 @@ const styles = StyleSheet.create({ marginBottom: 32, }, continueButton: { - paddingVertical: 16, - paddingHorizontal: 32, - borderRadius: 12, - alignItems: 'center', marginTop: 40, minWidth: 200, }, - continueButtonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, }); \ No newline at end of file diff --git a/app/coin-control.tsx b/app/coin-control.tsx index f839291e..e877f552 100644 --- a/app/coin-control.tsx +++ b/app/coin-control.tsx @@ -1,8 +1,10 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { ThemedSwitch } from '@/components/ThemedSwitch'; import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useCoinControl, useWalletActions, useWalletUtxos, useWallets } from '@/hooks/wallet-contexts'; import type { UTXO } from '@/types/wallet'; import { Stack, useRouter } from 'expo-router'; import { @@ -19,12 +21,11 @@ import { } from 'lucide-react-native'; import React, { useMemo, useState } from 'react'; import { + FlatList, Platform, RefreshControl, - ScrollView, StyleSheet, Text, - TouchableOpacity, View } from 'react-native'; @@ -32,14 +33,13 @@ type SortOption = 'value' | 'confirmations' | 'age' | 'address'; type FilterOption = 'all' | 'confirmed' | 'unconfirmed' | 'frozen' | 'unfrozen'; export default function CoinControlScreen() { - const { - theme, - currentWallet, - coinControl, - utxos: walletUtxos, - isLoadingUtxos, - refreshData, - } = useWallet(); + // Narrow subscriptions: sheds balance/transaction/price churn while keeping + // the UTXO and coin-control slices this screen actually renders + const { currentWallet } = useWallets(); + const coinControl = useCoinControl(); + const { utxos: walletUtxos, isLoadingUtxos } = useWalletUtxos(); + const { refreshData } = useWalletActions(); + const { theme } = useTheme(); const router = useRouter(); const [selectedUtxos, setSelectedUtxos] = useState>(new Set()); const [sortBy, setSortBy] = useState('value'); @@ -321,7 +321,7 @@ export default function CoinControlScreen() { {/* Freeze Button */} - - + {utxo.frozen ? 'Frozen' : 'Freeze'} @@ -344,7 +344,7 @@ export default function CoinControlScreen() { {/* Select Button */} - )} - + {isSelected ? 'Selected' : 'Select'} @@ -375,7 +375,7 @@ export default function CoinControlScreen() { Sort by: - { if (sortBy === 'value') { @@ -392,9 +392,9 @@ export default function CoinControlScreen() { {sortBy === 'value' && ( sortAscending ? : )} - + - { if (sortBy === 'confirmations') { @@ -411,13 +411,13 @@ export default function CoinControlScreen() { {sortBy === 'confirmations' && ( sortAscending ? : )} - + Filter: {(['all', 'confirmed', 'unconfirmed', 'frozen', 'unfrozen'] as FilterOption[]).map((filter) => ( - {filter.charAt(0).toUpperCase() + filter.slice(1)} - + ))} @@ -463,31 +463,31 @@ export default function CoinControlScreen() { {/* Custom Header */} - router.back()} testID="back-button" > - + Coin Control - setShowFilters(!showFilters)} style={styles.headerButton} testID="toggle-filters" > - - + Apply - + {/* Summary Stats */} @@ -525,34 +525,41 @@ export default function CoinControlScreen() { {/* Action Buttons */} {selectedUtxos.size > 0 && ( - Freeze - + - Unfreeze - + - Clear - + )} {/* UTXO List */} - `${utxo.txid}:${utxo.vout}`} + renderItem={({ item }) => } + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={7} + removeClippedSubviews refreshControl={ } - > - {isLoading ? ( + ListHeaderComponent={filteredAndSortedUtxos.length > 0 ? ( + + + {selectedUtxos.size} selected + + + + Select All + + + + + Deselect All + + + ) : null} + ListEmptyComponent={isLoading ? ( Loading UTXOs... - ) : filteredAndSortedUtxos.length === 0 ? ( + ) : ( @@ -577,69 +605,40 @@ export default function CoinControlScreen() { {filterBy === 'all' ? 'Your wallet has no unspent outputs' : `No UTXOs match the current filter: ${filterBy}`} - ) : ( - <> - {/* Quick Actions */} - - - {selectedUtxos.size} selected + )} + ListFooterComponent={filteredAndSortedUtxos.length > 0 ? ( + + + + + + + How Coin Control Works - - - Select All - - - - - Deselect All - - - {/* UTXO Items */} - {filteredAndSortedUtxos.map((utxo: UTXO) => ( - - ))} - - {/* Coin Control Educational Section */} - - - - - - - How Coin Control Works - - - - Coin control gives you precise control over which UTXOs (Unspent Transaction Outputs) to use in transactions: + + Coin control gives you precise control over which UTXOs (Unspent Transaction Outputs) to use in transactions: + + + + • UTXOs: Each Bitcoin you receive creates a separate "coin" that can be spent + + + • Selection: Choose specific UTXOs to include in your transaction + + + • Privacy: Avoid linking different Bitcoin sources together + + + • Frozen UTXOs: Temporarily prevent UTXOs from being automatically selected + + + • Change Management: Control how much Bitcoin you send back to yourself - - - • UTXOs: Each Bitcoin you receive creates a separate "coin" that can be spent - - - • Selection: Choose specific UTXOs to include in your transaction - - - • Privacy: Avoid linking different Bitcoin sources together - - - • Frozen UTXOs: Temporarily prevent UTXOs from being automatically selected - - - • Change Management: Control how much Bitcoin you send back to yourself - - - - )} - + + ) : null} + /> ); diff --git a/app/fee-bump.tsx b/app/fee-bump.tsx index 81692153..98099839 100644 --- a/app/fee-bump.tsx +++ b/app/fee-bump.tsx @@ -1,7 +1,10 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { AppButton } from '@/components/AppButton'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletMeta, useWalletSettings, useWalletTransactions, useWallets } from '@/hooks/wallet-contexts'; import { feeEstimationService } from '@/services/fee-service'; import { cancelTransaction, performRBF, validateRBFTransaction } from '@/services/rbf-service'; import { Transaction } from '@/types/wallet'; @@ -16,7 +19,6 @@ import { StyleSheet, Text, TextInput, - TouchableOpacity, View, } from 'react-native'; @@ -28,7 +30,11 @@ type FeeOption = { export default function FeeBumpScreen() { const { txid, mode } = useLocalSearchParams<{ txid: string; mode?: 'rbf' | 'cpfp' }>(); - const { theme, transactions, currentWallet, feeSettings, getMnemonic } = useWallet(); + const { theme } = useTheme(); + const { transactions } = useWalletTransactions(); + const { currentWallet } = useWallets(); + const { feeSettings } = useWalletSettings(); + const { getMnemonic } = useWalletMeta(); const [transaction, setTransaction] = useState(null); const [feeOptions, setFeeOptions] = useState([]); const [selectedOption, setSelectedOption] = useState('Fast'); @@ -531,7 +537,7 @@ export default function FeeBumpScreen() { ) : ( feeOptions.map((option) => ( - )} - + )) )} {/* Custom Fee Option */} - - + {/* Custom Fee Validation Feedback */} {selectedOption === 'Custom' && customFeeRate && ( @@ -659,25 +665,17 @@ export default function FeeBumpScreen() { {/* Bottom Actions */} - - {isBumpingFee ? ( - - ) : ( - Bump Fee - )} - + disabled={!isValidFeeRate() || !canReplace || isValidating || isCancelling} + loading={isBumpingFee} + style={styles.bumpFeeButton} + testID="bump-fee-button" + /> {!isCPFPMode && ( - Cancel Transaction )} - + )} - + details - + @@ -896,21 +894,9 @@ const styles = StyleSheet.create({ borderRadius: platformStyles.borderRadius.large, marginTop: platformStyles.spacing.sm, }, - actionButton: { - paddingVertical: platformStyles.spacing.md, - paddingHorizontal: platformStyles.spacing.xl, - borderRadius: platformStyles.borderRadius.medium, - alignItems: 'center', + bumpFeeButton: { marginBottom: platformStyles.spacing.md, }, - bumpButton: { - // Styles for bump fee button - }, - bumpButtonText: { - color: 'white', - ...platformStyles.typography.bodyLarge, - fontWeight: '600', - }, cancelButton: { paddingVertical: platformStyles.spacing.md, alignItems: 'center', diff --git a/app/fee-settings.tsx b/app/fee-settings.tsx index 86e81d3d..a6a082f3 100644 --- a/app/fee-settings.tsx +++ b/app/fee-settings.tsx @@ -1,8 +1,10 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { ThemedSwitch } from '@/components/ThemedSwitch'; import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletSettings } from '@/hooks/wallet-contexts'; import { feeEstimationService } from '@/services/fee-service'; import type { FeeEstimate } from '@/types/wallet'; import { Stack, router } from 'expo-router'; @@ -26,7 +28,6 @@ import { StyleSheet, Text, TextInput, - TouchableOpacity, View } from 'react-native'; @@ -43,7 +44,8 @@ interface FeeSettings { } export default function FeeSettingsScreen() { - const { theme, feeSettings, setFeeSettings } = useWallet(); + const { theme } = useTheme(); + const { feeSettings, setFeeSettings } = useWalletSettings(); const [feeEstimates, setFeeEstimates] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -143,7 +145,7 @@ export default function FeeSettingsScreen() { const estimatedCost = estimateTransactionCost(info.rate); return ( - - + ); }; @@ -280,9 +282,9 @@ export default function FeeSettingsScreen() { const getCongestionColor = () => { switch (congestion) { - case 'low': return '#10B981'; // Green - case 'medium': return '#F59E0B'; // Yellow - case 'high': return '#EF4444'; // Red + case 'low': return theme.colors.success; + case 'medium': return theme.colors.warning; + case 'high': return theme.colors.error; default: return theme.colors.textSecondary; } }; @@ -333,13 +335,13 @@ export default function FeeSettingsScreen() { {/* Custom Header */} - - + Fee Settings @@ -368,17 +370,17 @@ export default function FeeSettingsScreen() { {/* Custom Header */} - - + Fee Settings - - + @@ -452,7 +454,7 @@ export default function FeeSettingsScreen() { - Use Custom Rate - + {/* Advanced Settings */} diff --git a/app/generate-xpub.tsx b/app/generate-xpub.tsx index 54a7aea7..0fa77949 100644 --- a/app/generate-xpub.tsx +++ b/app/generate-xpub.tsx @@ -1,8 +1,10 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import PinVerificationScreen from '@/components/PinVerificationScreen'; import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWallets } from '@/hooks/wallet-contexts'; import * as Clipboard from 'expo-clipboard'; import { Stack, router } from 'expo-router'; import { AlertTriangle, ArrowLeft, Copy } from 'lucide-react-native'; @@ -14,13 +16,13 @@ import { StyleSheet, Text, TextInput, - TouchableOpacity, View, } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; export default function GenerateXPUBScreen() { - const { currentWallet, theme } = useWallet(); + const { currentWallet } = useWallets(); + const { theme } = useTheme(); const [copied, setCopied] = useState(false); const [isPinVerified, setIsPinVerified] = useState(false); @@ -101,13 +103,13 @@ export default function GenerateXPUBScreen() { {/* Custom Header */} - - + Extended Public Key (XPUB) @@ -153,7 +155,7 @@ export default function GenerateXPUBScreen() { {/* Copy Button */} - {copied ? 'Copied!' : 'Copy XPUB'} - + {/* Privacy Warning */} - + - - + + Privacy Warning! - + Sharing your XPUB allows anyone to monitor your entire wallet's transaction history (past, present, and future). Only share this with services you fully trust. diff --git a/app/legal-disclaimer.tsx b/app/legal-disclaimer.tsx index 79839379..0748c424 100644 --- a/app/legal-disclaimer.tsx +++ b/app/legal-disclaimer.tsx @@ -1,6 +1,7 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import { Stack, router } from 'expo-router'; import { ArrowLeft } from 'lucide-react-native'; import React from 'react'; @@ -9,12 +10,11 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; export default function LegalDisclaimerScreen() { - const { theme } = useWallet(); + const { theme } = useTheme(); const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( @@ -51,13 +51,13 @@ export default function LegalDisclaimerScreen() { {/* Custom Header */} - - + Legal Disclaimer @@ -138,11 +138,11 @@ export default function LegalDisclaimerScreen() { If you have questions about this Disclaimer, please contact us: - Linking.openURL('mailto:hello@bitsleuth.ai')}> + Linking.openURL('mailto:hello@bitsleuth.ai')}> 📧 hello@bitsleuth.ai - + diff --git a/app/manage-wallets.tsx b/app/manage-wallets.tsx index ae87d90e..cdbc73af 100644 --- a/app/manage-wallets.tsx +++ b/app/manage-wallets.tsx @@ -1,9 +1,11 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { platformStyles } from '@/constants/themes'; import { WALLET_COLOR_PALETTE, getWalletGradient } from '@/constants/wallet-colors'; import { useAutoLock } from '@/hooks/auto-lock-store'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletActions, useWallets } from '@/hooks/wallet-contexts'; import { getWalletTypeDisplayName } from '@/types/wallet'; import { LinearGradient } from 'expo-linear-gradient'; import { Stack, useRouter } from 'expo-router'; @@ -23,12 +25,10 @@ import { StyleSheet, Text, TextInput, - TouchableOpacity, View, } from 'react-native'; export default function ManageWalletsScreen() { - const walletContext = useWallet(); const { hasPin } = useAutoLock(); const router = useRouter(); const [showEditModal, setShowEditModal] = useState(false); @@ -37,13 +37,9 @@ export default function ManageWalletsScreen() { const [editColor, setEditColor] = useState('#8B5CF6'); const [isDeleting, setIsDeleting] = useState(false); - // Safely destructure with fallbacks to prevent crashes - const { - theme, - wallets = [], - editWallet, - deleteWallet - } = walletContext || {}; + const { theme } = useTheme(); + const { wallets } = useWallets(); + const { editWallet, deleteWallet } = useWalletActions(); const handleEditWallet = (wallet: any) => { setEditingWallet(wallet); @@ -133,14 +129,14 @@ export default function ManageWalletsScreen() { - handleEditWallet(wallet)} disabled={isDeleting} > - - + handleDeleteWallet(wallet)} disabled={isDeleting || wallets.length <= 1} @@ -149,7 +145,7 @@ export default function ManageWalletsScreen() { color={wallets.length <= 1 ? theme.colors.textSecondary : theme.colors.error} size={18} /> - + ); @@ -159,7 +155,7 @@ export default function ManageWalletsScreen() { Color {WALLET_COLOR_PALETTE.map((colorOption) => ( - setEditColor(colorOption.base)} @@ -174,7 +170,7 @@ export default function ManageWalletsScreen() { )} - + ))} @@ -195,13 +191,13 @@ export default function ManageWalletsScreen() { {/* Custom Header */} - - + Manage Wallets @@ -217,7 +213,7 @@ export default function ManageWalletsScreen() { - { if (hasPin) { @@ -231,26 +227,25 @@ export default function ManageWalletsScreen() { > Add Wallet - + {/* Edit Wallet Modal */} setShowEditModal(false)} > - - - + + Edit Wallet - setShowEditModal(false)} style={styles.modalCloseButton} > - + @@ -278,13 +273,13 @@ export default function ManageWalletsScreen() { - setShowEditModal(false)} > Cancel - - + Save - + - @@ -404,18 +398,8 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'center', - alignItems: 'center', - }, modalContent: { - width: '90%', - maxWidth: 400, - borderRadius: 20, - padding: 0, - overflow: 'hidden', + flex: 1, }, modalHeader: { flexDirection: 'row', @@ -423,7 +407,6 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: 20, borderBottomWidth: 1, - borderBottomColor: 'rgba(0, 0, 0, 0.1)', }, modalTitle: { fontSize: 20, diff --git a/app/mfa-test.tsx b/app/mfa-test.tsx deleted file mode 100644 index 52da229a..00000000 --- a/app/mfa-test.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; -import { GradientBackground } from '@/components/GradientBackground'; -import { useWallet } from '@/hooks/wallet-store'; -import { router, Stack } from 'expo-router'; -import { AlertTriangle, ArrowLeft, CheckCircle, Lock, TestTube } from 'lucide-react-native'; -import React, { useState } from 'react'; -import { - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View -} from 'react-native'; - -interface MFATestResult { - step: string; - success: boolean; - message: string; - details?: string; -} - -export default function MFATestScreen() { - const { theme } = useWallet(); - const [testResults, setTestResults] = useState([]); - const [isRunning, setIsRunning] = useState(false); - - const handleBack = () => { - router.back(); - }; - - const runMFATests = async () => { - setIsRunning(true); - setTestResults([]); - - const results: MFATestResult[] = [ - { - step: 'Security Capability Check', - success: true, - message: 'Security keys and multi-factor authentication have been removed. Biometric authentication and PIN are the only guard rails.', - }, - { - step: 'Biometric Requirement', - success: true, - message: 'Biometric prompts are only shown when the "Require Biometric for Transactions" setting is enabled.', - }, - { - step: 'Send Flow Behavior', - success: true, - message: 'If biometric-for-transactions is disabled, sends proceed immediately without extra prompts.', - }, - ]; - - setTestResults(results); - setIsRunning(false); - }; - - const TestResultItem = ({ result }: { result: MFATestResult }) => ( - - - - {result.success ? ( - - ) : ( - - )} - - - {result.step} - - - - {result.message} - - {result.details && ( - - Details: {result.details} - - )} - - ); - - return ( - - - - - {/* Custom Header */} - - - - - - MFA Enforcement Test - - - - - - {/* Test Info Card */} - - - - - MFA Enforcement Test Suite - - - - - This test suite verifies that Multi-Factor Authentication (MFA) is properly enforced - during transaction authentication. It checks various scenarios to ensure security - settings are respected. - - - - - Test Steps: - - - 1. Configuration validation - - - 2. Small transaction auth (standard) - - - 3. Large transaction auth (enhanced) - - - - - {/* Test Results */} - {testResults.length > 0 && ( - - - Test Results - - - - - - {testResults.filter(r => r.success).length} - - Passed - - - - {testResults.filter(r => !r.success).length} - - Failed - - - - {testResults.length} - - Total - - - - {testResults.map((result, index) => ( - - ))} - - )} - - {/* Run Test Button */} - - - - {isRunning ? 'Running Tests...' : 'Run MFA Enforcement Tests'} - - - - {/* MFA Status Info */} - - - - - Multi-Factor Authentication Status - - - - Biometric prompts during sends are controlled entirely by the "Require Biometric for Transactions" toggle. - - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 0, - }, - backButton: { - padding: 8, - marginLeft: -8, - }, - headerTitle: { - fontSize: 18, - fontWeight: 'bold', - flex: 1, - textAlign: 'center', - marginRight: 32, - }, - headerSpacer: { - width: 32, - }, - scrollView: { - flex: 1, - }, - infoCard: { - margin: 20, - padding: 24, - borderRadius: 16, - marginBottom: 20, - }, - infoHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - }, - infoTitle: { - fontSize: 20, - fontWeight: '700', - marginLeft: 8, - }, - infoDescription: { - fontSize: 14, - lineHeight: 20, - marginBottom: 16, - }, - testSteps: { - marginTop: 8, - }, - testStepTitle: { - fontSize: 16, - fontWeight: '600', - marginBottom: 8, - }, - testStepItem: { - fontSize: 14, - marginBottom: 4, - }, - resultsSection: { - marginHorizontal: 20, - marginBottom: 24, - }, - resultsHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - }, - resultsTitle: { - fontSize: 18, - fontWeight: '600', - marginLeft: 8, - }, - resultsStats: { - flexDirection: 'row', - justifyContent: 'space-around', - marginBottom: 16, - paddingVertical: 16, - backgroundColor: 'rgba(255,255,255,0.05)', - borderRadius: 12, - }, - statItem: { - alignItems: 'center', - }, - statNumber: { - fontSize: 24, - fontWeight: 'bold', - }, - statLabel: { - fontSize: 12, - marginTop: 4, - }, - testResultItem: { - marginBottom: 12, - padding: 16, - borderRadius: 12, - borderLeftWidth: 4, - backgroundColor: 'rgba(255,255,255,0.05)', - }, - testResultHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - }, - testResultIcon: { - width: 32, - height: 32, - borderRadius: 16, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - testResultStep: { - fontSize: 16, - fontWeight: '600', - flex: 1, - }, - testResultMessage: { - fontSize: 14, - marginBottom: 4, - }, - testResultDetails: { - fontSize: 12, - fontStyle: 'italic', - }, - runTestButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginHorizontal: 20, - marginBottom: 20, - paddingVertical: 16, - borderRadius: 12, - gap: 8, - }, - runTestButtonText: { - fontSize: 16, - fontWeight: '600', - color: 'white', - }, - statusCard: { - margin: 20, - padding: 24, - borderRadius: 16, - marginBottom: 20, - }, - statusHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - }, - statusTitle: { - fontSize: 18, - fontWeight: '600', - marginLeft: 8, - }, - statusDescription: { - fontSize: 14, - lineHeight: 20, - marginBottom: 16, - }, - statusFeatures: { - marginTop: 8, - }, - statusFeature: { - fontSize: 14, - marginBottom: 8, - }, - bottomSpacing: { - height: 40, - }, -}); diff --git a/app/passkeys-security.tsx b/app/passkeys-security.tsx index 208aded3..418350a1 100644 --- a/app/passkeys-security.tsx +++ b/app/passkeys-security.tsx @@ -1,8 +1,9 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { platformStyles } from '@/constants/themes'; import { useAutoLock } from '@/hooks/auto-lock-store'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import { secureAuthService, SecuritySettings } from '@/services/secure-auth-service'; import { securityGuard } from '@/services/security-guard-service'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -20,12 +21,11 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; export default function PasskeysSecurityScreen() { - const { theme } = useWallet(); + const { theme } = useTheme(); const { biometricEnabled, enableBiometric, disableBiometric } = useAutoLock(); const [biometricAvailable, setBiometricAvailable] = useState(false); const [loading, setLoading] = useState(true); @@ -190,13 +190,13 @@ export default function PasskeysSecurityScreen() { {/* Custom Header */} - - + Biometric Authentication @@ -224,13 +224,13 @@ export default function PasskeysSecurityScreen() { {/* Custom Header */} - - + Biometric Authentication @@ -260,7 +260,7 @@ export default function PasskeysSecurityScreen() { Authentication Settings - @@ -284,7 +284,7 @@ export default function PasskeysSecurityScreen() { { transform: [{ translateX: biometricEnabled ? 20 : 2 }] } ]} /> - + @@ -295,7 +295,7 @@ export default function PasskeysSecurityScreen() { {Platform.OS === 'android' ? 'Biometric' : 'Face ID/Touch ID'} required before sending funds - handleSecuritySettingChange('requireBiometricForTransactions', !securitySettings.requireBiometricForTransactions)} > @@ -303,7 +303,7 @@ export default function PasskeysSecurityScreen() { styles.toggleThumb, { transform: [{ translateX: securitySettings.requireBiometricForTransactions ? 20 : 2 }] } ]} /> - + )} diff --git a/app/pin-setup.tsx b/app/pin-setup.tsx index f7307dbd..139e8ec3 100644 --- a/app/pin-setup.tsx +++ b/app/pin-setup.tsx @@ -1,7 +1,9 @@ import { GradientBackground } from '@/components/GradientBackground'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { platformStyles } from '@/constants/themes'; import { useAutoLock } from '@/hooks/auto-lock-store'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWallets } from '@/hooks/wallet-contexts'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Haptics from 'expo-haptics'; import { Stack, router } from 'expo-router'; @@ -12,13 +14,13 @@ import { SafeAreaView, StyleSheet, Text, - TouchableOpacity, Vibration, View } from 'react-native'; export default function PinSetupScreen() { - const { theme, wallets } = useWallet(); + const { theme } = useTheme(); + const { wallets } = useWallets(); const { savePin } = useAutoLock(); const [pin, setPin] = useState(''); const [confirmPin, setConfirmPin] = useState(''); @@ -169,7 +171,7 @@ export default function PinSetupScreen() { if (item === 'delete') { return ( - - + ); } return ( - {item} - + ); })} @@ -208,13 +210,13 @@ export default function PinSetupScreen() { - router.back()} activeOpacity={0.7} > - + diff --git a/app/pin-verification.tsx b/app/pin-verification.tsx index 0b64c034..4da2bcbd 100644 --- a/app/pin-verification.tsx +++ b/app/pin-verification.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { SafeAreaView, Platform, View } from 'react-native'; import { Stack, router } from 'expo-router'; import PinVerificationScreen from '@/components/PinVerificationScreen'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import { GradientBackground } from '@/components/GradientBackground'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function PinVerificationRoute() { - const { theme } = useWallet(); + const { theme } = useTheme(); const insets = useSafeAreaInsets(); const handleSuccess = () => { diff --git a/app/privacy-policy.tsx b/app/privacy-policy.tsx index 7b1bad24..461fc7c9 100644 --- a/app/privacy-policy.tsx +++ b/app/privacy-policy.tsx @@ -1,6 +1,7 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import { Stack, router } from 'expo-router'; import { ArrowLeft } from 'lucide-react-native'; import React from 'react'; @@ -9,12 +10,11 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; export default function PrivacyPolicyScreen() { - const { theme } = useWallet(); + const { theme } = useTheme(); const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( @@ -51,13 +51,13 @@ export default function PrivacyPolicyScreen() { {/* Custom Header */} - - + Privacy Policy @@ -179,11 +179,11 @@ export default function PrivacyPolicyScreen() { If you have any questions about this Privacy Policy, please contact us at: - Linking.openURL('mailto:hello@bitsleuth.ai')}> + Linking.openURL('mailto:hello@bitsleuth.ai')}> 📧 hello@bitsleuth.ai - + diff --git a/app/terms-of-service.tsx b/app/terms-of-service.tsx index 3dec5bba..3da3ce40 100644 --- a/app/terms-of-service.tsx +++ b/app/terms-of-service.tsx @@ -1,6 +1,7 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import { Stack, router } from 'expo-router'; import { ArrowLeft } from 'lucide-react-native'; import React from 'react'; @@ -9,12 +10,11 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; export default function TermsOfServiceScreen() { - const { theme } = useWallet(); + const { theme } = useTheme(); const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( @@ -51,13 +51,13 @@ export default function TermsOfServiceScreen() { {/* Custom Header */} - - + Terms of Service @@ -180,11 +180,11 @@ export default function TermsOfServiceScreen() { If you have questions about these Terms, please contact us at: - Linking.openURL('mailto:hello@bitsleuth.ai')}> + Linking.openURL('mailto:hello@bitsleuth.ai')}> 📧 hello@bitsleuth.ai - + diff --git a/app/transaction-details.tsx b/app/transaction-details.tsx index 6da6f658..89f30f5b 100644 --- a/app/transaction-details.tsx +++ b/app/transaction-details.tsx @@ -1,6 +1,8 @@ import { GradientBackground } from '@/components/GradientBackground'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletActions, useWalletBalance, useWalletSettings, useWalletTransactions } from '@/hooks/wallet-contexts'; import { getTransactionDetails } from '@/services/esplora-service'; import { Transaction } from '@/types/wallet'; import { useQuery } from '@tanstack/react-query'; @@ -29,13 +31,16 @@ import { Share, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; export default function TransactionDetailsScreen() { const { txid } = useLocalSearchParams<{ txid: string }>(); - const { theme, transactions, formatCurrency, bitcoinPrice, feeSettings } = useWallet(); + const { theme } = useTheme(); + const { transactions } = useWalletTransactions(); + const { formatCurrency } = useWalletActions(); + const { bitcoinPrice } = useWalletBalance(); + const { feeSettings } = useWalletSettings(); const [transaction, setTransaction] = useState(null); const lastTxRef = useRef(null); @@ -229,9 +234,9 @@ export default function TransactionDetailsScreen() { headerTransparent: true, headerTintColor: theme.colors.text, headerRight: () => ( - + - + ), }} /> @@ -296,7 +301,7 @@ export default function TransactionDetailsScreen() { {transaction.status === 'pending' && isRBFToggleEnabled && isRBFEligible && ( - Speed Up (RBF) - + )} @@ -322,7 +327,7 @@ export default function TransactionDetailsScreen() { Address - copyToClipboard(transaction.address, 'Address')} > @@ -330,12 +335,12 @@ export default function TransactionDetailsScreen() { {transaction.address} - + Transaction ID - copyToClipboard(transaction.txid, 'Transaction ID')} > @@ -343,7 +348,7 @@ export default function TransactionDetailsScreen() { {transaction.txid} - + {typeof transaction.fee === 'number' && transaction.fee > 0 && ( @@ -419,16 +424,16 @@ export default function TransactionDetailsScreen() { Actions - + View on Block Explorer - + {/* RBF Button for sent transactions */} {!isReceived && transaction.status === 'pending' && isRBFToggleEnabled && ( - Replace-by-Fee (RBF) - + )} {/* CPFP Button for pending transactions when enabled */} {transaction.status === 'pending' && isCPFPToggleEnabled && ( - Child-Pays-for-Parent (CPFP) - + )} diff --git a/app/transaction-explorer.tsx b/app/transaction-explorer.tsx index 68576db1..5e212c2a 100644 --- a/app/transaction-explorer.tsx +++ b/app/transaction-explorer.tsx @@ -1,7 +1,9 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletActions, useWalletBalance, useWalletTransactions, useWallets } from '@/hooks/wallet-contexts'; import { getTransactionDetails } from '@/services/esplora-service'; import { Transaction, Wallet } from '@/types/wallet'; import * as Clipboard from 'expo-clipboard'; @@ -19,7 +21,6 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; @@ -101,7 +102,11 @@ interface ExtendedTransactionDetails extends Omit { export default function TransactionExplorerScreen() { const { txid } = useLocalSearchParams<{ txid: string }>(); - const { theme, transactions, bitcoinPrice, currentWallet, formatCurrency } = useWallet(); + const { theme } = useTheme(); + const { transactions } = useWalletTransactions(); + const { bitcoinPrice } = useWalletBalance(); + const { currentWallet } = useWallets(); + const { formatCurrency } = useWalletActions(); const lastDetailsRef = useRef>({}); const [transaction, setTransaction] = useState(null); const [explorerData, setExplorerData] = useState(null); @@ -213,13 +218,13 @@ export default function TransactionExplorerScreen() { {/* Custom Header */} - router.back()} testID="back-button" > - + Transaction @@ -246,13 +251,13 @@ export default function TransactionExplorerScreen() { {/* Custom Header */} - router.back()} testID="back-button" > - + Transaction @@ -284,13 +289,13 @@ export default function TransactionExplorerScreen() { {/* Custom Header */} - router.back()} testID="back-button" > - + Transaction @@ -311,14 +316,14 @@ export default function TransactionExplorerScreen() { Broadcasted on {formatDate(explorerData.timestamp)} - copyToClipboard(explorerData.txid)} > {explorerData.txid} - + {/* Summary Card */} @@ -469,7 +474,7 @@ export default function TransactionExplorerScreen() { {explorerData.inputs.map((input, index) => ( - copyToClipboard(input.address)} @@ -480,7 +485,7 @@ export default function TransactionExplorerScreen() { {input.value.toFixed(8)} - + ))} @@ -491,7 +496,7 @@ export default function TransactionExplorerScreen() { {explorerData.outputs.map((output, index) => ( - copyToClipboard(output.address)} @@ -502,7 +507,7 @@ export default function TransactionExplorerScreen() { {output.value.toFixed(8)} - + ))} diff --git a/app/transaction-history.tsx b/app/transaction-history.tsx index 146af9e7..233e63cc 100644 --- a/app/transaction-history.tsx +++ b/app/transaction-history.tsx @@ -1,16 +1,18 @@ import TransactionItem from '@/components/TransactionItem'; -import { useWallet } from '@/hooks/wallet-store'; +import { PressableOpacity } from '@/components/PressableOpacity'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletActions, useWalletTransactions, useWallets } from '@/hooks/wallet-contexts'; import { Transaction } from '@/types/wallet'; import { Stack, router } from 'expo-router'; import { ArrowLeft, Clock } from 'lucide-react-native'; -import React from 'react'; +import React, { useCallback } from 'react'; import { ActivityIndicator, + FlatList, + ListRenderItemInfo, RefreshControl, - ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; @@ -18,19 +20,24 @@ import { GradientBackground } from '@/components/GradientBackground'; import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; export default function TransactionHistoryScreen() { - const { - theme, - transactions, - isLoadingTransactions, - hasTransactionsError, - refreshData, - currentWallet - } = useWallet(); + const { theme } = useTheme(); + const { transactions, isLoadingTransactions, hasTransactionsError } = useWalletTransactions(); + const { refreshData } = useWalletActions(); + const { currentWallet } = useWallets(); const handleRefresh = async () => { await refreshData(); }; + const keyExtractor = useCallback((transaction: Transaction) => transaction.txid, []); + + const renderTransaction = useCallback( + ({ item, index }: ListRenderItemInfo) => ( + + ), + [] + ); + const EmptyState = () => ( @@ -77,21 +84,24 @@ export default function TransactionHistoryScreen() { {/* Custom Header */} - - + Transaction History - } showsVerticalScrollIndicator={false} - > - {/* Content Header */} - - - All Transactions - - - {transactions.length} transaction{transactions.length !== 1 ? 's' : ''} - - - - {/* Loading State */} - {isLoadingTransactions && transactions.length === 0 && ( - - - - Loading transactions... + contentContainerStyle={styles.listContent} + initialNumToRender={12} + maxToRenderPerBatch={10} + windowSize={7} + removeClippedSubviews + ListHeaderComponent={ + + + All Transactions + + + {transactions.length} transaction{transactions.length !== 1 ? 's' : ''} - )} - - {/* Error State */} - {hasTransactionsError && transactions.length === 0 && !isLoadingTransactions && ( - - )} - - {/* Empty State */} - {!isLoadingTransactions && !hasTransactionsError && transactions.length === 0 && ( - - )} - - {/* Transaction List */} - {transactions.length > 0 && ( - - {transactions.map((transaction: Transaction, index: number) => ( - - ))} - - )} - - {/* Bottom spacing */} - - + } + ListEmptyComponent={ + isLoadingTransactions ? ( + + + + Loading transactions... + + + ) : hasTransactionsError ? ( + + ) : ( + + ) + } + ListFooterComponent={} + /> ); @@ -226,7 +221,7 @@ const styles = StyleSheet.create({ textAlign: 'center', lineHeight: 22, }, - transactionsList: { + listContent: { paddingHorizontal: 16, paddingBottom: 20, }, diff --git a/app/view-recovery-phrase.tsx b/app/view-recovery-phrase.tsx index 2bc50155..6899c6cc 100644 --- a/app/view-recovery-phrase.tsx +++ b/app/view-recovery-phrase.tsx @@ -1,5 +1,8 @@ import PinVerificationScreen from '@/components/PinVerificationScreen'; -import { useWallet } from '@/hooks/wallet-store'; +import { AppButton } from '@/components/AppButton'; +import { PressableOpacity } from '@/components/PressableOpacity'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletMeta, useWallets } from '@/hooks/wallet-contexts'; import { Stack, router } from 'expo-router'; import { AlertTriangle, ArrowLeft, Eye, X } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; @@ -9,7 +12,6 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; @@ -17,7 +19,9 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; import { GradientBackground } from '@/components/GradientBackground'; export default function ViewRecoveryPhrase() { - const { currentWallet, theme, getMnemonic } = useWallet(); + const { currentWallet } = useWallets(); + const { theme } = useTheme(); + const { getMnemonic } = useWalletMeta(); const [isRevealed, setIsRevealed] = useState(false); const [isPinVerified, setIsPinVerified] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false); @@ -188,13 +192,13 @@ export default function ViewRecoveryPhrase() { {/* Custom Header */} - - + Recovery Phrase @@ -246,14 +250,13 @@ export default function ViewRecoveryPhrase() { {/* Reveal Button */} {!isRevealed && ( - } onPress={handleReveal} + style={styles.revealButton} testID="reveal-button" - > - - Reveal Recovery Phrase - + /> )} {/* Warning Section */} @@ -282,26 +285,26 @@ export default function ViewRecoveryPhrase() { Security Warning - + - + Make sure no one is watching your screen. Your recovery phrase gives full access to your wallet. - I Understand - - + Cancel - + @@ -413,17 +416,7 @@ const styles = StyleSheet.create({ flex: 1, }, revealButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: 16, - borderRadius: 12, marginBottom: 30, - gap: 8, - }, - revealText: { - fontSize: 16, - fontWeight: '600', }, warningContainer: { // backgroundColor and borderColor will be set dynamically via theme diff --git a/app/wallet-addresses.tsx b/app/wallet-addresses.tsx index fac007db..46153199 100644 --- a/app/wallet-addresses.tsx +++ b/app/wallet-addresses.tsx @@ -1,4 +1,6 @@ -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWallets } from '@/hooks/wallet-contexts'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { transformAddressDataForUI, type AddressInfo } from '@/utils/address-transform'; import { loadWalletService } from '@/utils/wallet-service-loader'; import { useQuery } from '@tanstack/react-query'; @@ -8,17 +10,16 @@ import { ArrowLeft, Copy, ExternalLink, Info, RefreshCw } from 'lucide-react-nat import React, { useMemo, useState } from 'react'; import { ActivityIndicator, - Alert, + FlatList, Platform, - ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; import { GradientBackground } from '@/components/GradientBackground'; +import { toast } from '@/components/Toast'; import { platformStyles } from '@/constants/themes'; // Load wallet service using shared utility @@ -29,10 +30,8 @@ const walletService = loadWalletService([ ]); export default function WalletAddressesScreen() { - const { - theme, - currentWallet, - } = useWallet(); + const { theme } = useTheme(); + const { currentWallet } = useWallets(); const router = useRouter(); const [selectedTab, setSelectedTab] = useState<'receiving' | 'change'>('receiving'); const [generatingAddresses, setGeneratingAddresses] = useState(false); @@ -118,10 +117,10 @@ export default function WalletAddressesScreen() { const copyToClipboard = async (address: string) => { try { await Clipboard.setStringAsync(address); - Alert.alert('Copied', 'Address copied to clipboard'); + toast.success('Copied!', 'Address copied to clipboard'); } catch (error) { console.error('Failed to copy address:', error); - Alert.alert('Error', 'Failed to copy address'); + toast.error('Copy failed', 'Could not copy address to clipboard'); } }; @@ -136,11 +135,11 @@ export default function WalletAddressesScreen() { if (currentWallet?.xpub && walletService.clearAddressCache) { walletService.clearAddressCache(currentWallet.xpub); } - + await addressesQuery.refetch(); } catch (error) { console.error('Failed to refresh addresses:', error); - Alert.alert('Error', 'Failed to refresh addresses'); + toast.error('Refresh failed', 'Could not refresh addresses'); } finally { setGeneratingAddresses(false); } @@ -151,7 +150,7 @@ export default function WalletAddressesScreen() { }; const AddressItem = ({ addressInfo }: { addressInfo: AddressInfo }) => ( - openAddressDetails(addressInfo.address)} activeOpacity={0.7} @@ -218,7 +217,7 @@ export default function WalletAddressesScreen() { Balance: {formatBalance(addressInfo.balance)} BTC - { e.stopPropagation(); copyToClipboard(addressInfo.address); @@ -226,8 +225,8 @@ export default function WalletAddressesScreen() { style={styles.actionButton} > - - + { e.stopPropagation(); openAddressDetails(addressInfo.address); @@ -235,10 +234,10 @@ export default function WalletAddressesScreen() { style={styles.actionButton} > - + - + ); const TabButton = ({ @@ -250,7 +249,7 @@ export default function WalletAddressesScreen() { isActive: boolean; onPress: () => void; }) => ( - {title} - + ); return ( @@ -284,17 +283,17 @@ export default function WalletAddressesScreen() { {/* Custom Header */} - router.back()} testID="back-button" > - + View Addresses - - + {/* Tab Navigation */} @@ -320,19 +319,20 @@ export default function WalletAddressesScreen() { /> - - {addressesQuery.isLoading && addressData.length === 0 ? ( - - - - Generating addresses... - - - ) : addressData.length > 0 ? ( - <> + addressInfo.address} + renderItem={({ item }) => } + showsVerticalScrollIndicator={false} + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={7} + removeClippedSubviews + ListHeaderComponent={addressData.length > 0 ? ( - {selectedTab === 'receiving' + {selectedTab === 'receiving' ? `Receiving addresses (m/84\'/0\'/0\'/0/x) - Following BIP44 gap limit: all used + up to 20 unused. Showing ${addressData.length} addresses.` : `Change addresses (m/84\'/0\'/0\'/1/x) - Following BIP44 gap limit: all used + up to 20 unused. Showing ${addressData.length} addresses.`} @@ -355,10 +355,25 @@ export default function WalletAddressesScreen() { - {addressData.map((addressInfo, index) => ( - - ))} - + ) : null} + ListEmptyComponent={addressesQuery.isLoading ? ( + + + + Generating addresses... + + + ) : ( + + + {addressesQuery.error + ? 'Failed to generate addresses. Please try again.' + : 'No addresses found'} + + + )} + ListFooterComponent={addressData.length > 0 ? ( + <> {/* Gap Limit Info */} @@ -421,16 +436,8 @@ export default function WalletAddressesScreen() { - ) : ( - - - {addressesQuery.error - ? 'Failed to generate addresses. Please try again.' - : 'No addresses found'} - - - )} - + ) : null} + /> ); diff --git a/app/wallet-settings.tsx b/app/wallet-settings.tsx index 013e9aab..c043fbbc 100644 --- a/app/wallet-settings.tsx +++ b/app/wallet-settings.tsx @@ -1,8 +1,10 @@ import { AndroidSafeContainer } from '@/components/AndroidSafeContainer'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import { LiquidGlassView } from '@/components/LiquidGlassView'; import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletActions, useWallets } from '@/hooks/wallet-contexts'; import { getWalletTypeDisplayName } from '@/types/wallet'; import { transformAddressDataForUI } from '@/utils/address-transform'; import { loadWalletService } from '@/utils/wallet-service-loader'; @@ -26,7 +28,6 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; @@ -34,16 +35,10 @@ import { const walletService = loadWalletService(['generateAddressesForView']); export default function WalletSettingsScreen() { - const walletContext = useWallet(); const queryClient = useQueryClient(); - - // Safely destructure with fallbacks to prevent crashes - const { - theme, - currentWallet, - deleteWallet, - wallets = [] - } = walletContext || {}; + const { theme } = useTheme(); + const { currentWallet, wallets } = useWallets(); + const { deleteWallet } = useWalletActions(); // Prefetch address data in the background when this screen loads // This ensures instant loading when user navigates to "View Addresses" @@ -174,7 +169,7 @@ const SettingItem: React.FC<{ showDivider?: boolean; }> = ({ icon: Icon, title, subtitle, onPress, danger, showDivider = true }) => ( <> - )} - + {showDivider && ( ( {/* Custom Header */} - - + Wallet Settings diff --git a/app/wallet-setup.tsx b/app/wallet-setup.tsx index 3f8c54a4..df85b5d6 100644 --- a/app/wallet-setup.tsx +++ b/app/wallet-setup.tsx @@ -1,12 +1,14 @@ // Import crypto polyfill first import '@/services/crypto-polyfill'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { GradientBackground } from '@/components/GradientBackground'; import QRScanner from '@/components/QRScanner'; import { platformStyles } from '@/constants/themes'; import { WALLET_COLOR_PALETTE } from '@/constants/wallet-colors'; import { useAutoLock } from '@/hooks/auto-lock-store'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletActions } from '@/hooks/wallet-contexts'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { LinearGradient } from 'expo-linear-gradient'; import { Stack, router } from 'expo-router'; @@ -25,16 +27,11 @@ import { StyleSheet, Text, TextInput, - TouchableOpacity, View, } from 'react-native'; import ConfettiCannon from 'react-native-confetti-cannon'; import type { WalletService } from '@/types/wallet'; -// Fallback test mnemonics - DO NOT USE FOR REAL FUNDS -const FALLBACK_MNEMONIC_12 = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; -const FALLBACK_MNEMONIC_24 = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art'; - // Wallet service import with platform detection let walletService: WalletService; try { @@ -57,7 +54,7 @@ try { }; // Verify all required functions are available - const requiredFunctions: Array = ['generateMnemonic', 'validateMnemonic', 'createWallet', 'importWallet']; + const requiredFunctions: (keyof WalletService)[] = ['generateMnemonic', 'validateMnemonic', 'createWallet', 'importWallet']; const missingFunctions = requiredFunctions.filter(func => typeof walletService[func] !== 'function'); if (missingFunctions.length > 0) { @@ -69,18 +66,22 @@ try { } } catch (error) { console.error('❌ Failed to load wallet service for', Platform.OS, ':', error); - // Provide a minimal fallback - WARNING: These are test mnemonics only + // Fail closed: never hand out a predictable mnemonic or accept an invalid + // one just because the crypto module failed to load + const serviceUnavailable = async (): Promise => { + throw new Error('Wallet service not available. Please restart the app.'); + }; walletService = { - generateMnemonic: (strength: number = 128) => - Promise.resolve(strength === 256 ? FALLBACK_MNEMONIC_24 : FALLBACK_MNEMONIC_12), - validateMnemonic: () => true, - createWallet: async () => { throw new Error('Wallet service not available'); }, - importWallet: async () => { throw new Error('Wallet service not available'); } + generateMnemonic: serviceUnavailable, + validateMnemonic: () => false, + createWallet: serviceUnavailable, + importWallet: serviceUnavailable, }; } export default function WalletSetupScreen() { - const { theme, importWallet } = useWallet(); + const { theme } = useTheme(); + const { importWallet } = useWalletActions(); const { hasPin, biometricEnabled } = useAutoLock(); const [mode, setMode] = useState<'select' | 'create' | 'import' | 'confirm'>('select'); const [walletName, setWalletName] = useState(''); @@ -132,23 +133,14 @@ export default function WalletSetupScreen() { if (__DEV__) { console.log('Starting mnemonic generation for word count:', wordCount); } - - // Set fallback immediately to prevent undefined state - const fallbackMnemonic = wordCount === 24 ? FALLBACK_MNEMONIC_24 : FALLBACK_MNEMONIC_12; - setGeneratedMnemonic(fallbackMnemonic); - + try { - if (__DEV__) { - console.log('Attempting to generate mnemonic with wallet service'); - console.log('Wallet service type:', typeof walletService.generateMnemonic); - } - if (typeof walletService.generateMnemonic !== 'function') { throw new Error('generateMnemonic is not a function'); } - + const strength = wordCount === 24 ? 256 : 128; - + // Handle both sync and async versions of generateMnemonic let newMnemonic: string; const result = walletService.generateMnemonic(strength); @@ -157,38 +149,26 @@ export default function WalletSetupScreen() { } else { newMnemonic = result; } - + + if (!newMnemonic || typeof newMnemonic !== 'string' || !newMnemonic.trim()) { + throw new Error('Generated mnemonic was empty'); + } + if (__DEV__) { console.log('Successfully generated mnemonic with wallet service'); } - - // Only update if we got a valid mnemonic - if (newMnemonic && typeof newMnemonic === 'string' && newMnemonic.trim()) { - // Check if we're using a fallback/test mnemonic - const isFallbackMnemonic = newMnemonic === FALLBACK_MNEMONIC_12 || - newMnemonic === FALLBACK_MNEMONIC_24; - - if (isFallbackMnemonic) { - // Warn user that this is a test mnemonic - Alert.alert( - '⚠️ Test Mnemonic Warning', - 'You are using a test recovery phrase. DO NOT use this wallet for real funds! This phrase is publicly known and insecure.', - [{ text: 'I Understand', style: 'destructive' }] - ); - } - - setGeneratedMnemonic(newMnemonic); - } + + setGeneratedMnemonic(newMnemonic); } catch (error) { - console.error('Error generating mnemonic, using fallback:', error); - - // Show warning that we're using fallback + console.error('Error generating mnemonic:', error); + // Fail closed: never substitute a publicly-known test phrase. + // An empty mnemonic blocks wallet creation until generation succeeds. + setGeneratedMnemonic(''); Alert.alert( - '⚠️ Fallback Mnemonic', - 'Failed to generate secure mnemonic. Using test phrase. DO NOT use for real funds!', - [{ text: 'OK', style: 'destructive' }] + 'Unable to Generate Recovery Phrase', + 'Secure key generation failed, so no wallet was created. Please restart the app and try again.', + [{ text: 'OK' }] ); - // Fallback is already set above } }, [wordCount]); @@ -381,7 +361,7 @@ export default function WalletSetupScreen() { ]} showsVerticalScrollIndicator={false} > - router.replace('/(tabs)')} > @@ -389,7 +369,7 @@ export default function WalletSetupScreen() { Back to Dashboard - + @@ -435,7 +415,7 @@ export default function WalletSetupScreen() { - setMode('create')} > @@ -444,9 +424,9 @@ export default function WalletSetupScreen() { Generate a new Bitcoin wallet with recovery phrase - + - setMode('import')} > @@ -457,7 +437,7 @@ export default function WalletSetupScreen() { Restore wallet using your recovery phrase - + @@ -502,13 +482,13 @@ export default function WalletSetupScreen() { Platform.OS === 'android' && { paddingBottom: 100 } ]} > - - + @@ -518,7 +498,7 @@ export default function WalletSetupScreen() { Select 12 or 24 word generation, enter your wallet name and select a color - - + {showWordCountDropdown && ( - { setWordCount(12); @@ -547,8 +527,8 @@ export default function WalletSetupScreen() { > 12 words {wordCount === 12 && } - - + { setWordCount(24); @@ -558,7 +538,7 @@ export default function WalletSetupScreen() { > 24 words {wordCount === 24 && } - + )} @@ -585,7 +565,7 @@ export default function WalletSetupScreen() { {WALLET_COLOR_PALETTE.map((colorOption) => ( - setSelectedColor(colorOption.base)} @@ -600,7 +580,7 @@ export default function WalletSetupScreen() { )} - + ))} @@ -630,7 +610,7 @@ export default function WalletSetupScreen() { ))} - Copy - + - setHasStoredPhrase(!hasStoredPhrase)} accessibilityRole="checkbox" @@ -670,14 +650,14 @@ export default function WalletSetupScreen() { {hasStoredPhrase && ( )} - + I have securely stored my recovery phrase - setAcceptedTerms(!acceptedTerms)} accessibilityRole="checkbox" @@ -689,7 +669,7 @@ export default function WalletSetupScreen() { {acceptedTerms && ( )} - + I accept the{' '} - {isLoading ? 'Creating...' : 'Confirm'} - + - openLink('https://www.bitsleuth.ai/glossary/passphrase')} > What is a recovery phrase? - + ); @@ -741,7 +721,7 @@ export default function WalletSetupScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - { console.log('Import back button pressed'); @@ -754,7 +734,7 @@ export default function WalletSetupScreen() { activeOpacity={0.7} > - + @@ -787,7 +767,7 @@ export default function WalletSetupScreen() { {WALLET_COLOR_PALETTE.map((colorOption) => ( - setSelectedColor(colorOption.base)} @@ -802,7 +782,7 @@ export default function WalletSetupScreen() { )} - + ))} @@ -811,7 +791,7 @@ export default function WalletSetupScreen() { Recovery Phrase (12 or 24 words) - { if (Platform.OS === 'web') { @@ -823,7 +803,7 @@ export default function WalletSetupScreen() { > Scan QR - + - {isLoading ? 'Importing...' : 'Import Wallet'} - + - openLink('https://www.bitsleuth.ai/glossary/passphrase')} accessibilityRole="link" @@ -877,7 +857,7 @@ export default function WalletSetupScreen() { What is a recovery phrase? - + @@ -955,13 +935,13 @@ export default function WalletSetupScreen() { const renderConfirmMode = () => ( - setMode('create')} activeOpacity={0.7} > - + @@ -998,7 +978,7 @@ export default function WalletSetupScreen() { ))} - {isLoading ? 'Creating Wallet...' : 'Create Wallet'} - + - openLink('https://www.bitsleuth.ai/glossary/passphrase')} > What is a recovery phrase? - + ); diff --git a/components/AnimatedNumber.tsx b/components/AnimatedNumber.tsx deleted file mode 100644 index 504d7f83..00000000 --- a/components/AnimatedNumber.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useEffect } from 'react'; -import { StyleProp, TextInput, TextStyle } from 'react-native'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withSpring, - withTiming, - useAnimatedProps, -} from 'react-native-reanimated'; - -// Extend TextInput props to include Reanimated's virtual 'text' prop for animating text content -const AnimatedTextInput = Animated.createAnimatedComponent( - TextInput as React.ComponentType & { text?: string }> -); - -interface AnimatedNumberProps { - value: number; - formatter?: (value: number) => string; - style?: StyleProp; - duration?: number; - prefix?: string; - suffix?: string; -} - -/** - * AnimatedNumber - Smoothly animates between numeric values with a counting effect. - * Used for balance displays, price tickers, and any numeric value that changes over time. - * Creates a premium feel by making value changes feel alive rather than instant. - */ -export default function AnimatedNumber({ - value, - formatter, - style, - duration = 800, - prefix = '', - suffix = '', -}: AnimatedNumberProps) { - const animatedValue = useSharedValue(value); - - useEffect(() => { - animatedValue.value = withTiming(value, { duration }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, duration]); // animatedValue is a stable shared value ref and should not be in deps - - // Scale animation on value change for emphasis - const scale = useSharedValue(1); - - useEffect(() => { - scale.value = withSpring(1.02, { damping: 8, stiffness: 300 }, () => { - scale.value = withSpring(1, { damping: 15, stiffness: 400 }); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); // scale is a stable shared value ref and should not be in deps - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - const format = formatter || ((v: number) => v.toFixed(2)); - - const animatedProps = useAnimatedProps(() => { - const text = `${prefix}${format(animatedValue.value)}${suffix}`; - return { - text, - }; - }); - - return ( - - - - ); -} diff --git a/components/AnimatedPressable.tsx b/components/AnimatedPressable.tsx deleted file mode 100644 index 8e286386..00000000 --- a/components/AnimatedPressable.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { HapticService } from '@/services/haptic-service'; -import React, { useCallback } from 'react'; -import { AccessibilityRole, Insets, StyleProp, ViewStyle } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { - runOnJS, - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated'; - -type HapticType = 'light' | 'medium' | 'heavy' | 'none'; - -/** - * Props for the AnimatedPressable component - */ -interface AnimatedPressableProps { - /** Content to render inside the pressable */ - children: React.ReactNode; - /** Callback fired when the component is pressed */ - onPress?: () => void; - /** Callback fired when the component is long-pressed (min 500ms) */ - onLongPress?: () => void; - /** Custom styles to apply to the container */ - style?: StyleProp; - /** Scale factor when pressed (default: 0.97) */ - scaleDown?: number; - /** Haptic feedback type (default: 'light') */ - haptic?: HapticType; - /** Whether the component is disabled (default: false) */ - disabled?: boolean; - /** Accessibility label for screen readers */ - accessibilityLabel?: string; - /** Accessibility role (e.g., 'button', 'link') */ - accessibilityRole?: AccessibilityRole; - /** Accessibility hint to describe the action */ - accessibilityHint?: string; - /** Test identifier for testing frameworks */ - testID?: string; - /** Expand the touchable area beyond the visible bounds */ - hitSlop?: Insets | number; -} - -const SPRING_CONFIG = { damping: 15, stiffness: 400 }; - -/** - * AnimatedPressable - A universal pressable component with spring animations and haptics. - * Provides consistent interaction feedback across the entire app. - * Uses Gesture Handler for smoother gesture recognition. - * - * @example - * ```tsx - * console.log('Pressed')} - * accessibilityLabel="Submit form" - * accessibilityRole="button" - * haptic="medium" - * > - * Press Me - * - * ``` - */ -const AnimatedPressable = React.memo(({ - children, - onPress, - onLongPress, - style, - scaleDown = 0.97, - haptic = 'light', - disabled = false, - accessibilityLabel, - accessibilityRole = 'button', - accessibilityHint, - testID, - hitSlop, -}: AnimatedPressableProps) => { - const scale = useSharedValue(1); - const opacity = useSharedValue(1); - - const triggerHaptic = useCallback((type: HapticType) => { - if (type === 'none') return; - switch (type) { - case 'light': - HapticService.light(); - break; - case 'medium': - HapticService.medium(); - break; - case 'heavy': - HapticService.heavy(); - break; - } - }, []); - - const handlePress = useCallback(() => { - if (!disabled && onPress) { - onPress(); - } - }, [disabled, onPress]); - - const handleLongPress = useCallback(() => { - if (!disabled && onLongPress) { - onLongPress(); - } - }, [disabled, onLongPress]); - - const tapGesture = Gesture.Tap() - .enabled(!disabled) - .onBegin(() => { - scale.value = withSpring(scaleDown, SPRING_CONFIG); - opacity.value = withSpring(0.9, SPRING_CONFIG); - if (haptic !== 'none') { - runOnJS(triggerHaptic)(haptic); - } - }) - .onEnd(() => { - runOnJS(handlePress)(); - }) - .onFinalize(() => { - scale.value = withSpring(1, SPRING_CONFIG); - opacity.value = withSpring(1, SPRING_CONFIG); - }); - - const longPressGesture = Gesture.LongPress() - .enabled(!disabled && !!onLongPress) - .minDuration(500) - .onStart(() => { - runOnJS(triggerHaptic)('medium'); - runOnJS(handleLongPress)(); - }); - - const composedGesture = onLongPress - ? Gesture.Race(tapGesture, longPressGesture) - : tapGesture; - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - opacity: disabled ? 0.5 : opacity.value, - })); - - return ( - - - {children} - - - ); -}); - -AnimatedPressable.displayName = 'AnimatedPressable'; - -export default AnimatedPressable; diff --git a/components/AppButton.tsx b/components/AppButton.tsx new file mode 100644 index 00000000..88d4930c --- /dev/null +++ b/components/AppButton.tsx @@ -0,0 +1,125 @@ +import { createButtonStyle } from '@/constants/themes'; +import { useTheme } from '@/hooks/theme-store'; +import { HapticService } from '@/services/haptic-service'; +import React, { ReactNode, useCallback, useMemo } from 'react'; +import { + ActivityIndicator, + Pressable, + StyleProp, + StyleSheet, + Text, + TextStyle, + View, + ViewStyle, +} from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +export type AppButtonVariant = 'primary' | 'secondary' | 'outline' | 'destructive'; + +interface AppButtonProps { + title: string; + onPress: () => void; + variant?: AppButtonVariant; + disabled?: boolean; + loading?: boolean; + /** Optional icon rendered to the left of the title */ + icon?: ReactNode; + style?: StyleProp; + textStyle?: StyleProp; + testID?: string; +} + +/** + * AppButton - The shared button primitive: themed variants built on + * createButtonStyle, spring press feedback, haptics, and loading/disabled + * states. Prefer this over ad-hoc TouchableOpacity buttons for CTAs. + */ +export function AppButton({ + title, + onPress, + variant = 'primary', + disabled = false, + loading = false, + icon, + style, + textStyle, + testID, +}: AppButtonProps) { + const { theme } = useTheme(); + const scale = useSharedValue(1); + + const isDisabled = disabled || loading; + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + const baseStyle = useMemo(() => { + if (variant === 'destructive') { + return { ...createButtonStyle(theme, 'primary'), backgroundColor: theme.colors.error }; + } + return createButtonStyle(theme, variant); + }, [theme, variant]); + + const textColor = + variant === 'secondary' ? theme.colors.text + : variant === 'outline' ? theme.colors.primary + : '#FFFFFF'; + + const handlePressIn = useCallback(() => { + if (isDisabled) return; + HapticService.light(); + scale.value = withSpring(0.97, { damping: 15, stiffness: 400 }); + }, [isDisabled, scale]); + + const handlePressOut = useCallback(() => { + scale.value = withSpring(1, { damping: 15, stiffness: 400 }); + }, [scale]); + + return ( + + {loading ? ( + + ) : ( + + {icon ?? null} + + {title} + + + )} + + ); +} + +const styles = StyleSheet.create({ + content: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + text: { + fontSize: 17, + fontWeight: '700', + letterSpacing: 0.2, + }, + disabled: { + opacity: 0.5, + shadowOpacity: 0, + elevation: 0, + }, +}); + +export default AppButton; diff --git a/components/BitSleuthButton.tsx b/components/BitSleuthButton.tsx deleted file mode 100644 index e172a5d0..00000000 --- a/components/BitSleuthButton.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { createButtonStyle, lightTheme } from '@/constants/themes'; -import { HapticService } from '@/services/haptic-service'; -import { LinearGradient } from 'expo-linear-gradient'; -import React from 'react'; -import { - ActivityIndicator, - StyleSheet, - Text, - TextStyle, - TouchableOpacity, - ViewStyle, -} from 'react-native'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withSequence, - withSpring, - withTiming -} from 'react-native-reanimated'; - -interface BitSleuthButtonProps { - title: string; - onPress: () => void | Promise; - variant?: 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'error' | 'ghost' | 'fun'; - size?: 'small' | 'medium' | 'large'; - disabled?: boolean; - loading?: boolean; - emoji?: string; - style?: ViewStyle; - textStyle?: TextStyle; - hapticType?: 'light' | 'medium' | 'heavy' | 'success' | 'error' | 'warning'; - animated?: boolean; -} - -const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity); - -export default function BitSleuthButton({ - title, - onPress, - variant = 'primary', - size = 'medium', - disabled = false, - loading = false, - emoji, - style, - textStyle, - hapticType = 'light', - animated = true, -}: BitSleuthButtonProps) { - // Animation values - const scale = useSharedValue(1); - const rotation = useSharedValue(0); - const opacity = useSharedValue(1); - - // Size configurations - const sizeConfig = { - small: { paddingVertical: 10, paddingHorizontal: 16, fontSize: 14, minHeight: 40 }, - medium: { paddingVertical: 14, paddingHorizontal: 20, fontSize: 16, minHeight: 52 }, - large: { paddingVertical: 18, paddingHorizontal: 24, fontSize: 18, minHeight: 60 }, - }; - - const currentSize = sizeConfig[size]; - - // Animated styles - const animatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { scale: scale.value }, - { rotate: `${rotation.value}deg` }, - ], - opacity: opacity.value, - }; - }); - - // Press animations - const handlePressIn = () => { - if (!animated || disabled || loading) return; - - scale.value = withSpring(0.95, { damping: 15, stiffness: 300 }); - - // Trigger haptic feedback - HapticService[hapticType](); - }; - - const handlePressOut = () => { - if (!animated || disabled || loading) return; - - scale.value = withSpring(1, { damping: 15, stiffness: 300 }); - }; - - // Success animation - const triggerSuccessAnimation = () => { - if (!animated) return; - - // Scale up then down - scale.value = withSequence( - withSpring(1.1, { damping: 10, stiffness: 300 }), - withSpring(1, { damping: 15, stiffness: 300 }) - ); - - // Slight rotation for delight - rotation.value = withSequence( - withTiming(-2, { duration: 100 }), - withTiming(2, { duration: 100 }), - withTiming(0, { duration: 100 }) - ); - }; - - // Enhanced press handler - const handlePress = async () => { - if (disabled || loading) return; - - triggerSuccessAnimation(); - - try { - await onPress(); - } catch (err) { - console.error('Button press error:', err); - // Error animation - if (animated) { - opacity.value = withSequence( - withTiming(0.7, { duration: 100 }), - withTiming(1, { duration: 100 }) - ); - } - } - }; - - // Button style - // Map variant to theme variant - const themeVariant = (() => { - switch (variant) { - case 'accent': - case 'success': - case 'warning': - case 'error': - case 'ghost': - return 'primary'; - case 'secondary': - return 'secondary'; - case 'fun': - return 'fun'; - default: - return variant as 'primary' | 'secondary' | 'outline' | 'gradient' | 'fun'; - } - })(); - - const buttonStyle = [ - createButtonStyle(lightTheme, themeVariant), - currentSize, - style, - animatedStyle, - ]; - - // Text style - const textStyleConfig = [ - styles.text, - { fontSize: currentSize.fontSize, color: variant === 'ghost' ? '#FF6B6B' : 'white' }, - textStyle, - ]; - - // Render content - const renderContent = () => { - if (loading) { - return ( - <> - - Loading... - - ); - } - - return ( - <> - {emoji && {emoji}} - {title} - - ); - }; - - // Special handling for gradient variants - if (variant === 'primary' && animated) { - return ( - - - {renderContent()} - - ); - } - - return ( - - {renderContent()} - - ); -} - -const styles = StyleSheet.create({ - text: { - fontWeight: '600', - textAlign: 'center', - marginLeft: 4, - }, - loadingText: { - marginLeft: 8, - }, - emoji: { - fontSize: 18, - marginRight: 6, - }, -}); diff --git a/components/BitSleuthCard.tsx b/components/BitSleuthCard.tsx deleted file mode 100644 index 0d15fc7f..00000000 --- a/components/BitSleuthCard.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { createCardStyle, lightTheme } from '@/constants/themes'; -import { HapticService } from '@/services/haptic-service'; -import { LinearGradient } from 'expo-linear-gradient'; -import React from 'react'; -import { - StyleSheet, - TouchableOpacity, - ViewStyle -} from 'react-native'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withSpring, - withTiming -} from 'react-native-reanimated'; - -interface BitSleuthCardProps { - children: React.ReactNode; - variant?: 'default' | 'elevated' | 'fun' | 'gradient'; - color?: 'purple' | 'yellow' | 'pink' | 'orange' | 'lime'; - onPress?: () => void; - style?: ViewStyle; - animated?: boolean; - hapticFeedback?: boolean; - shadowElevation?: 'small' | 'medium' | 'large'; -} - -const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity); - -export default function BitSleuthCard({ - children, - variant = 'default', - color = 'purple', - onPress, - style, - animated = true, - hapticFeedback = true, - shadowElevation = 'medium', -}: BitSleuthCardProps) { - // Animation values - const scale = useSharedValue(1); - const translateY = useSharedValue(0); - const shadowOpacity = useSharedValue(0.15); - - // Color mapping for fun variants - const funColors: Record = { - purple: ['#9B59B6', '#8E44AD'], - yellow: ['#F1C40F', '#F39C12'], - pink: ['#E91E63', '#C2185B'], - orange: ['#E67E22', '#D35400'], - lime: ['#CDDC39', '#C0CA33'], - }; - - // Animated styles - const animatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { scale: scale.value }, - { translateY: translateY.value }, - ], - shadowOpacity: shadowOpacity.value, - }; - }); - - // Press animations - const handlePressIn = () => { - if (!animated) return; - - scale.value = withSpring(0.98, { damping: 15, stiffness: 300 }); - translateY.value = withSpring(2, { damping: 15, stiffness: 300 }); - shadowOpacity.value = withTiming(0.05, { duration: 150 }); - - if (hapticFeedback) { - HapticService.light(); - } - }; - - const handlePressOut = () => { - if (!animated) return; - - scale.value = withSpring(1, { damping: 15, stiffness: 300 }); - translateY.value = withSpring(0, { damping: 15, stiffness: 300 }); - shadowOpacity.value = withTiming(0.15, { duration: 150 }); - }; - - // Card style - // Map variant to theme variant - const themeVariant = variant === 'gradient' ? 'elevated' : variant as 'default' | 'elevated' | 'fun'; - - const cardStyle = [ - createCardStyle(lightTheme, themeVariant), - style, - animatedStyle, - ]; - - // Render card content - const renderCardContent = () => { - if (variant === 'gradient') { - return ( - - {children} - - ); - } - - if (variant === 'fun') { - return ( - - {children} - - ); - } - - return children; - }; - - // If card is pressable - if (onPress) { - return ( - - {renderCardContent()} - - ); - } - - // Static card - return ( - - {renderCardContent()} - - ); -} diff --git a/components/ConfettiCelebration.tsx b/components/ConfettiCelebration.tsx deleted file mode 100644 index 1135b460..00000000 --- a/components/ConfettiCelebration.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { HapticService } from '@/services/haptic-service'; -import React, { useEffect, useRef } from 'react'; -import { Dimensions, StyleSheet, View } from 'react-native'; -import ConfettiCannon from 'react-native-confetti-cannon'; - -const { height: screenHeight } = Dimensions.get('window'); - -interface ConfettiCelebrationProps { - isVisible: boolean; - onComplete?: () => void; - type?: 'success' | 'milestone' | 'wallet-created' | 'transaction-complete' | 'balance-update'; - position?: 'top' | 'center' | 'bottom'; -} - -export default function ConfettiCelebration({ - isVisible, - onComplete, - type = 'success', - position = 'center', -}: ConfettiCelebrationProps) { - const confettiRef = useRef(null); - - // Configuration for different celebration types - const getConfettiConfig = () => { - switch (type) { - case 'success': - return { - colors: ['#2ECC71', '#27AE60', '#58D68D'], // Green success colors - count: 200, - origin: { x: -10, y: 0 }, - autoStart: false, - fadeOut: true, - }; - case 'milestone': - return { - colors: ['#F1C40F', '#F39C12', '#E67E22'], // Gold milestone colors - count: 300, - origin: { x: -10, y: 0 }, - autoStart: false, - fadeOut: true, - }; - case 'wallet-created': - return { - colors: ['#9B59B6', '#8E44AD', '#E91E63'], // Purple/pink wallet colors - count: 250, - origin: { x: -10, y: 0 }, - autoStart: false, - fadeOut: true, - }; - case 'transaction-complete': - return { - colors: ['#3498DB', '#2980B9', '#5DADE2'], // Blue transaction colors - count: 180, - origin: { x: -10, y: 0 }, - autoStart: false, - fadeOut: true, - }; - case 'balance-update': - return { - colors: ['#1ABC9C', '#16A085', '#48C9B0'], // Teal balance colors - count: 150, - origin: { x: -10, y: 0 }, - autoStart: false, - fadeOut: true, - }; - default: - return { - colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'], // Default brand colors - count: 200, - origin: { x: -10, y: 0 }, - autoStart: false, - fadeOut: true, - }; - } - }; - - // Position configuration - const getPositionStyle = (): { top?: number; bottom?: number } => { - switch (position) { - case 'top': - return { top: 100 }; - case 'center': - return { top: screenHeight / 2 }; - case 'bottom': - return { bottom: 100 }; - default: - return { top: screenHeight / 2 }; - } - }; - - useEffect(() => { - if (isVisible && confettiRef.current) { - // Trigger haptic feedback based on celebration type - switch (type) { - case 'success': - HapticService.success(); - break; - case 'milestone': - HapticService.heavy(); - break; - case 'wallet-created': - HapticService.walletCreated(); - break; - case 'transaction-complete': - HapticService.transactionSuccess(); - break; - case 'balance-update': - HapticService.balanceUpdate(); - break; - } - - // Start confetti animation - confettiRef.current.start(); - - // Call onComplete after animation duration - setTimeout(() => { - onComplete?.(); - }, 3000); - } - }, [isVisible, type, onComplete]); - - if (!isVisible) return null; - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - container: { - position: 'absolute', - left: 0, - right: 0, - zIndex: 1000, - }, -}); diff --git a/components/EmojiReaction.tsx b/components/EmojiReaction.tsx deleted file mode 100644 index 706bb9d6..00000000 --- a/components/EmojiReaction.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { platformStyles } from '@/constants/themes'; -import React, { useCallback, useEffect, useState } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withSpring, - withTiming -} from 'react-native-reanimated'; - -interface EmojiReactionProps { - action: 'success' | 'error' | 'warning' | 'loading' | 'complete' | 'milestone' | 'balance-up' | 'balance-down'; - message?: string; - duration?: number; - onComplete?: () => void; -} - -export default function EmojiReaction({ - action, - message, - duration = 2000, - onComplete, -}: EmojiReactionProps) { - const [isVisible, setIsVisible] = useState(false); - const scale = useSharedValue(0); - const opacity = useSharedValue(0); - - // Contextual emoji and message mapping - const getReactionConfig = () => { - switch (action) { - case 'success': - return { - emoji: '🎉', - color: '#2ECC71', - message: message || 'Success!', - }; - case 'error': - return { - emoji: '😅', - color: '#E74C3C', - message: message || 'Oops!', - }; - case 'warning': - return { - emoji: '⚠️', - color: '#F39C12', - message: message || 'Heads up!', - }; - case 'loading': - return { - emoji: '⏳', - color: '#3498DB', - message: message || 'Loading...', - }; - case 'complete': - return { - emoji: '✅', - color: '#27AE60', - message: message || 'Complete!', - }; - case 'milestone': - return { - emoji: '🏆', - color: '#F1C40F', - message: message || 'Milestone reached!', - }; - case 'balance-up': - return { - emoji: '📈', - color: '#2ECC71', - message: message || 'Balance increased!', - }; - case 'balance-down': - return { - emoji: '📉', - color: '#E74C3C', - message: message || 'Balance decreased', - }; - default: - return { - emoji: '✨', - color: '#9B59B6', - message: message || 'Action completed', - }; - } - }; - - const config = getReactionConfig(); - - const hideReaction = useCallback(() => { - scale.value = withSpring(0, { damping: 8, stiffness: 100 }); - opacity.value = withTiming(0, { duration: 200 }, () => { - setIsVisible(false); - onComplete?.(); - }); - }, [scale, opacity, onComplete]); - - const showReaction = useCallback(() => { - setIsVisible(true); - - // Animate in - scale.value = withSpring(1, { damping: 8, stiffness: 100 }); - opacity.value = withTiming(1, { duration: 300 }); - - // Auto-hide after duration - setTimeout(() => { - hideReaction(); - }, duration); - }, [scale, opacity, duration, hideReaction]); - - useEffect(() => { - if (action) { - showReaction(); - } - }, [action, showReaction]); - - // Animated styles - const animatedStyle = useAnimatedStyle(() => { - return { - transform: [{ scale: scale.value }], - opacity: opacity.value, - }; - }); - - if (!isVisible) return null; - - return ( - - - {config.emoji} - {config.message} - - - ); -} - -const styles = StyleSheet.create({ - container: { - position: 'absolute', - top: '50%', - left: '50%', - zIndex: 1000, - }, - reactionContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 12, - borderRadius: 25, - ...platformStyles.cardShadow, - transform: [{ translateX: -50 }, { translateY: -25 }], - }, - emoji: { - fontSize: 24, - marginRight: 8, - }, - message: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, -}); diff --git a/components/FeedbackPopup.tsx b/components/FeedbackPopup.tsx index a094aec9..307b1cf5 100644 --- a/components/FeedbackPopup.tsx +++ b/components/FeedbackPopup.tsx @@ -1,8 +1,10 @@ import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { PressableOpacity } from '@/components/PressableOpacity'; +import { useTheme } from '@/hooks/theme-store'; +import { WalletsContext } from '@/hooks/wallet-contexts'; import { Mail, X } from 'lucide-react-native'; -import React from 'react'; -import { Linking, Modal, Platform, Text, TouchableOpacity, View } from 'react-native'; +import React, { useContext } from 'react'; +import { Linking, Modal, Platform, Text, View } from 'react-native'; interface FeedbackPopupProps { visible: boolean; @@ -11,14 +13,13 @@ interface FeedbackPopupProps { } export default function FeedbackPopup({ visible, onDismiss, onSubmitFeedback }: FeedbackPopupProps) { - const walletContext = useWallet(); - + const walletData = useContext(WalletsContext); + const { theme } = useTheme(); + // Safety check: if context is not available yet, don't render - if (!walletContext) { + if (!walletData) { return null; } - - const { theme } = walletContext; const handleSubmitFeedback = async () => { const email = 'feedback@bitsleuth.ai'; @@ -83,14 +84,14 @@ export default function FeedbackPopup({ visible, onDismiss, onSubmitFeedback }: }}> Share Your Feedback - - + {/* Content */} @@ -108,7 +109,7 @@ export default function FeedbackPopup({ visible, onDismiss, onSubmitFeedback }: flexDirection: 'row', gap: 12, }}> - Maybe Later - + - Send Feedback - + diff --git a/components/GradientBackground.tsx b/components/GradientBackground.tsx index ae6f4fcd..e7f0ce50 100644 --- a/components/GradientBackground.tsx +++ b/components/GradientBackground.tsx @@ -48,39 +48,30 @@ export const GradientBackground: React.FC = ({ return [theme.colors.background, theme.colors.surface, theme.colors.background]; } } else { - // Light mode - ensure gradients end with solid white at bottom for tab bar consistency + // Light mode mirrors the dark branch: soft system-grey background with a + // subtle warm glow, ending in solid surface white for tab bar consistency switch (variant) { case 'primary': - // Beautiful coral gradient that fades to solid white at bottom - return [ - '#FFE5DB', // Light peach - '#FFD4C4', // Soft coral - '#FFF5F0', // Very light peach - '#FFFFFF', // Pure white at bottom for tab bar - ]; + return [theme.colors.background, theme.colors.surface, theme.colors.surface]; case 'secondary': - return [ - '#FFE5DB', - '#FFDDD2', - '#FFFFFF', // Pure white at bottom - ]; + return [theme.colors.surfaceDark, theme.colors.surface, theme.colors.surface]; case 'accent': return [ - '#FFC3AD', - '#FFD4C4', - '#FFFFFF', // Pure white at bottom + `${theme.colors.glowPrimary}`, + theme.colors.background, + theme.colors.surface, // End with solid surface for tab bar ]; case 'subtle': - return ['#FFF5F0', '#FFE5DB', '#FFFFFF']; + return [theme.colors.background, theme.colors.surface, theme.colors.surface]; case 'glow': return [ - '#FFE5DB', - '#FFC3AD', - '#FFF5F0', - '#FFFFFF', // Pure white at bottom + theme.colors.background, + `${theme.colors.glowPrimary}`, + `${theme.colors.glowSecondary}`, + theme.colors.surface, // End with solid surface for tab bar ]; default: - return ['#FFE5DB', '#FFD4C4', '#FFFFFF']; + return [theme.colors.background, theme.colors.surface, theme.colors.surface]; } } }, [theme.isDark, theme.colors.background, theme.colors.surface, theme.colors.surfaceDark, theme.colors.glowPrimary, theme.colors.glowSecondary, variant]); diff --git a/components/LiquidGlassView.tsx b/components/LiquidGlassView.tsx index c074000a..8e9fa186 100644 --- a/components/LiquidGlassView.tsx +++ b/components/LiquidGlassView.tsx @@ -1,7 +1,8 @@ -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { WalletsContext } from '@/hooks/wallet-contexts'; import { getLiquidGlassTint, getThinMaterialTint, getUltraThinMaterialTint, isIOS } from '@/utils/platform'; import { BlurView } from 'expo-blur'; -import React from 'react'; +import React, { useContext } from 'react'; import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; export type LiquidGlassVariant = 'chrome' | 'thin' | 'ultraThin'; @@ -52,15 +53,14 @@ export function LiquidGlassView({ style, children, }: LiquidGlassViewProps) { - const walletContext = useWallet(); - + const walletData = useContext(WalletsContext); + const { theme } = useTheme(); + // Safety check: if context is not available yet, return null - if (!walletContext) { + if (!walletData) { return null; } - - const { theme } = walletContext; - + // Get the appropriate tint based on variant and theme const getTint = () => { switch (variant) { diff --git a/components/LoadingAnimation.tsx b/components/LoadingAnimation.tsx deleted file mode 100644 index 6ed40b33..00000000 --- a/components/LoadingAnimation.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useEffect } from 'react'; -import { StyleSheet, View } from 'react-native'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withDelay, - withRepeat, - withSequence, - withTiming, -} from 'react-native-reanimated'; - -interface LoadingAnimationProps { - size?: 'small' | 'medium' | 'large'; - color?: string; -} - -export default function LoadingAnimation({ - size = 'medium', - color = '#26F5FE' -}: LoadingAnimationProps) { - const scale1 = useSharedValue(0.8); - const scale2 = useSharedValue(0.8); - const scale3 = useSharedValue(0.8); - const opacity1 = useSharedValue(0.3); - const opacity2 = useSharedValue(0.3); - const opacity3 = useSharedValue(0.3); - - const sizeMap = { - small: 12, - medium: 16, - large: 20, - }; - - const dotSize = sizeMap[size]; - - useEffect(() => { - const animation = (scaleValue: ReturnType>, opacityValue: ReturnType>, delay: number) => { - scaleValue.value = withDelay( - delay, - withRepeat( - withSequence( - withTiming(1.2, { duration: 600 }), - withTiming(0.8, { duration: 600 }) - ), - -1, - false - ) - ); - - opacityValue.value = withDelay( - delay, - withRepeat( - withSequence( - withTiming(1, { duration: 600 }), - withTiming(0.3, { duration: 600 }) - ), - -1, - false - ) - ); - }; - - animation(scale1, opacity1, 0); - animation(scale2, opacity2, 200); - animation(scale3, opacity3, 400); - }, [scale1, scale2, scale3, opacity1, opacity2, opacity3]); - - const animatedStyle1 = useAnimatedStyle(() => ({ - transform: [{ scale: scale1.value }], - opacity: opacity1.value, - })); - - const animatedStyle2 = useAnimatedStyle(() => ({ - transform: [{ scale: scale2.value }], - opacity: opacity2.value, - })); - - const animatedStyle3 = useAnimatedStyle(() => ({ - transform: [{ scale: scale3.value }], - opacity: opacity3.value, - })); - - return ( - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - }, - dot: { - borderRadius: 999, - }, -}); - diff --git a/components/NetworkStatus.tsx b/components/NetworkStatus.tsx index 458a8d07..2735fb5e 100644 --- a/components/NetworkStatus.tsx +++ b/components/NetworkStatus.tsx @@ -4,7 +4,8 @@ */ import React, { useEffect, useState } from 'react'; -import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { PressableOpacity } from '@/components/PressableOpacity'; +import { Alert, StyleSheet, Text, View } from 'react-native'; import { NetworkDiagnosticResult, networkDiagnosticService } from '../services/network-diagnostic-service'; interface NetworkStatusProps { @@ -95,25 +96,25 @@ export const NetworkStatus: React.FC = ({ onDiagnosticsCompl {getStatusIcon()} {getStatusText()} - - + {lastError && ( {lastError} - Run Diagnostics - + )} diff --git a/components/OptimizedTouchableOpacity.tsx b/components/OptimizedTouchableOpacity.tsx deleted file mode 100644 index 30bee863..00000000 --- a/components/OptimizedTouchableOpacity.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react'; -import { TouchableOpacity, TouchableOpacityProps } from 'react-native'; - -interface OptimizedTouchableOpacityProps extends TouchableOpacityProps { - children: React.ReactNode; -} - -/** - * Shallow equality check for objects - */ -const shallowEqual = (obj1: any, obj2: any): boolean => { - if (obj1 === obj2) return true; - - if (!obj1 || !obj2) return false; - - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); - - if (keys1.length !== keys2.length) return false; - - for (const key of keys1) { - if (obj1[key] !== obj2[key]) return false; - } - - return true; -}; - -/** - * Optimized TouchableOpacity that prevents unnecessary re-renders - * by memoizing the component and only re-rendering when props actually change - */ -const OptimizedTouchableOpacity = React.memo( - ({ children, ...props }) => { - return {children}; - }, - (prevProps, nextProps) => { - // Custom comparison function to prevent re-renders when only style objects change - // but their content is the same - const prevKeys = Object.keys(prevProps); - const nextKeys = Object.keys(nextProps); - - // Check if number of props changed (prop added/removed) - if (prevKeys.length !== nextKeys.length) { - return false; - } - - // Check all props from both objects - const allKeys = new Set([...prevKeys, ...nextKeys]); - - for (const key of allKeys) { - if (key === 'style') { - // Deep comparison for style objects - const prevStyle = prevProps[key]; - const nextStyle = nextProps[key]; - - // If both are undefined/null, they're equal - if (!prevStyle && !nextStyle) continue; - - // If one is undefined/null and the other isn't, they're different - if (!prevStyle || !nextStyle) return false; - - // If both are arrays, compare each element - if (Array.isArray(prevStyle) && Array.isArray(nextStyle)) { - if (prevStyle.length !== nextStyle.length) return false; - for (let i = 0; i < prevStyle.length; i++) { - if (!shallowEqual(prevStyle[i], nextStyle[i])) return false; - } - continue; - } - - // If both are objects, do shallow comparison - if (typeof prevStyle === 'object' && typeof nextStyle === 'object') { - if (!shallowEqual(prevStyle, nextStyle)) return false; - continue; - } - - // For primitive values, direct comparison - if (prevStyle !== nextStyle) return false; - continue; - } - - // For non-style props, direct comparison - if ((prevProps as any)[key] !== (nextProps as any)[key]) { - return false; // Props changed, should re-render - } - } - - return true; // Props are the same, skip re-render - } -); - -OptimizedTouchableOpacity.displayName = 'OptimizedTouchableOpacity'; - -export default OptimizedTouchableOpacity; diff --git a/components/PinUnlockScreen.tsx b/components/PinUnlockScreen.tsx index 316dc491..08e9a6e6 100644 --- a/components/PinUnlockScreen.tsx +++ b/components/PinUnlockScreen.tsx @@ -1,7 +1,9 @@ import { GradientBackground } from '@/components/GradientBackground'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { platformStyles } from '@/constants/themes'; import { useAutoLock } from '@/hooks/auto-lock-store'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { useWalletActions } from '@/hooks/wallet-contexts'; import * as Haptics from 'expo-haptics'; import { router } from 'expo-router'; import { Delete, Fingerprint, Lock, Shield } from 'lucide-react-native'; @@ -12,13 +14,13 @@ import { SafeAreaView, StyleSheet, Text, - TouchableOpacity, Vibration, View, } from 'react-native'; export default function PinUnlockScreen() { - const { theme, logoutAndEraseWallet } = useWallet(); + const { theme } = useTheme(); + const { logoutAndEraseWallet } = useWalletActions(); const { unlock, biometricEnabled, biometricType, authenticateWithBiometric } = useAutoLock(); const [pin, setPin] = useState(''); const [attempts, setAttempts] = useState(0); @@ -205,7 +207,7 @@ export default function PinUnlockScreen() { if (item === 'delete') { return ( - - + ); } return ( - {item} - + ); })} @@ -262,7 +264,7 @@ export default function PinUnlockScreen() { )} {showBiometricButton && ( - Use {getBiometricText()} - + )} @@ -282,11 +284,11 @@ export default function PinUnlockScreen() { {renderNumberPad} - + Forgot PIN? Restore Wallet - + ); diff --git a/components/PinVerificationScreen.tsx b/components/PinVerificationScreen.tsx index f86a0363..9bb2bd83 100644 --- a/components/PinVerificationScreen.tsx +++ b/components/PinVerificationScreen.tsx @@ -1,6 +1,7 @@ import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import { PressableOpacity } from '@/components/PressableOpacity'; +import { useTheme } from '@/hooks/theme-store'; +import { getPin as getSecurePin } from '@/services/secure-pin-service'; import * as Haptics from 'expo-haptics'; import { ArrowLeft, Delete } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; @@ -8,7 +9,6 @@ import { Platform, StyleSheet, Text, - TouchableOpacity, Vibration, View, } from 'react-native'; @@ -26,7 +26,7 @@ export default function PinVerificationScreen({ onSuccess, onBack, }: PinVerificationScreenProps) { - const { theme } = useWallet(); + const { theme } = useTheme(); const [pin, setPin] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -45,7 +45,7 @@ export default function PinVerificationScreen({ const verifyPin = useCallback(async (enteredPin: string) => { setIsLoading(true); try { - const storedPin = await AsyncStorage.getItem('pin'); + const storedPin = await getSecurePin(); if (storedPin === enteredPin) { // Trigger success haptic feedback asynchronously @@ -151,7 +151,7 @@ export default function PinVerificationScreen({ if (item === 'delete') { return ( - - + ); } return ( - {item} - + ); })} @@ -191,13 +191,13 @@ export default function PinVerificationScreen({ {/* Header */} - - + {title} diff --git a/components/PremiumButton.tsx b/components/PremiumButton.tsx deleted file mode 100644 index 7b8ba571..00000000 --- a/components/PremiumButton.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { platformStyles } from '@/constants/themes'; -import { HapticService } from '@/services/haptic-service'; -import { LinearGradient } from 'expo-linear-gradient'; -import React, { useCallback } from 'react'; -import { ActivityIndicator, StyleSheet, Text, TextStyle, TouchableOpacity, ViewStyle } from 'react-native'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withSequence, - withSpring, - withTiming, -} from 'react-native-reanimated'; - -interface PremiumButtonProps { - title: string; - onPress: () => void | Promise; - variant?: 'primary' | 'secondary' | 'success' | 'danger'; - disabled?: boolean; - loading?: boolean; - style?: ViewStyle; - textStyle?: TextStyle; - icon?: React.ReactNode; -} - -const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); - -export default function PremiumButton({ - title, - onPress, - variant = 'primary', - disabled = false, - loading = false, - style, - textStyle, - icon, -}: PremiumButtonProps) { - const scale = useSharedValue(1); - const opacity = useSharedValue(1); - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - opacity: opacity.value, - })); - - const handlePressIn = useCallback(() => { - if (disabled || loading) return; - HapticService.light(); - scale.value = withSpring(0.96, { damping: 15, stiffness: 400 }); - }, [disabled, loading, scale]); - - const handlePressOut = useCallback(() => { - if (disabled || loading) return; - scale.value = withSpring(1, { damping: 15, stiffness: 400 }); - }, [disabled, loading, scale]); - - const handlePress = useCallback(async () => { - if (disabled || loading) return; - - try { - await onPress(); - - // Success animation - only triggered after successful completion - scale.value = withSequence( - withSpring(1.05, { damping: 10, stiffness: 300 }), - withSpring(1, { damping: 15, stiffness: 400 }) - ); - HapticService.success(); - } catch (err) { - console.error('Premium button error:', err); - // Error feedback animation - opacity.value = withSequence( - withTiming(0.6, { duration: 100 }), - withTiming(1, { duration: 100 }) - ); - HapticService.error(); - } - }, [disabled, loading, onPress, scale, opacity]); - - const getGradientColors = (): readonly [string, string] => { - switch (variant) { - case 'primary': - return ['#26F5FE', '#00BCD4']; - case 'secondary': - return ['#FF8A65', '#FF6B6B']; - case 'success': - return ['#00E676', '#00C853']; - case 'danger': - return ['#FF5252', '#E91E63']; - default: - return ['#26F5FE', '#00BCD4']; - } - }; - - const buttonContent = ( - <> - {loading && } - {!loading && icon && <>{icon}} - - {loading ? 'Loading...' : title} - - - ); - - return ( - - - {buttonContent} - - ); -} - -const styles = StyleSheet.create({ - button: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 16, - paddingHorizontal: 24, - borderRadius: 16, - minHeight: 56, - overflow: 'hidden', - ...platformStyles.buttonShadow, - }, - gradient: { - borderRadius: 16, - }, - text: { - color: 'white', - fontSize: 17, - fontWeight: '700', - letterSpacing: 0.3, - }, - textWithIcon: { - marginLeft: 8, - }, - loader: { - marginRight: 8, - }, - disabled: { - opacity: 0.5, - }, -}); - diff --git a/components/PressableOpacity.tsx b/components/PressableOpacity.tsx new file mode 100644 index 00000000..c57925a4 --- /dev/null +++ b/components/PressableOpacity.tsx @@ -0,0 +1,32 @@ +import React, { forwardRef } from 'react'; +import { Pressable, PressableProps, StyleProp, View, ViewStyle } from 'react-native'; + +interface PressableOpacityProps extends Omit { + /** Opacity applied while pressed, mirroring TouchableOpacity's prop. */ + activeOpacity?: number; + style?: StyleProp; +} + +/** + * PressableOpacity - drop-in replacement for TouchableOpacity built on + * Pressable (per repo skill react-native-skills §9.3). Renders identically: + * dims to `activeOpacity` while pressed. Prefer AppButton for real CTAs; + * use this for rows, icon buttons, and other tap targets. + */ +export const PressableOpacity = forwardRef( + function PressableOpacity({ activeOpacity = 0.7, style, disabled, ...props }, ref) { + return ( + [ + style, + pressed && !disabled ? { opacity: activeOpacity } : null, + ]} + {...props} + /> + ); + } +); + +export default PressableOpacity; diff --git a/components/PriceChart.tsx b/components/PriceChart.tsx index 5efd3004..7b80e152 100644 --- a/components/PriceChart.tsx +++ b/components/PriceChart.tsx @@ -1,9 +1,16 @@ import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { WalletsContext, useWalletActions, useWalletBalance, useWalletTransactions } from '@/hooks/wallet-contexts'; import { Transaction } from '@/types/wallet'; import { WifiOff } from 'lucide-react-native'; -import React, { useRef, useState } from 'react'; -import { Animated, Dimensions, PanResponder, StyleSheet, Text, View } from 'react-native'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; +import { Dimensions, PanResponder, StyleSheet, Text, View } from 'react-native'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import Svg, { Circle, Defs, LinearGradient, Path, Stop } from 'react-native-svg'; const { width } = Dimensions.get('window'); @@ -26,42 +33,41 @@ interface BalanceChartProps { // Wrapper component that checks for context availability export default function BalanceChart({ selectedPeriod }: BalanceChartProps) { - const walletContext = useWallet(); - + const walletData = useContext(WalletsContext); + // Safety check: if context is not available yet, return null - if (!walletContext) { + if (!walletData) { return null; } - + return ; } // Main component with all hooks function BalanceChartContent({ selectedPeriod }: BalanceChartProps) { - const { theme, hasBalanceError, balance, transactions, bitcoinPrice, formatCurrency } = useWallet()!; // Non-null assertion is safe here because wrapper checked + const { theme } = useTheme(); + const { hasBalanceError, balance, bitcoinPrice } = useWalletBalance(); + const { transactions } = useWalletTransactions(); + const { formatCurrency } = useWalletActions(); const [selectedPoint, setSelectedPoint] = useState(null); const [showTooltip, setShowTooltip] = useState(false); - const tooltipOpacity = useRef(new Animated.Value(0)).current; + const tooltipOpacity = useSharedValue(0); - // Show error state only if balance data is unavailable - if (hasBalanceError) { - return ( - - - - Balance Chart Unavailable - - - Unable to load balance history - - - ); - } + const tooltipAnimatedStyle = useAnimatedStyle(() => ({ + opacity: tooltipOpacity.value, + })); - // Generate balance history based on actual transactions - const generateBalanceHistory = (): DataPoint[] => { + const hideTooltip = useCallback(() => { + setShowTooltip(false); + setSelectedPoint(null); + }, []); + + // Generate balance history based on actual transactions. + // Memoized: this is the most expensive synchronous computation on the home + // screen and must not re-run on unrelated store updates (e.g. price polls). + const balanceHistory = useMemo((): DataPoint[] => { const currentBalance = balance; - + // Calculate days based on selected period const getDaysForPeriod = (period: TimePeriod): number => { switch (period) { @@ -69,19 +75,19 @@ function BalanceChartContent({ selectedPeriod }: BalanceChartProps) { case '1W': return 7; case '1M': return 30; case '1Y': return 365; - case 'All': return Math.max(365, transactions.length > 0 ? + case 'All': return Math.max(365, transactions.length > 0 ? Math.ceil((Date.now() - Math.min(...transactions.map((tx: Transaction) => tx.timestamp))) / (24 * 60 * 60 * 1000)) : 365); default: return 30; } }; - + const days = getDaysForPeriod(selectedPeriod); - + // If no transactions, show flat line at current balance if (transactions.length === 0) { const dataPoints = selectedPeriod === '1D' ? 24 : Math.min(days, 100); // Limit data points for performance const interval = selectedPeriod === '1D' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; // 1 hour for 1D, 1 day for others - + return Array.from({ length: dataPoints }, (_, i) => ({ x: i, y: currentBalance, @@ -89,32 +95,33 @@ function BalanceChartContent({ selectedPeriod }: BalanceChartProps) { date: new Date(Date.now() - (dataPoints - 1 - i) * interval) })); } - + // Sort transactions by oldest first const sortedTransactions = [...transactions].sort((a, b) => a.timestamp - b.timestamp); - + // Create balance history by walking through transactions const history: DataPoint[] = []; let runningBalance = 0; - + // Calculate interval and data points based on period const dataPoints = selectedPeriod === '1D' ? 24 : Math.min(days, 100); const interval = selectedPeriod === '1D' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; - + // Start from the beginning of the period const startDate = new Date(Date.now() - (dataPoints - 1) * interval); - + + // Single pass over the sorted transactions: advance a cursor as the date + // window moves forward instead of re-filtering the whole array per point + let txIndex = 0; for (let i = 0; i < dataPoints; i++) { const date = new Date(startDate.getTime() + i * interval); - - // Add transactions that occurred before or on this date - const transactionsUpToDate = sortedTransactions.filter(tx => tx.timestamp <= date.getTime()); - - // Calculate balance at this point in time - runningBalance = transactionsUpToDate.reduce((balance, tx) => { - return tx.type === 'received' ? balance + tx.amount : balance - tx.amount; - }, 0); - + + while (txIndex < sortedTransactions.length && sortedTransactions[txIndex].timestamp <= date.getTime()) { + const tx = sortedTransactions[txIndex]; + runningBalance += tx.type === 'received' ? tx.amount : -tx.amount; + txIndex++; + } + const balanceValue = Math.max(0, runningBalance); history.push({ x: i, @@ -123,11 +130,9 @@ function BalanceChartContent({ selectedPeriod }: BalanceChartProps) { date }); } - + return history; - }; - - const balanceHistory = generateBalanceHistory(); + }, [transactions, balance, selectedPeriod]); const createPath = (data: DataPoint[]) => { const maxY = Math.max(...data.map(d => d.y)); @@ -178,11 +183,7 @@ function BalanceChartContent({ selectedPeriod }: BalanceChartProps) { const point = getPointAtX(locationX, balanceHistory); setSelectedPoint(point); setShowTooltip(true); - Animated.timing(tooltipOpacity, { - toValue: 1, - duration: 200, - useNativeDriver: true, - }).start(); + tooltipOpacity.value = withTiming(1, { duration: 200 }); }, onPanResponderMove: (evt) => { const { locationX } = evt.nativeEvent; @@ -190,13 +191,10 @@ function BalanceChartContent({ selectedPeriod }: BalanceChartProps) { setSelectedPoint(point); }, onPanResponderRelease: () => { - Animated.timing(tooltipOpacity, { - toValue: 0, - duration: 200, - useNativeDriver: true, - }).start(() => { - setShowTooltip(false); - setSelectedPoint(null); + tooltipOpacity.value = withTiming(0, { duration: 200 }, (finished) => { + if (finished) { + runOnJS(hideTooltip)(); + } }); }, }); @@ -256,15 +254,31 @@ function BalanceChartContent({ selectedPeriod }: BalanceChartProps) { const chartColor = isDarkMode ? bitcoinGold : bitcoinOrange; const gradientStartColor = chartColor; const gradientEndColor = isDarkMode ? 'rgba(255, 184, 77, 0.1)' : 'rgba(247, 147, 26, 0.1)'; - + // Calculate balance change for display (but don't use it for coloring) const firstBalance = balanceHistory[0]?.y || 0; const lastBalance = balanceHistory[balanceHistory.length - 1]?.y || 0; const balanceChange = lastBalance - firstBalance; - - const { linePath, gradientPath } = createPath(balanceHistory); - const timeLabels = getTimeRangeLabels(balanceHistory); - + + const { linePath, gradientPath } = useMemo(() => createPath(balanceHistory), [balanceHistory]); + // eslint-disable-next-line react-hooks/exhaustive-deps + const timeLabels = useMemo(() => getTimeRangeLabels(balanceHistory), [balanceHistory, selectedPeriod]); + + // Show error state only if balance data is unavailable + if (hasBalanceError) { + return ( + + + + Balance Chart Unavailable + + + Unable to load balance history + + + ); + } + const getSelectedPointPosition = () => { if (!selectedPoint) return { x: 0, y: 0 }; @@ -333,11 +347,11 @@ function BalanceChartContent({ selectedPeriod }: BalanceChartProps) { {/* Tooltip */} {showTooltip && selectedPoint && ( - (null); @@ -77,7 +78,7 @@ function QRScannerContent({ onScan, onClose }: QRScannerProps) { On web, you can manually enter the Bitcoin address below or use your device's camera to scan QR codes. - { // Try to access camera on web @@ -110,13 +111,13 @@ function QRScannerContent({ onScan, onClose }: QRScannerProps) { }} > Try Camera Access - - + Manual Entry - + ); @@ -154,18 +155,18 @@ function QRScannerContent({ onScan, onClose }: QRScannerProps) { Google Play Services is required for QR code scanning but is not available on this device. You can manually enter the Bitcoin address instead. - setManualEntry(true)} > Manual Entry - - + Cancel - + ); @@ -181,18 +182,18 @@ function QRScannerContent({ onScan, onClose }: QRScannerProps) { We need camera permission to scan QR codes containing Bitcoin addresses. - Grant Permission - - + Cancel - + ); @@ -236,12 +237,12 @@ function QRScannerContent({ onScan, onClose }: QRScannerProps) { Manual Entry - setManualEntry(false)} > - + @@ -264,19 +265,19 @@ function QRScannerContent({ onScan, onClose }: QRScannerProps) { underlineColorAndroid="transparent" /> - Use Address - + - setManualEntry(false)} > Back to Scanner - + ); @@ -364,9 +365,9 @@ function QRScannerContent({ onScan, onClose }: QRScannerProps) { }} > - + - + @@ -379,12 +380,12 @@ function QRScannerContent({ onScan, onClose }: QRScannerProps) { - setManualEntry(true)} > Manual Entry - + diff --git a/components/ScreenLoading.tsx b/components/ScreenLoading.tsx new file mode 100644 index 00000000..f02138b8 --- /dev/null +++ b/components/ScreenLoading.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, View, useColorScheme } from 'react-native'; + +/** + * ScreenLoading - Shared fallback for screens that render before the wallet + * context is available. Intentionally context-free: it reads the system color + * scheme directly so it never depends on providers that may not exist yet. + * Colors mirror lightTheme/darkTheme backgrounds in constants/themes.ts. + */ +export function ScreenLoading() { + const isDark = useColorScheme() === 'dark'; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); + +export default ScreenLoading; diff --git a/components/SkeletonLoader.tsx b/components/SkeletonLoader.tsx index 3678eb85..ec70d08e 100644 --- a/components/SkeletonLoader.tsx +++ b/components/SkeletonLoader.tsx @@ -1,5 +1,5 @@ import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import React, { useEffect } from 'react'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -23,8 +23,7 @@ interface SkeletonProps { * Replaces plain "Loading..." text with animated shimmer bars that match the layout. */ function Skeleton({ width, height, borderRadius = 8, style }: SkeletonProps) { - const walletContext = useWallet(); - const theme = walletContext?.theme; + const { theme } = useTheme(); const shimmer = useSharedValue(0); useEffect(() => { @@ -40,7 +39,7 @@ function Skeleton({ width, height, borderRadius = 8, style }: SkeletonProps) { return { opacity }; }); - const baseColor = theme?.isDark ? '#27272A' : '#E5E7EB'; + const baseColor = theme.colors.border; return ( + @@ -99,11 +97,10 @@ export function BalanceSkeleton() { * TransactionSkeleton - Skeleton placeholder for a single transaction item. */ export function TransactionSkeleton() { - const walletContext = useWallet(); - const theme = walletContext?.theme; + const { theme } = useTheme(); return ( - + @@ -139,11 +136,10 @@ export function TransactionListSkeleton({ count = 3 }: { count?: number }) { * ChartSkeleton - Skeleton placeholder for the price chart area. */ export function ChartSkeleton() { - const walletContext = useWallet(); - const theme = walletContext?.theme; + const { theme } = useTheme(); return ( - + ); diff --git a/components/SplashScreen.tsx b/components/SplashScreen.tsx index 72da207f..d6593414 100644 --- a/components/SplashScreen.tsx +++ b/components/SplashScreen.tsx @@ -1,13 +1,19 @@ import { LinearGradient } from 'expo-linear-gradient'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { - Animated, Platform, StatusBar, StyleSheet, Text, View, } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withDelay, + withSpring, + withTiming, +} from 'react-native-reanimated'; import Svg, { Circle, Path } from 'react-native-svg'; import { APP_VERSION } from '@/constants/app-version'; @@ -15,56 +21,40 @@ interface SplashScreenProps { onAnimationComplete?: () => void; } +// Sequence timing: logo entrance (600ms) -> text fade (800ms) -> hold (1500ms) +const LOGO_DURATION_MS = 600; +const TEXT_DURATION_MS = 800; +const HOLD_DURATION_MS = 1500; + export default function SplashScreen({ onAnimationComplete }: SplashScreenProps) { - const logoScale = useRef(new Animated.Value(0)).current; - const logoOpacity = useRef(new Animated.Value(0)).current; - const textOpacity = useRef(new Animated.Value(0)).current; - const magnifyingGlassScale = useRef(new Animated.Value(0)).current; + const logoOpacity = useSharedValue(0); + const textOpacity = useSharedValue(0); + const magnifyingGlassScale = useSharedValue(0); useEffect(() => { - const startAnimation = () => { - // Reset values - logoScale.setValue(0); - logoOpacity.setValue(0); - textOpacity.setValue(0); - magnifyingGlassScale.setValue(0); + // Magnifying glass entrance with bounce, logo fade in parallel + magnifyingGlassScale.value = withSpring(1, { damping: 9, stiffness: 60, mass: 1 }); + logoOpacity.value = withTiming(1, { duration: LOGO_DURATION_MS }); + + // Text fade in after the logo settles + textOpacity.value = withDelay(LOGO_DURATION_MS, withTiming(1, { duration: TEXT_DURATION_MS })); + + // Completion is scheduled on the JS side for deterministic timing + const completeTimeout = setTimeout(() => { + onAnimationComplete?.(); + }, LOGO_DURATION_MS + TEXT_DURATION_MS + HOLD_DURATION_MS); - // Create animation sequence - const animationSequence = Animated.sequence([ - // Magnifying glass entrance with bounce - Animated.parallel([ - Animated.spring(magnifyingGlassScale, { - toValue: 1, - tension: 20, - friction: 7, - useNativeDriver: true, - }), - Animated.timing(logoOpacity, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }), - ]), - // Text fade in - Animated.timing(textOpacity, { - toValue: 1, - duration: 800, - useNativeDriver: true, - }), - // Hold for a moment - Animated.delay(1500), - ]); + return () => clearTimeout(completeTimeout); + }, [onAnimationComplete, logoOpacity, magnifyingGlassScale, textOpacity]); - animationSequence.start(() => { - // Animation complete, trigger callback - if (onAnimationComplete) { - onAnimationComplete(); - } - }); - }; + const logoStyle = useAnimatedStyle(() => ({ + opacity: logoOpacity.value, + transform: [{ scale: magnifyingGlassScale.value }], + })); - startAnimation(); - }, [onAnimationComplete, logoOpacity, logoScale, magnifyingGlassScale, textOpacity]); + const textStyle = useAnimatedStyle(() => ({ + opacity: textOpacity.value, + })); return ( @@ -80,15 +70,7 @@ export default function SplashScreen({ onAnimationComplete }: SplashScreenProps) {/* Main content container */} {/* Magnifying Glass Logo Container */} - + {/* Static Magnifying Glass Logo - Exact replica of the provided image */} {/* Main magnifying glass circle */} @@ -112,17 +94,17 @@ export default function SplashScreen({ onAnimationComplete }: SplashScreenProps) {/* App name */} - + BitSleuth {/* App tagline */} - + secure • private • trusted {/* Version and description */} - + {`v${APP_VERSION}`} Bitcoin Wallet diff --git a/components/ThemedSwitch.tsx b/components/ThemedSwitch.tsx index fb9ea2cc..fab4e8f4 100644 --- a/components/ThemedSwitch.tsx +++ b/components/ThemedSwitch.tsx @@ -34,17 +34,11 @@ interface ThemedSwitchProps { * ThemedSwitch component * * A consistent toggle switch across the app with theme-aware colors: - * - * Light Mode: - * - Track OFF: Light grey (#E5E7EB) - * - Track ON: Orange (theme.colors.primary) - * - Thumb: White (#FFFFFF) - * - * Dark Mode: - * - Track OFF: Darker grey (#374151) - * - Track ON: Bright teal/cyan (theme.colors.primary) - * - Thumb: White (#FFFFFF) - * + * + * - Track OFF: Light grey (#E5E7EB) in light mode, darker grey (#374151) in dark mode + * - Track ON: Bitcoin orange (theme.colors.primary) + * - Thumb: White (#FFFFFF) for maximum contrast + * * Consistent behavior across iOS, Android, and Web platforms. */ export function ThemedSwitch({ @@ -56,7 +50,7 @@ export function ThemedSwitch({ }: ThemedSwitchProps) { // Consistent colors across all toggles const trackColorOff = theme.isDark ? '#374151' : '#E5E7EB'; // Slightly darker grey for dark mode, light grey for light mode - const trackColorOn = theme.colors.primary; // Orange in light mode, cyan in dark mode + const trackColorOn = theme.colors.primary; const thumbColor = '#FFFFFF'; // Always white for maximum contrast if (Platform.OS === 'web') { diff --git a/components/Toast.tsx b/components/Toast.tsx index 8c86b91b..8f9bb9dd 100644 --- a/components/Toast.tsx +++ b/components/Toast.tsx @@ -1,5 +1,5 @@ import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; import { HapticService } from '@/services/haptic-service'; import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; @@ -64,8 +64,7 @@ const ICON_MAP = { }; function ToastItem({ item, onDismiss }: { item: ToastMessage; onDismiss: (id: string) => void }) { - const walletContext = useWallet(); - const theme = walletContext?.theme; + const { theme } = useTheme(); const translateY = useSharedValue(-100); const opacity = useSharedValue(0); @@ -99,17 +98,17 @@ function ToastItem({ item, onDismiss }: { item: ToastMessage; onDismiss: (id: st })); const Icon = ICON_MAP[item.type]; - const iconColor = item.type === 'success' ? (theme?.colors.success || '#10B981') - : item.type === 'error' ? (theme?.colors.error || '#EF4444') - : item.type === 'warning' ? (theme?.colors.warning || '#F59E0B') - : (theme?.colors.primary || '#F7931A'); + const iconColor = item.type === 'success' ? theme.colors.success + : item.type === 'error' ? theme.colors.error + : item.type === 'warning' ? theme.colors.warning + : theme.colors.primary; return ( - + {item.title} - {item.message && ( - + {!!item.message && ( + {item.message} )} diff --git a/components/TransactionItem.tsx b/components/TransactionItem.tsx index 1db414dd..dbb9adbb 100644 --- a/components/TransactionItem.tsx +++ b/components/TransactionItem.tsx @@ -1,10 +1,11 @@ import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { BalanceContext, useWalletActions } from '@/hooks/wallet-contexts'; import { HapticService } from '@/services/haptic-service'; -import { Transaction } from '@/types/wallet'; +import { BitcoinPrice, Theme, Transaction } from '@/types/wallet'; import { router } from 'expo-router'; import { ArrowDownLeft, ArrowUpRight, CheckCircle, Clock, DollarSign, Zap } from 'lucide-react-native'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useContext, useEffect } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Animated, { Easing, @@ -23,20 +24,45 @@ interface TransactionItemProps { index?: number; } -// Wrapper component that checks for context availability +interface TransactionItemContentProps extends TransactionItemProps { + theme: Theme; + bitcoinPrice: BitcoinPrice | null | undefined; + hasPriceError: boolean; + formatCurrency: (amount: number, showSymbol?: boolean) => string; +} + +// Thin wrapper subscribes to only the balance/price and actions slices; the +// memoized content below re-renders only when its own props actually change, +// so list rows stay idle during the 30s wallet data polls. export default function TransactionItem({ transaction, index = 0 }: TransactionItemProps) { - const walletContext = useWallet(); + const balanceData = useContext(BalanceContext); + const { formatCurrency } = useWalletActions(); + const { theme } = useTheme(); - if (!walletContext) { + if (!balanceData) { return null; } - return ; + return ( + + ); } -function TransactionItemContent({ transaction, index = 0 }: TransactionItemProps) { - const { theme, bitcoinPrice, hasPriceError, formatCurrency } = useWallet()!; - +const TransactionItemContent = React.memo(function TransactionItemContent({ + transaction, + index = 0, + theme, + bitcoinPrice, + hasPriceError, + formatCurrency, +}: TransactionItemContentProps) { const isReceived = transaction.type === 'received'; const amountUSD = !hasPriceError && bitcoinPrice?.usd ? transaction.amount * bitcoinPrice.usd : 0; @@ -117,8 +143,9 @@ function TransactionItemContent({ transaction, index = 0 }: TransactionItemProps return theme.colors.warning; }; - // Staggered entrance animation - const enteringAnimation = FadeInDown.delay(index * 60).duration(400).springify().damping(15); + // Staggered entrance animation, capped so long lists don't queue + // multi-second delays as rows mount during scrolling + const enteringAnimation = FadeInDown.delay(Math.min(index, 8) * 60).duration(400).springify().damping(15); return ( @@ -233,7 +260,7 @@ function TransactionItemContent({ transaction, index = 0 }: TransactionItemProps ); -} +}); const styles = StyleSheet.create({ container: { diff --git a/components/TransactionReviewSheet.tsx b/components/TransactionReviewSheet.tsx new file mode 100644 index 00000000..196b6d1b --- /dev/null +++ b/components/TransactionReviewSheet.tsx @@ -0,0 +1,372 @@ +import { AppButton } from '@/components/AppButton'; +import SuccessAnimation from '@/components/SuccessAnimation'; +import { platformStyles } from '@/constants/themes'; +import { useTheme } from '@/hooks/theme-store'; +import { HapticService } from '@/services/haptic-service'; +import * as Clipboard from 'expo-clipboard'; +import { AlertTriangle, Copy, Zap } from 'lucide-react-native'; +import React, { useEffect, useState } from 'react'; +import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; + +export interface TransactionReviewDetails { + /** e.g. "0.00120000 BTC" */ + amountBTC: string; + /** Pre-formatted fiat string (e.g. "$52.13"), or null when price unavailable */ + amountFiat: string | null; + /** Full recipient address */ + recipient: string; + /** e.g. "0.00000320 BTC" or "Calculating..." */ + feeBTC: string; + feeFiat: string | null; + feeRate: number; + timeEstimate: string; + rbfEnabled: boolean; + /** e.g. "0.00120320 BTC" */ + totalBTC: string; +} + +export interface TransactionSuccessDetails { + txid: string; + amountBTC: string; + feeBTC: string; +} + +interface TransactionReviewSheetProps { + visible: boolean; + details: TransactionReviewDetails | null; + /** When set, the sheet shows the broadcast-success state */ + success: TransactionSuccessDetails | null; + /** Disables Confirm and shows its loading state while broadcasting */ + sending: boolean; + onConfirm: () => void; + onCancel: () => void; + onViewTransaction: () => void; + onDone: () => void; +} + +function DetailRow({ label, value, valueColor, mono }: { + label: string; + value: string; + valueColor: string; + mono?: boolean; +}) { + const { theme } = useTheme(); + return ( + + {label} + + {value} + + + ); +} + +/** + * TransactionReviewSheet - Native formSheet replacing the old Alert-based + * send confirmation. Review state summarizes the transaction before the + * irreversible broadcast; success state confirms it with a copyable txid. + * The sheet is purely presentational: authentication and signing stay in + * the send screen's existing handlers. + */ +export function TransactionReviewSheet({ + visible, + details, + success, + sending, + onConfirm, + onCancel, + onViewTransaction, + onDone, +}: TransactionReviewSheetProps) { + const { theme } = useTheme(); + const [copiedTxid, setCopiedTxid] = useState(false); + + useEffect(() => { + if (visible) { + HapticService.medium(); + setCopiedTxid(false); + } + }, [visible]); + + const handleCopyTxid = async () => { + if (!success) return; + try { + await Clipboard.setStringAsync(success.txid); + HapticService.success(); + setCopiedTxid(true); + } catch (error) { + console.warn('Failed to copy txid:', error); + } + }; + + return ( + + + {!success ? ( + + Review Transaction + + Sending real Bitcoin on mainnet + + + + {details?.amountBTC} + {!!details?.amountFiat && ( + + {details.amountFiat} + + )} + + + + To + {details?.recipient} + + + + + + + Replace-By-Fee + + + + {details?.rbfEnabled ? 'Enabled' : 'Disabled'} + + + + + + + + + + + Bitcoin transactions cannot be reversed once broadcast. Double-check the recipient address. + + + + + + + ) : ( + + + + + Transaction Broadcast + + + Sent {success.amountBTC} · Fee {success.feeBTC} + + + + + Transaction ID + + {success.txid} + + + {!!copiedTxid && ( + Copied to clipboard + )} + + + + The transaction will appear in your wallet once it receives confirmations. + This typically takes 10-60 minutes depending on network congestion. + + + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + padding: platformStyles.spacing.xl, + paddingTop: platformStyles.spacing.xxl, + paddingBottom: platformStyles.spacing.huge, + gap: platformStyles.spacing.lg, + }, + title: { + fontSize: 26, + fontWeight: '800', + textAlign: 'center', + letterSpacing: -0.4, + }, + subtitle: { + fontSize: 15, + textAlign: 'center', + marginTop: 4, + }, + amountBlock: { + alignItems: 'center', + marginVertical: platformStyles.spacing.lg, + }, + amount: { + fontSize: 34, + fontWeight: '800', + letterSpacing: -0.5, + }, + amountFiat: { + fontSize: 17, + fontWeight: '600', + marginTop: 4, + }, + card: { + borderRadius: platformStyles.borderRadius.xl, + borderWidth: 1, + padding: platformStyles.spacing.lg, + gap: platformStyles.spacing.sm, + }, + recipient: { + fontSize: 14, + fontFamily: 'monospace', + letterSpacing: 0.4, + lineHeight: 21, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + minHeight: 28, + gap: platformStyles.spacing.md, + }, + rowLabel: { + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.2, + textTransform: 'uppercase', + }, + rowValue: { + fontSize: 15, + fontWeight: '600', + flexShrink: 1, + textAlign: 'right', + }, + mono: { + fontFamily: 'monospace', + }, + rbfBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 8, + }, + rbfText: { + fontSize: 12, + fontWeight: '700', + }, + divider: { + height: StyleSheet.hairlineWidth, + marginVertical: platformStyles.spacing.xs, + }, + warningBox: { + flexDirection: 'row', + alignItems: 'center', + gap: platformStyles.spacing.md, + borderRadius: platformStyles.borderRadius.large, + padding: platformStyles.spacing.lg, + }, + warningText: { + flex: 1, + fontSize: 13, + lineHeight: 19, + }, + confirmButton: { + marginTop: platformStyles.spacing.md, + }, + successHeader: { + alignItems: 'center', + gap: platformStyles.spacing.md, + marginVertical: platformStyles.spacing.xl, + }, + successTitle: { + marginTop: platformStyles.spacing.lg, + }, + txidRow: { + flexDirection: 'row', + alignItems: 'center', + gap: platformStyles.spacing.md, + }, + txid: { + flex: 1, + fontSize: 13, + fontFamily: 'monospace', + letterSpacing: 0.3, + lineHeight: 19, + }, + copiedText: { + fontSize: 12, + fontWeight: '600', + }, + successNote: { + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + paddingHorizontal: platformStyles.spacing.md, + }, +}); + +export default TransactionReviewSheet; diff --git a/components/WalletCard.tsx b/components/WalletCard.tsx index 8e9e054b..3ad8f5d0 100644 --- a/components/WalletCard.tsx +++ b/components/WalletCard.tsx @@ -1,11 +1,13 @@ import { platformStyles } from '@/constants/themes'; +import { PressableOpacity } from '@/components/PressableOpacity'; import { getWalletGradient } from '@/constants/wallet-colors'; -import { useWallet } from '@/hooks/wallet-store'; +import { useTheme } from '@/hooks/theme-store'; +import { WalletsContext, useWalletActions, useWalletBalance, useWalletSettings, useWallets } from '@/hooks/wallet-contexts'; import { HapticService } from '@/services/haptic-service'; import { Wallet, getWalletTypeDisplayName } from '@/types/wallet'; import { LinearGradient } from 'expo-linear-gradient'; import { Edit3, MoreHorizontal, Trash2 } from 'lucide-react-native'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { Alert, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Animated, { useAnimatedStyle, @@ -18,8 +20,8 @@ import Animated, { const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); -// Default gradient colors for fallback -const DEFAULT_GRADIENT = ['#6366F1', '#8B5CF6'] as const; +// Default gradient colors for fallback - Bitcoin orange, matching the brand +const DEFAULT_GRADIENT = ['#FFAB40', '#F7931A'] as const; interface WalletCardProps { wallet?: Wallet; @@ -28,11 +30,12 @@ interface WalletCardProps { onEdit?: (wallet: Wallet) => void; } -// Wrapper component that checks for context availability +// Wrapper component that checks for context availability. Subscribes only to +// the low-churn wallets slice so the gate itself doesn't re-render on polls. export default function WalletCard({ wallet, isActive = false, onPress, onEdit }: WalletCardProps) { - const walletContext = useWallet(); + const walletData = useContext(WalletsContext); - if (!walletContext) { + if (!walletData) { return null; } @@ -40,7 +43,12 @@ export default function WalletCard({ wallet, isActive = false, onPress, onEdit } } function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: WalletCardProps) { - const { currentWallet, balance, balanceUSD, hasBalanceError, hasPriceError, formatCurrency, hideBalance, deleteWallet, theme } = useWallet()!; + // Narrow subscriptions: keeps genuine balance churn, sheds tx/utxo/feedback churn + const { currentWallet } = useWallets(); + const { balance, balanceUSD, hasBalanceError, hasPriceError } = useWalletBalance(); + const { formatCurrency, deleteWallet } = useWalletActions(); + const { hideBalance } = useWalletSettings(); + const { theme } = useTheme(); const [showMenu, setShowMenu] = useState(false); const [menuPosition, setMenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); @@ -211,7 +219,7 @@ function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: Wallet - - + @@ -261,7 +269,7 @@ function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: Wallet animationType="fade" onRequestClose={() => setShowMenu(false)} > - setShowMenu(false)} @@ -272,7 +280,7 @@ function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: Wallet left: menuPosition.x, backgroundColor: theme.colors.surface, }]}> - Edit - + - - - + + - Delete - + Delete + - + ); diff --git a/components/WalletSelector.tsx b/components/WalletSelector.tsx index d02c3a88..f4c0cc5b 100644 --- a/components/WalletSelector.tsx +++ b/components/WalletSelector.tsx @@ -1,15 +1,16 @@ import { platformStyles } from '@/constants/themes'; -import { useWallet } from '@/hooks/wallet-store'; +import { PressableOpacity } from '@/components/PressableOpacity'; +import { useTheme } from '@/hooks/theme-store'; +import { WalletsContext, useWalletActions, useWallets } from '@/hooks/wallet-contexts'; import { Wallet } from '@/types/wallet'; import { Check, ChevronDown } from 'lucide-react-native'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { FlatList, Modal, SafeAreaView, StyleSheet, Text, - TouchableOpacity, View, } from 'react-native'; @@ -18,21 +19,25 @@ interface WalletSelectorProps { onWalletChange?: (wallet: Wallet) => void; } -// Wrapper component that checks for context availability +// Wrapper component that checks for context availability. Subscribes only to +// the low-churn wallets slice so the gate itself doesn't re-render on polls. export default function WalletSelector({ label, onWalletChange }: WalletSelectorProps) { - const walletContext = useWallet(); - + const walletData = useContext(WalletsContext); + // Safety check: if context is not available yet, return null - if (!walletContext) { + if (!walletData) { return null; } - + return ; } -// Main component with all hooks +// Main component with all hooks. Narrow subscriptions: none of these slices +// change on the 30s data polls, so this component stays idle between them. function WalletSelectorContent({ label, onWalletChange }: WalletSelectorProps) { - const { wallets, currentWallet, switchWallet, theme } = useWallet()!; // Non-null assertion is safe here because wrapper checked + const { wallets, currentWallet } = useWallets(); + const { switchWallet } = useWalletActions(); + const { theme } = useTheme(); // Initialize state hooks const [isModalVisible, setIsModalVisible] = useState(false); @@ -51,7 +56,7 @@ function WalletSelectorContent({ label, onWalletChange }: WalletSelectorProps) { const isSelected = item.id === currentWallet?.id; return ( - )} - + ); }; @@ -96,7 +101,7 @@ function WalletSelectorContent({ label, onWalletChange }: WalletSelectorProps) { {label} - - + Select Wallet - setIsModalVisible(false)} > Done - + platformStyles.spacing.md // Consistent border radius helper export const getBorderRadius = (size: 'small' | 'medium' | 'large' | 'xl' = 'medium') => platformStyles.borderRadius[size]; -// Platform-specific haptic feedback -export const triggerHapticFeedback = async (type: 'light' | 'medium' | 'heavy' | 'success' | 'error' = 'light') => { - if (Platform.OS === 'web') return; - - try { - const Haptics = await import('expo-haptics'); - - switch (type) { - case 'light': - await Haptics.selectionAsync(); - break; - case 'medium': - await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - break; - case 'heavy': - await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); - break; - case 'success': - await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - break; - case 'error': - await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - break; - } - } catch (error) { - console.log('Haptics not available:', error); - } -}; - -// New fun design helpers -export const createGradientStyle = (theme: Theme, direction: 'horizontal' | 'vertical' = 'horizontal') => ({ - // This is a placeholder - actual gradients need LinearGradient component - backgroundColor: theme.colors.gradientStart, -}); - -export const createFunCardStyle = (theme: Theme) => ({ - backgroundColor: theme.colors.surface, - borderRadius: platformStyles.borderRadius.xl, - padding: platformStyles.spacing.lg, - borderWidth: 2, - borderColor: theme.colors.accent, - ...platformStyles.cardShadow, -}); - -export const createAccentButtonStyle = (theme: Theme) => ({ - paddingVertical: platformStyles.spacing.md, - paddingHorizontal: platformStyles.spacing.xl, - borderRadius: platformStyles.borderRadius.large, - backgroundColor: theme.colors.accent, - alignItems: 'center' as const, - justifyContent: 'center' as const, - minHeight: 48, - ...platformStyles.buttonShadow, -}); \ No newline at end of file diff --git a/constants/wallet-colors.ts b/constants/wallet-colors.ts index 184a62dd..c2874be7 100644 --- a/constants/wallet-colors.ts +++ b/constants/wallet-colors.ts @@ -7,6 +7,11 @@ export type WalletColorOption = { // Beautiful gradient color palette for wallets export const WALLET_COLOR_PALETTE: WalletColorOption[] = [ + { + id: 'bitcoin', + base: '#F7931A', + gradient: ['#FFAB40', '#F7931A', '#E07B00'], // Bitcoin orange gradient + }, { id: 'cyan', base: '#26F5FE', @@ -35,7 +40,7 @@ export const WALLET_COLOR_PALETTE: WalletColorOption[] = [ { id: 'coral', base: '#FF8A65', - gradient: ['#FFAB91', '#FF8A65', '#FF7043'], // Coral gradient (matches light theme) + gradient: ['#FFAB91', '#FF8A65', '#FF7043'], // Coral gradient }, { id: 'pink', @@ -92,5 +97,5 @@ export function getWalletGradient(baseColor: string): [string, string, string] { // Get just the base colors for simple color pickers export const WALLET_COLORS = WALLET_COLOR_PALETTE.map(c => c.base); -// Default wallet color - now cyan to match new dark theme -export const DEFAULT_WALLET_COLOR = WALLET_COLOR_PALETTE[0].base; // '#26F5FE' \ No newline at end of file +// Default wallet color - Bitcoin orange, matching the app's brand primary +export const DEFAULT_WALLET_COLOR = WALLET_COLOR_PALETTE[0].base; // '#F7931A' \ No newline at end of file diff --git a/docs/REVAMP_NOTES.md b/docs/REVAMP_NOTES.md new file mode 100644 index 00000000..d8512d99 --- /dev/null +++ b/docs/REVAMP_NOTES.md @@ -0,0 +1,143 @@ +# Visual + Performance Revamp Notes + +Reference notes for the audit-driven revamp shipped on branch +`claude/bitsleuth-wallet-audit-2xio0a`. Read this before touching theming, +the wallet data pipeline, or the send flow. + +## Theme architecture + +- `hooks/theme-store.tsx` is the single source of truth for theme. It exposes + `ThemeProvider` and `useTheme()` returning `{ theme, isDark, themeMode, toggleTheme, setThemeMode }`. +- Mode is `'light' | 'dark' | 'system'` (default **system**, resolved via + `useColorScheme()`), persisted under the pre-existing `'theme'` AsyncStorage + key. Legacy `'light'`/`'dark'` values are still honored, so users who chose a + theme before this change keep it. +- `hooks/wallet-store.ts` consumes `useTheme()` internally and **re-exports** + `theme` and `toggleTheme` through `useWallet()` so older call sites keep + working. New code (and anything that only needs theme) should use + `useTheme()` directly — it does not re-render on wallet data polls, while + the wallet store context updates every poll cycle. +- The tab bar (`app/(tabs)/_layout.tsx`) keys `NativeTabs` on `isDark`; it + remounts only when the resolved appearance flips. + +## Color rules + +- No raw hex where a theme token exists (`theme.colors.*`). The legacy + cyan/coral palette (`#26F5FE`, `#FF8A65`, coral gradients, `#0A0A0F`, + `#1F1F33`) was removed everywhere; a grep for those values should stay + clean. +- Exceptions that are intentionally hardcoded (both mirror + `constants/themes.ts` values and must be kept in sync): + - The `ErrorBoundary` in `app/_layout.tsx` (renders outside providers). + - `components/ScreenLoading.tsx` (renders before the wallet context exists). +- Default wallet color is Bitcoin orange (`#F7931A`, palette id `'bitcoin'` + in `constants/wallet-colors.ts`). + +## Store architecture (hooks/wallet-store.tsx + hooks/wallet-contexts.tsx) + +The store body (`useWalletStoreState()` in `wallet-store.tsx`) owns every +query, mutation, and piece of wallet state, and runs exactly once inside +`WalletProvider`. Its memoized slices are published through separate +churn-domain contexts in `hooks/wallet-contexts.tsx`: + +- **Narrow hooks are the only store API**: `useWallets`, `useWalletBalance`, + `useWalletTransactions`, `useWalletAddresses`, `useWalletUtxos`, + `useWalletSettings`, `useWalletActions`, `useCoinControl`, `useFeedback`, + `useWalletMeta`. Subscribe to only what the component renders — a + component using `useWallets` + `useWalletActions` does not re-render on + the 30s data polls. These hooks throw outside `WalletProvider`. The old + combined `useWallet()` hook has been removed; do not reintroduce an + all-slices hook. +- Wrapper "context available?" gates should read `useContext(WalletsContext)` + so the gate itself stays low-churn. +- Do not put whole React Query objects into a slice memo's value or deps + (they change identity every render and would make that context churn + constantly) — expose granular fields instead, as `balanceData` does. + +## Data pipeline (services/wallet-service.ts) + +`getWalletData(xpub)` is called independently by the balance, transactions, +and UTXO queries every 30s poll. The exported function: + +- shares one in-flight promise per xpub (concurrent callers get the same run); +- memoizes **successful** results (and the benign "no transaction history" + result) for 25 seconds — just under the poll interval; +- never memoizes failures, so retries hit the network immediately. + +`clearWalletDataMemo()` drops both maps and is called by `refreshData` +(pull-to-refresh) and `logoutAndEraseWallet`. If you add another "force +fresh data" path, call it there too. + +Per-address wire cost is 1 request (UTXOs) for addresses whose txs are +cached from discovery, and there is **no per-address stats call** — the +wallet balance comes from UTXOs and address activity from the txs +response. The addresses screen (`generateAddressesForView`) keeps its +`chain_stats` calls because those are exact totals. + +**Confirmed history is paginated** (`getAddressTransactionsPaginated`): +page 1 is fetched raw (its own dedupe key; no cached-body merge, so the +continuation cursor is trustworthy), then `/txs/chain/{last_seen_txid}` +pages up to `MAX_TX_CHAIN_PAGES_PER_ADDRESS`. A fresh combined txid-list +cache serves with zero requests; after TTL expiry, pagination early-stops +on the first full page of already-cached txs and reassembles deeper +history from the permanent confirmed-body cache. Mid-pagination failures +return a partial result. The merged wallet list caps at +`WALLET_TRANSACTIONS_DISPLAY_LIMIT` (300). Mempool-only pagination does +not exist in Esplora (>50 unconfirmed still truncates until confirmation). + +Refresh/cold-start behavior: + +- Pull-to-refresh clears only the load-bearing caches for the current + wallet (`clearWalletDataMemo`, `clearCacheForWalletXpub`, + `clearAddressCache`) then invalidates+refetches the queries. Other + wallets' caches and the global tx-body cache stay (confirmed tx bodies + are immutable). +- Cold starts wipe caches **only on app-version change**; otherwise the + per-key TTLs (metadata 2m, txids/stats 5m, utxos 2m) guarantee + freshness within 5 minutes. + +Polling: all four wallet queries use `refetchIntervalInBackground: false` +(foregrounding is covered by `refetchOnWindowFocus: true`); address +discovery polls at 60s, the rest at 30s. + +## Send flow + +- Review + success UI lives in `components/TransactionReviewSheet.tsx` + (native formSheet). It is purely presentational: validation, the biometric + gate (`isEnhancedSecurityRequired` → `authenticateForTransactionEnhanced`), + and `handleSendTransaction` are unchanged in `app/(tabs)/send.tsx`. +- The confirm button is disabled while broadcasting (no double-submit); the + error path still uses `Alert.alert` on top of the sheet. + +## PIN storage + +- `services/secure-pin-service.ts` stores the unlock PIN in Expo SecureStore + under `unlock_pin`. Reads transparently migrate a legacy plaintext PIN from + the AsyncStorage `'pin'` key (write to SecureStore, delete plaintext) — + existing users notice nothing. +- Wallet erase must call `deletePin()` (AsyncStorage.clear() does not touch + SecureStore); `logoutAndEraseWallet` already does. + +## Shared UI primitives & conventions + +- `components/AppButton.tsx` — the button primitive (Pressable + spring + scale + haptics; variants primary/secondary/outline/destructive; loading/ + disabled; icon slot). Use it for real CTAs. +- `components/PressableOpacity.tsx` — drop-in Pressable replacement for + TouchableOpacity (mirrors `activeOpacity`). Use it for rows, icon + buttons, and other tap targets; do not import TouchableOpacity in new + code. The only remaining TouchableOpacity references are the three + `createAnimatedComponent(TouchableOpacity)` bases (settings logout, + WalletCard, TransactionItem), which have custom Reanimated feedback. +- **Animations are Reanimated-only.** No component imports the legacy + react-native `Animated` API; keep it that way. +- `components/ScreenLoading.tsx` — the pre-context loading fallback. +- Picker/edit modals use `presentationStyle="formSheet"` per + `.github/skills/react-native-skills/rules/ui-native-modals.md`. + +## Deferred (known, intentional) + +- `expo-image` is installed but unused; it was kept because it is a native + module already linked in the committed ios/android projects — removing it + requires a prebuild. Either adopt it for QR/logo rendering or remove it in + a native-touching release. diff --git a/hooks/auto-lock-store.ts b/hooks/auto-lock-store.ts index d168ceeb..2fa3da8d 100644 --- a/hooks/auto-lock-store.ts +++ b/hooks/auto-lock-store.ts @@ -1,4 +1,5 @@ import { secureAuthService } from '@/services/secure-auth-service'; +import { getPin as getSecurePin, savePin as saveSecurePin } from '@/services/secure-pin-service'; import createContextHook from '@nkzw/create-context-hook'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -16,11 +17,11 @@ export const [AutoLockProvider, useAutoLock] = createContextHook(() => { const lockTimeoutRef = useRef | null>(null); const appStateRef = useRef('active'); - // Load stored PIN from AsyncStorage + // Load stored PIN from SecureStore (migrates any legacy plaintext PIN) const pinQuery = useQuery({ queryKey: ['pin'], queryFn: async () => { - const pin = await AsyncStorage.getItem('pin'); + const pin = await getSecurePin(); return pin; }, }); @@ -206,7 +207,7 @@ export const [AutoLockProvider, useAutoLock] = createContextHook(() => { const savePin = useCallback(async (pin: string) => { try { - await AsyncStorage.setItem('pin', pin); + await saveSecurePin(pin); setStoredPin(pin); console.log('✅ PIN saved successfully'); } catch (error) { diff --git a/hooks/theme-store.tsx b/hooks/theme-store.tsx new file mode 100644 index 00000000..7ebb5a9b --- /dev/null +++ b/hooks/theme-store.tsx @@ -0,0 +1,78 @@ +import { darkTheme, lightTheme } from '@/constants/themes'; +import { Theme } from '@/types/wallet'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import React, { ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useColorScheme } from 'react-native'; + +export type ThemeMode = 'light' | 'dark' | 'system'; + +const THEME_STORAGE_KEY = 'theme'; + +interface ThemeContextValue { + theme: Theme; + isDark: boolean; + themeMode: ThemeMode; + toggleTheme: () => void; + setThemeMode: (mode: ThemeMode) => void; +} + +const ThemeContext = createContext(null); + +function isThemeMode(value: string | null): value is ThemeMode { + return value === 'light' || value === 'dark' || value === 'system'; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const systemScheme = useColorScheme(); + // Legacy installs persisted 'light'/'dark' under the same key; those values + // stay valid so an explicit user choice survives the upgrade to system mode. + const [themeMode, setThemeModeState] = useState('system'); + + useEffect(() => { + let cancelled = false; + AsyncStorage.getItem(THEME_STORAGE_KEY) + .then(stored => { + if (!cancelled && isThemeMode(stored)) { + setThemeModeState(stored); + } + }) + .catch(err => console.warn('Failed to load theme:', err)); + return () => { + cancelled = true; + }; + }, []); + + const setThemeMode = useCallback((mode: ThemeMode) => { + setThemeModeState(mode); + AsyncStorage.setItem(THEME_STORAGE_KEY, mode).catch(err => + console.warn('Failed to save theme:', err) + ); + }, []); + + const isDark = themeMode === 'system' ? systemScheme === 'dark' : themeMode === 'dark'; + + const toggleTheme = useCallback(() => { + setThemeMode(isDark ? 'light' : 'dark'); + }, [isDark, setThemeMode]); + + const value = useMemo( + () => ({ + theme: isDark ? darkTheme : lightTheme, + isDark, + themeMode, + toggleTheme, + setThemeMode, + }), + [isDark, themeMode, toggleTheme, setThemeMode] + ); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} diff --git a/hooks/use-tab-animation.ts b/hooks/use-tab-animation.ts index 9ce7e047..879e5b65 100644 --- a/hooks/use-tab-animation.ts +++ b/hooks/use-tab-animation.ts @@ -1,47 +1,39 @@ import { useFocusEffect } from 'expo-router'; -import { useRef } from 'react'; -import { Animated } from 'react-native'; +import { useCallback, useRef } from 'react'; +import { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; +/** + * Entrance animation for tab screens: a quick fade + subtle upward slide each + * time the tab regains focus. Returns a Reanimated style — wrap the screen + * content in Reanimated's . + */ export const useTabAnimation = (tabIndex: number) => { - const opacityAnim = useRef(new Animated.Value(1)).current; - const translateY = useRef(new Animated.Value(0)).current; + const opacity = useSharedValue(1); + const translateY = useSharedValue(0); const isInitialMount = useRef(true); useFocusEffect( - () => { + useCallback(() => { // Skip animation on initial mount for instant first load if (isInitialMount.current) { isInitialMount.current = false; - opacityAnim.setValue(1); - translateY.setValue(0); + opacity.value = 1; + translateY.value = 0; return; } // Combined fade + subtle slide animation for premium feel - opacityAnim.setValue(0); - translateY.setValue(8); - - Animated.parallel([ - Animated.timing(opacityAnim, { - toValue: 1, - duration: 220, - useNativeDriver: true, - }), - Animated.spring(translateY, { - toValue: 0, - damping: 20, - stiffness: 300, - mass: 0.8, - useNativeDriver: true, - }), - ]).start(); - } + opacity.value = 0; + translateY.value = 8; + opacity.value = withTiming(1, { duration: 220 }); + translateY.value = withSpring(0, { damping: 20, stiffness: 300, mass: 0.8 }); + }, [opacity, translateY]) ); - const animatedStyle = { - opacity: opacityAnim, - transform: [{ translateY }], - }; + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateY: translateY.value }], + })); return { animatedStyle }; }; diff --git a/hooks/wallet-contexts.tsx b/hooks/wallet-contexts.tsx new file mode 100644 index 00000000..ba938e2e --- /dev/null +++ b/hooks/wallet-contexts.tsx @@ -0,0 +1,116 @@ +/** + * Churn-domain contexts for the wallet store. + * + * WalletProvider (hooks/wallet-store.tsx) runs the store body once and + * publishes each of its memoized slices through one of these contexts. + * Components subscribe via the narrow hooks below so they only re-render + * when the slice they render actually changes — e.g. a component that shows + * only the wallet list stops re-rendering on the 30s balance/transaction + * polls. These hooks are the only store API; there is no combined hook. + * + * The import from wallet-store is type-only, so there is no runtime cycle. + */ + +import { createContext, useContext } from 'react'; +import type { WalletStoreSlices } from '@/hooks/wallet-store'; + +export const WalletsContext = createContext(undefined); +export const BalanceContext = createContext(undefined); +export const TransactionsContext = createContext(undefined); +export const AddressesContext = createContext(undefined); +export const UtxosContext = createContext(undefined); +export const SettingsContext = createContext(undefined); +export const ActionsContext = createContext(undefined); +export const CoinControlContext = createContext(undefined); +export const FeedbackContext = createContext(undefined); +export const WalletMetaContext = createContext(undefined); + +/** Wallet list, current wallet, and wallet CRUD state. Changes on wallet add/remove/switch/edit — not on data polls. */ +export function useWallets() { + const value = useContext(WalletsContext); + if (value === undefined) { + throw new Error('useWallets must be used within WalletProvider'); + } + return value; +} + +/** Balance + BTC price. Changes on the 30s poll when values actually move. */ +export function useWalletBalance() { + const value = useContext(BalanceContext); + if (value === undefined) { + throw new Error('useWalletBalance must be used within WalletProvider'); + } + return value; +} + +/** Transaction history. Changes on the 30s poll when new txs arrive. */ +export function useWalletTransactions() { + const value = useContext(TransactionsContext); + if (value === undefined) { + throw new Error('useWalletTransactions must be used within WalletProvider'); + } + return value; +} + +/** Discovered addresses. Changes on the 60s discovery poll. */ +export function useWalletAddresses() { + const value = useContext(AddressesContext); + if (value === undefined) { + throw new Error('useWalletAddresses must be used within WalletProvider'); + } + return value; +} + +/** UTXO set. Changes on the 30s poll when UTXOs change. */ +export function useWalletUtxos() { + const value = useContext(UtxosContext); + if (value === undefined) { + throw new Error('useWalletUtxos must be used within WalletProvider'); + } + return value; +} + +/** Currency, hide-balance, auto-lock, fee settings. Rare, user-driven churn. */ +export function useWalletSettings() { + const value = useContext(SettingsContext); + if (value === undefined) { + throw new Error('useWalletSettings must be used within WalletProvider'); + } + return value; +} + +/** Stable store actions (createWallet, switchWallet, refreshData, formatCurrency, ...). */ +export function useWalletActions() { + const value = useContext(ActionsContext); + if (value === undefined) { + throw new Error('useWalletActions must be used within WalletProvider'); + } + return value; +} + +/** Coin-control selection/freeze state and helpers. */ +export function useCoinControl() { + const value = useContext(CoinControlContext); + if (value === undefined) { + throw new Error('useCoinControl must be used within WalletProvider'); + } + return value; +} + +/** Feedback-prompt state and usage tracking. */ +export function useFeedback() { + const value = useContext(FeedbackContext); + if (value === undefined) { + throw new Error('useFeedback must be used within WalletProvider'); + } + return value; +} + +/** Loose store fields: isCreatingWallet, address-stats cache helpers, getMnemonic. */ +export function useWalletMeta() { + const value = useContext(WalletMetaContext); + if (value === undefined) { + throw new Error('useWalletMeta must be used within WalletProvider'); + } + return value; +} diff --git a/hooks/wallet-store.ts b/hooks/wallet-store.tsx similarity index 94% rename from hooks/wallet-store.ts rename to hooks/wallet-store.tsx index 801358bb..aee94075 100644 --- a/hooks/wallet-store.ts +++ b/hooks/wallet-store.tsx @@ -1,15 +1,26 @@ -import { darkTheme, lightTheme } from '@/constants/themes'; -import { FRESH_LAUNCH_THRESHOLD_MS, REACT_QUERY_GC_TIME } from '@/constants/cache'; +import { REACT_QUERY_GC_TIME } from '@/constants/cache'; import { clearCacheForWalletXpub, clearEmptyUTXOCaches } from '@/services/address-cache-service'; import { getBTCPrice } from '@/services/esplora-service'; import { clearAllCache } from '@/services/transaction-cache-service'; -import { clearAddressCache, getWalletData } from '@/services/wallet-service'; +import { clearAddressCache, clearWalletDataMemo, getWalletData } from '@/services/wallet-service'; import { getPersistedBalance, getPersistedTransactions, getPersistedUTXOs, persistWalletData, clearPersistedWalletData, clearAllPersistedWalletData } from '@/services/wallet-persistence-service'; -import { FeeSettings, FiatCurrency, Theme, Transaction, UTXO, Wallet } from '@/types/wallet'; -import createContextHook from '@nkzw/create-context-hook'; +import { useTheme } from '@/hooks/theme-store'; +import { + ActionsContext, + AddressesContext, + BalanceContext, + CoinControlContext, + FeedbackContext, + SettingsContext, + TransactionsContext, + UtxosContext, + WalletMetaContext, + WalletsContext, +} from '@/hooks/wallet-contexts'; +import { FeeSettings, FiatCurrency, Transaction, UTXO, Wallet } from '@/types/wallet'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform, AppState, AppStateStatus } from 'react-native'; import Constants from 'expo-constants'; @@ -202,10 +213,17 @@ const feeSettingsMapsEqual = (a: Record, b: Record { +// The store body: every query, mutation, and piece of state the app's wallet +// layer owns. It runs exactly once, inside WalletProvider below; its memoized +// slices are published through separate churn-domain contexts so components +// can subscribe to only the data they render. +function useWalletStoreState() { const [wallets, setWallets] = useState([]); const [currentWalletId, setCurrentWalletId] = useState(null); - const [theme, setTheme] = useState(lightTheme); + // Theme lives in its own low-churn context (hooks/theme-store.tsx) so + // theme-only consumers don't re-render on wallet data polls; it is + // re-exported here to keep the existing useWallet() API intact. + const { theme, toggleTheme, setThemeMode } = useTheme(); const [selectedCurrency, setSelectedCurrency] = useState('USD'); const [hideBalance, setHideBalance] = useState(false); const [autoLockTimeout, setAutoLockTimeout] = useState(15); @@ -346,28 +364,20 @@ export const [WalletProvider, useWallet] = createContextHook(() => { const currentVersion = Constants.expoConfig?.version || '1.0.0'; // fallback to 1.0.0 if unable to read console.log('📱 Current app version:', currentVersion); - // Check stored version and last launch timestamp + // Check stored version const storedVersion = await AsyncStorage.getItem('app_version'); - const lastLaunchTimestamp = await AsyncStorage.getItem('last_launch_timestamp'); console.log('💾 Stored app version:', storedVersion); - console.log('💾 Last launch timestamp:', lastLaunchTimestamp); - + // Detect if app was updated (version changed) const isAppUpdate = storedVersion !== null && storedVersion !== currentVersion; - - // Detect if this is a fresh app launch (check if significant time has passed) - const timeSinceLastLaunch = lastLaunchTimestamp - ? Date.now() - parseInt(lastLaunchTimestamp, 10) - : Infinity; - const isFreshLaunch = timeSinceLastLaunch > FRESH_LAUNCH_THRESHOLD_MS; - - // Clear caches on app update OR fresh launch to ensure data freshness - if (isAppUpdate || isFreshLaunch) { - if (isAppUpdate) { - console.log('🔄 App update detected! Old version:', storedVersion, '→ New version:', currentVersion); - } else { - console.log(`🔄 Fresh app launch detected (time since last launch: ${Math.round(timeSinceLastLaunch / 1000)} seconds)`); - } + + // Wipe caches only on app update (cache formats may have changed). + // Ordinary cold starts no longer wipe: every affected cache already + // self-expires within <=5 minutes (address metadata 2m, txids/stats + // 5m, utxos 2m) and confirmed transaction bodies are immutable, so + // startup is fast and correctness is guaranteed by the TTLs. + if (isAppUpdate) { + console.log('🔄 App update detected! Old version:', storedVersion, '→ New version:', currentVersion); console.log('🧹 Clearing all cached wallet data to ensure fresh sync...'); // Get all wallets to clear their caches @@ -401,9 +411,9 @@ export const [WalletProvider, useWallet] = createContextHook(() => { } else if (storedVersion === null) { console.log('🆕 First launch or version not tracked yet'); } else { - console.log(`✅ Recent launch - using cached data (time since last: ${Math.round(timeSinceLastLaunch / 1000)} seconds)`); + console.log('✅ Same app version - using cached data (per-key TTLs keep it fresh)'); } - + // Update stored version and launch timestamp await AsyncStorage.setItem('app_version', currentVersion); await AsyncStorage.setItem('last_launch_timestamp', Date.now().toString()); @@ -540,22 +550,6 @@ export const [WalletProvider, useWallet] = createContextHook(() => { gcTime: Infinity, }); - // Load theme from storage - const themeQuery = useQuery({ - queryKey: ['theme'], - queryFn: async () => { - try { - const stored = await AsyncStorage.getItem('theme'); - return stored === 'dark' ? darkTheme : lightTheme; - } catch (err) { - console.warn('Failed to load theme:', err); - return lightTheme; - } - }, - staleTime: Infinity, - gcTime: Infinity, - }); - // Load currency from storage const currencyQuery = useQuery({ queryKey: ['currency'], @@ -671,12 +665,6 @@ export const [WalletProvider, useWallet] = createContextHook(() => { } }, [coinControlFrozenQuery.data]); - useEffect(() => { - if (themeQuery.data !== undefined) { - setTheme(themeQuery.data); - } - }, [themeQuery.data]); - useEffect(() => { if (currencyQuery.data !== undefined) { setSelectedCurrency(currencyQuery.data); @@ -907,7 +895,7 @@ export const [WalletProvider, useWallet] = createContextHook(() => { placeholderData: currentWallet?.id ? persistedBalances[currentWallet.id] || 0 : 0, enabled: !!currentWallet && !!currentWallet.xpub && cryptoReady && isWalletDataHydrated, refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds to catch incoming/outgoing transactions - refetchIntervalInBackground: true, // Continue polling even when component is not focused + refetchIntervalInBackground: false, // Pause polling while backgrounded; refetchOnWindowFocus catches up on foreground retry: 1, // Reduce retries to avoid hammering the API on iOS retryDelay: 15000, // Longer delay between retries staleTime: 0, // Always consider data stale to ensure refetchInterval works correctly @@ -1006,7 +994,7 @@ export const [WalletProvider, useWallet] = createContextHook(() => { placeholderData: currentWallet?.id ? persistedTransactions[currentWallet.id] || [] : [], enabled: !!currentWallet && !!currentWallet.xpub && cryptoReady && isWalletDataHydrated, refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds to catch incoming/outgoing transactions - refetchIntervalInBackground: true, // Continue polling even when component is not focused + refetchIntervalInBackground: false, // Pause polling while backgrounded; refetchOnWindowFocus catches up on foreground retry: 1, // Reduced retries to avoid hammering the API on iOS retryDelay: 15000, // Fixed 15 second delay staleTime: 0, // Always consider data stale to ensure refetchInterval works correctly @@ -1056,8 +1044,8 @@ export const [WalletProvider, useWallet] = createContextHook(() => { } }, enabled: !!currentWallet && !!currentWallet.xpub && cryptoReady && isWalletDataHydrated, - refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds to catch new addresses - refetchIntervalInBackground: true, // Continue polling even when component is not focused + refetchInterval: 60 * 1000, // Address discovery is the most expensive call; new addresses only appear after user action or new txs + refetchIntervalInBackground: false, // Pause polling while backgrounded; refetchOnWindowFocus catches up on foreground retry: 1, // Reduced retries to avoid hammering the API retryDelay: 15000, // Fixed 15 second delay staleTime: 0, // Always consider data stale to ensure refetchInterval works correctly @@ -1157,7 +1145,7 @@ export const [WalletProvider, useWallet] = createContextHook(() => { placeholderData: currentWallet?.id ? persistedUtxos[currentWallet.id] || [] : [], enabled: !!currentWallet && !!currentWallet.xpub && cryptoReady && isWalletDataHydrated, refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds to catch UTXO changes - refetchIntervalInBackground: true, // Continue polling even when component is not focused + refetchIntervalInBackground: false, // Pause polling while backgrounded; refetchOnWindowFocus catches up on foreground retry: 1, // Reduced retries to avoid hammering the API retryDelay: 15000, // Fixed 15 second delay staleTime: 0, // Always consider data stale to ensure refetchInterval works correctly @@ -1265,19 +1253,6 @@ export const [WalletProvider, useWallet] = createContextHook(() => { }); const { mutate: saveCurrentWalletId } = saveCurrentWalletIdMutation; - // Save theme mutation - useMutation({ - mutationFn: async (isDark: boolean) => { - const newTheme = isDark ? darkTheme : lightTheme; - await AsyncStorage.setItem('theme', isDark ? 'dark' : 'light'); - return newTheme; - }, - onSuccess: (newTheme) => { - setTheme(newTheme); - queryClient.invalidateQueries({ queryKey: ['theme'] }); - }, - }); - // Save currency mutation const saveCurrencyMutation = useMutation({ mutationFn: async (currency: FiatCurrency) => { @@ -1847,7 +1822,10 @@ export const [WalletProvider, useWallet] = createContextHook(() => { const refreshData = useCallback(async () => { try { console.log('🔄 Refreshing wallet data...'); - + + // Drop the getWalletData memo so the refetch below hits the network + clearWalletDataMemo(); + // Clear mock/test data await AsyncStorage.multiRemove([ 'mock_data', 'test_data', 'sample_data', 'dummy_data', @@ -1855,44 +1833,18 @@ export const [WalletProvider, useWallet] = createContextHook(() => { 'sample_balance', 'sample_transactions', 'test_balance', 'test_transactions' ]); - // Clear ALL wallet-related caches and data to force completely fresh data + // Clear only the load-bearing caches for THIS wallet. These are the + // layers that would otherwise short-circuit the refetch below: + // - clearCacheForWalletXpub: persistent per-address txids/stats/utxos + // (the address→txid list is what gates incoming-tx detection) + // - clearAddressCache: the in-memory discovery metadata (which + // addresses get scanned at all) + // Other wallets' caches and the global tx-body cache stay intact — + // confirmed transaction bodies are immutable and never go stale. if (currentWallet?.xpub) { - console.log('🔄 Clearing ALL wallet data and caches...'); - - // Clear address cache (both persistent and in-memory) await clearCacheForWalletXpub(currentWallet.xpub); clearAddressCache(currentWallet.xpub); - console.log('✅ Address cache cleared'); - - // Clear empty UTXO caches that might be blocking fresh fetches - await clearEmptyUTXOCaches(); - console.log('✅ Empty UTXO caches cleared'); - - // Clear transaction cache to ensure fresh transaction data - await clearAllCache(); - console.log('✅ Transaction cache cleared'); - - // Clear ALL wallet-related AsyncStorage keys to ensure no stale data - const keysToRemove = [ - // Address cache keys (constructed directly from xpub) - `addr_cache_${currentWallet.xpub}`, - `addr_wallet_${currentWallet.xpub}`, - `wallet_addrs_${currentWallet.xpub}`, - `wallet_txids_${currentWallet.xpub}`, - // Transaction cache keys - 'tx_cache_confirmed', - 'tx_cache_unconfirmed', - // General cache keys that might contain wallet data - // (Removed duplicate mock/test data keys already cleared earlier) - ]; - - if (keysToRemove.length > 0) { - console.log(`🗑️ Removing ${keysToRemove.length} additional cache keys...`); - await AsyncStorage.multiRemove(keysToRemove); - console.log('✅ Additional cache keys cleared'); - } - - console.log('✅ Complete wallet data refresh prepared'); + console.log('✅ Wallet caches cleared for refresh'); } // Invalidate and refetch React Query caches @@ -2076,6 +2028,8 @@ export const [WalletProvider, useWallet] = createContextHook(() => { const allKeys = await AsyncStorage.getAllKeys(); console.log('📋 Found AsyncStorage keys:', allKeys); + clearWalletDataMemo(); + // Clear all persisted wallet data before clearing AsyncStorage await clearAllPersistedWalletData(); @@ -2088,12 +2042,20 @@ export const [WalletProvider, useWallet] = createContextHook(() => { } catch (e) { console.warn('Failed to delete mnemonics from secure storage:', e); } - + + // The PIN lives in SecureStore, which AsyncStorage.clear() below doesn't touch + try { + const { deletePin } = await import('@/services/secure-pin-service'); + await deletePin(); + } catch (e) { + console.warn('Failed to delete PIN from secure storage:', e); + } + await AsyncStorage.clear(); setWallets([]); setCurrentWalletId(null); - setTheme(lightTheme); + setThemeMode('system'); setSelectedCurrency('USD'); setHideBalance(false); setAutoLockTimeout(15); @@ -2226,11 +2188,13 @@ export const [WalletProvider, useWallet] = createContextHook(() => { const balanceData = useMemo(() => { const balance = balanceQuery.data ?? lastBalanceRef.current ?? 0; + // Note: only granular priceQuery fields belong here — putting the query + // object itself in the value/deps would give this memo a new identity on + // every store render, making BalanceContext churn for all its readers. return { balance, balanceUSD: balance * (priceQuery.data?.usd || 0), bitcoinPrice: priceQuery.data, - priceQuery, isLoadingBalance: balanceQuery.isLoading && lastBalanceRef.current === null, isRefreshingBalance: balanceQuery.isFetching && lastBalanceRef.current !== null, isLoadingPrice: priceQuery.isLoading, @@ -2239,7 +2203,8 @@ export const [WalletProvider, useWallet] = createContextHook(() => { balanceError: balanceQuery.error, priceError: priceQuery.error, }; - }, [balanceQuery.data, balanceQuery.isLoading, balanceQuery.isFetching, balanceQuery.error, priceQuery.data, priceQuery.isLoading, priceQuery.error, priceQuery]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [balanceQuery.data, balanceQuery.isLoading, balanceQuery.isFetching, balanceQuery.error, priceQuery.data, priceQuery.isLoading, priceQuery.error]); const stableTransactions = transactionsQuery.data ?? lastTransactionsRef.current; useEffect(() => { @@ -2301,13 +2266,6 @@ export const [WalletProvider, useWallet] = createContextHook(() => { setFeeSettings, }), [theme, selectedCurrency, hideBalance, autoLockTimeout, feeSettings, feeSettingsLoading, getCurrentFeeSettings, setFeeSettings]); - // Theme toggle function - const toggleTheme = useCallback(() => { - const newTheme = theme === lightTheme ? darkTheme : lightTheme; - setTheme(newTheme); - AsyncStorage.setItem('theme', theme === lightTheme ? 'dark' : 'light'); - }, [theme]); - // Set currency function const setCurrency = useCallback((currency: FiatCurrency) => { saveCurrency(currency); @@ -2545,22 +2503,15 @@ export const [WalletProvider, useWallet] = createContextHook(() => { } }, []); - // Memoize the final returned object to prevent unnecessary re-renders - const walletStoreData = useMemo(() => ({ - ...walletData, - ...balanceData, - ...transactionData, - ...addressesData, - ...utxosData, - ...settingsData, - ...actionsData, - coinControl: coinControlData, - ...feedbackData, + // Loose fields that don't belong to any data slice + const metaData = useMemo(() => ({ isCreatingWallet: saveWalletsMutation.isPending, setAddressStatsCache, getAddressStatsCacheValue, getMnemonic, // Helper to retrieve mnemonic from secure storage - }), [ + }), [saveWalletsMutation.isPending, setAddressStatsCache, getAddressStatsCacheValue, getMnemonic]); + + return { walletData, balanceData, transactionData, @@ -2570,11 +2521,41 @@ export const [WalletProvider, useWallet] = createContextHook(() => { actionsData, coinControlData, feedbackData, - saveWalletsMutation.isPending, - setAddressStatsCache, - getAddressStatsCacheValue, - getMnemonic, - ]); + metaData, + }; +} + +export type WalletStoreSlices = ReturnType; + +export function WalletProvider({ children }: { children: ReactNode }) { + const s = useWalletStoreState(); + + // Each slice is an already-memoized object from the store body, so a + // context only notifies its readers when that slice actually changed. + // `children` identity is stable (created in app/_layout.tsx), so React + // bails out of re-rendering the subtree except for context readers. + return ( + + + + + + + + + + + {children} + + + + + + + + + + + ); +} - return walletStoreData; -}); diff --git a/package-lock.json b/package-lock.json index 55b7e411..3a8fd6f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,6 @@ "expo-web-browser": "~15.0.10", "inherits": "^2.0.4", "lucide-react-native": "^0.563.0", - "nativewind": "^4.2.1", "process": "^0.11.10", "querystring-es3": "^0.2.1", "react": "19.1.0", @@ -90,8 +89,7 @@ "url": "^0.11.4", "util": "^0.12.5", "web-streams-polyfill": "^4.2.0", - "yaml": "^2.8.3", - "zustand": "^5.0.11" + "yaml": "^2.8.3" }, "devDependencies": { "@expo/ngrok": "^4.1.3", @@ -137,19 +135,6 @@ "typescript": "^5.0.0 || ^6.0.0" } }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -4625,6 +4610,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -4638,6 +4624,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -4647,6 +4634,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -6948,12 +6936,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "license": "MIT" - }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -7474,19 +7456,6 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bip174": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", @@ -7868,16 +7837,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -7914,44 +7873,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -8163,19 +8084,6 @@ "node": ">= 6" } }, - "node_modules/comment-json": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", - "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", - "license": "MIT", - "dependencies": { - "array-timsort": "^1.0.3", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -8430,19 +8338,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "peer": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -8702,26 +8597,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "license": "Apache-2.0", - "peer": true - }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT", - "peer": true - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -9672,19 +9553,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -10952,6 +10820,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -10968,6 +10837,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -11049,6 +10919,7 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -11643,6 +11514,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -12388,19 +12260,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -12522,6 +12381,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12575,6 +12435,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -13086,7 +12947,9 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", + "optional": true, "peer": true, "bin": { "jiti": "bin/jiti.js" @@ -13638,19 +13501,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -13942,6 +13792,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -14577,23 +14428,6 @@ "url": "https://opencollective.com/napi-postinstall" } }, - "node_modules/nativewind": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/nativewind/-/nativewind-4.2.4.tgz", - "integrity": "sha512-PRO7X5a5cnmJD5ryijqeDJhmtabfbbZiPLk3ItTtL7trDzH3uWOv7kPJIqm6L0QFH98m2ynZ55DRPe3AETEOAQ==", - "license": "MIT", - "dependencies": { - "comment-json": "^4.2.5", - "debug": "^4.3.7", - "react-native-css-interop": "0.2.4" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "tailwindcss": ">3.3.0" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -14779,16 +14613,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -15378,16 +15202,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -15476,140 +15290,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "license": "MIT", - "peer": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "peer": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT", - "peer": true - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -16042,6 +15722,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -16268,289 +15949,6 @@ "integrity": "sha512-IZuWjlW7QsdxEGNnvpD6W+7iKCCQhnd5BvuNvMtirU7Nxm8WS2N6LPGMBz1ZYDuusG+GRZkoXXTNCdoAAGpCTg==", "license": "MIT" }, - "node_modules/react-native-css-interop": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.4.tgz", - "integrity": "sha512-ATP3BACxGM4h/l8cisFauGMGxnXpu8Bcp4Bc3O7iNZpq7j0VJjc1RRRBUSBY4C4WuI7VA/xvp3puijVS9d95rg==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/traverse": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.3.7", - "lightningcss": "~1.27.0", - "semver": "^7.6.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": ">=18", - "react-native": "*", - "react-native-reanimated": ">=3.6.2", - "tailwindcss": "~3" - }, - "peerDependenciesMeta": { - "react-native-safe-area-context": { - "optional": true - }, - "react-native-svg": { - "optional": true - } - } - }, - "node_modules/react-native-css-interop/node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz", - "integrity": "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^1.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.27.0", - "lightningcss-darwin-x64": "1.27.0", - "lightningcss-freebsd-x64": "1.27.0", - "lightningcss-linux-arm-gnueabihf": "1.27.0", - "lightningcss-linux-arm64-gnu": "1.27.0", - "lightningcss-linux-arm64-musl": "1.27.0", - "lightningcss-linux-x64-gnu": "1.27.0", - "lightningcss-linux-x64-musl": "1.27.0", - "lightningcss-win32-arm64-msvc": "1.27.0", - "lightningcss-win32-x64-msvc": "1.27.0" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-darwin-arm64": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.27.0.tgz", - "integrity": "sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-darwin-x64": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.27.0.tgz", - "integrity": "sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-freebsd-x64": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.27.0.tgz", - "integrity": "sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.27.0.tgz", - "integrity": "sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.27.0.tgz", - "integrity": "sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.27.0.tgz", - "integrity": "sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.27.0.tgz", - "integrity": "sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-linux-x64-musl": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.27.0.tgz", - "integrity": "sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.27.0.tgz", - "integrity": "sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.27.0.tgz", - "integrity": "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/react-native-css-interop/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/react-native-fetch-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/react-native-fetch-api/-/react-native-fetch-api-3.0.0.tgz", @@ -16941,16 +16339,6 @@ } } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "pify": "^2.3.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -16965,32 +16353,6 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -17257,6 +16619,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -17356,6 +16719,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -18353,44 +17717,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tar": { "version": "7.5.16", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", @@ -19791,35 +19117,6 @@ "zod": "^3.25.28 || ^4" } }, - "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/zx": { "version": "8.8.5", "resolved": "https://registry.npmjs.org/zx/-/zx-8.8.5.tgz", diff --git a/package.json b/package.json index 55cc6885..231b3bf8 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "expo-web-browser": "~15.0.10", "inherits": "^2.0.4", "lucide-react-native": "^0.563.0", - "nativewind": "^4.2.1", "process": "^0.11.10", "querystring-es3": "^0.2.1", "react": "19.1.0", @@ -100,8 +99,7 @@ "url": "^0.11.4", "util": "^0.12.5", "web-streams-polyfill": "^4.2.0", - "yaml": "^2.8.3", - "zustand": "^5.0.11" + "yaml": "^2.8.3" }, "devDependencies": { "@expo/ngrok": "^4.1.3", diff --git a/services/address-cache-service.ts b/services/address-cache-service.ts index 4e7ee3c3..5542d03b 100644 --- a/services/address-cache-service.ts +++ b/services/address-cache-service.ts @@ -80,6 +80,22 @@ export async function getCachedAddressTxIds(address: string): Promise { + try { + const raw = await AsyncStorage.getItem(KEY_TXIDS(address)); + const { data } = hydrateCacheEntry(raw, 0); // ttl 0 → no expiry check + return Array.isArray(data) ? data : null; + } catch (error) { + console.warn('Failed to read cached address txids (any age):', error); + return null; + } +} + export async function setCachedAddressTxIds(address: string, txids: string[], xpubHint?: string): Promise { try { const uniqueTxids = unique(txids); diff --git a/services/esplora-service.ts b/services/esplora-service.ts index eee45583..a1639bd3 100644 --- a/services/esplora-service.ts +++ b/services/esplora-service.ts @@ -3,16 +3,22 @@ * Based on the Esplora API specification for Bitcoin blockchain data */ +import { + ESPLORA_CONFIRMED_PAGE_SIZE, + MAX_TX_CHAIN_PAGES_PER_ADDRESS, + TX_CHAIN_PAGE_TTL_MS, +} from '../constants/cache'; import { getCachedAddressStats, getCachedAddressTransactions, + getCachedAddressTxIdsAnyAge, getCachedAddressUTXOs, setCachedAddressStats, setCachedAddressTxIds, setCachedAddressUTXOs } from './address-cache-service'; import { reliableFetch } from './networking-polyfill'; -import { cacheTransaction, cacheTransactions, getCachedTransactionIds, loadTransactionCache } from './transaction-cache-service'; +import { cacheTransaction, cacheTransactions, getCachedTransaction, getCachedTransactionIds, loadTransactionCache } from './transaction-cache-service'; const BLOCKSTREAM_API_BASE = 'https://blockstream.info/api'; const MEMPOOL_SPACE_API_BASE = 'https://mempool.space/api'; @@ -433,7 +439,21 @@ async function fetchJsonXHR(url: string, options?: RequestInit, timeoutMs: numbe * - Caches confirmed transactions permanently * - Caches unconfirmed transactions for 2 minutes */ -export async function esploraGet(path: string, cacheTtlMs: number = 600000, xpubHint?: string): Promise { +export async function esploraGet( + path: string, + cacheTtlMs: number = 600000, + xpubHint?: string, + opts?: { + /** + * For /address/{addr}/txs requests: skip the cached-list short-circuit + * and return the raw API page WITHOUT merging in cached bodies. Used by + * paginated fetching, which derives its continuation cursor from the + * last confirmed tx of the page — a merged response could put an + * arbitrarily old cached tx there and silently skip history. + */ + rawAddressTxs?: boolean; + } +): Promise { // Load transaction cache if not already loaded await loadTransactionCache(); @@ -449,6 +469,7 @@ export async function esploraGet(path: string, cacheTtlMs: number = 600000, xpub // - Bech32 (bc1q...): starts with bc1q, 42-62 chars, bech32 // - Bech32m (bc1p...): starts with bc1p, 62 chars, bech32m const addressTxMatch = path.match(/^\/address\/((?:[13]|bc1)[a-zA-HJ-NP-Z0-9]{25,62})\/txs$/); + const addressTxChainMatch = path.match(/^\/address\/((?:[13]|bc1)[a-zA-HJ-NP-Z0-9]{25,62})\/txs\/chain\/([a-f0-9]{64})$/); const addressStatsMatch = path.match(/^\/address\/((?:[13]|bc1)[a-zA-HJ-NP-Z0-9]{25,62})$/); const addressUtxoMatch = path.match(/^\/address\/((?:[13]|bc1)[a-zA-HJ-NP-Z0-9]{25,62})\/utxo$/); @@ -469,7 +490,8 @@ export async function esploraGet(path: string, cacheTtlMs: number = 600000, xpub } // For bulk address requests, check cache and filter out what we already have - if (addressTxMatch) { + // (raw mode skips this — its caller checked the cache before deciding to paginate) + if (addressTxMatch && !opts?.rawAddressTxs) { // If we already have cached transactions for this address, return them immediately const address = addressTxMatch[1]; const cachedAddressTxs = await getCachedAddressTransactions(address); @@ -580,7 +602,9 @@ export async function esploraGet(path: string, cacheTtlMs: number = 600000, xpub // Create deduplication key from path to avoid duplicate concurrent requests // Timeout: 30s for slow networks/large responses (balance of reliability vs UX) // Note: UI should show loading states for operations >5s - const dedupeKey = `esplora:${path}`; + // Raw address-tx requests get their own dedupe key so they never + // receive a merged response from a concurrent plain /txs request + const dedupeKey = opts?.rawAddressTxs ? `esplora:${path}:raw` : `esplora:${path}`; const data = await requestQueue.enqueue(() => fetchJson(url, {}, 30000), dedupeKey); console.log(`✅ Success from ${base} for ${path}`); @@ -635,32 +659,45 @@ export async function esploraGet(path: string, cacheTtlMs: number = 600000, xpub await setCachedAddressTxIds(currentAddress, txidsFromApi, xpubHint); } - const allCachedTxs: any[] = []; - if (currentAddress) { - for (const txid of cachedTxIds) { - const cached = getCachedTransaction(txid); - // Only include cached transactions that: - // 1. Exist in cache - // 2. Are NOT in the fresh API response (to avoid duplicates) - // 3. Belong to the current address (check inputs and outputs) - if (cached && !data.find(tx => tx.txid === txid)) { - // Check if this transaction belongs to the current address - const belongsToAddress = - cached.vin?.some((input: any) => input.prevout?.scriptpubkey_address === currentAddress) || - cached.vout?.some((output: any) => output.scriptpubkey_address === currentAddress); - - if (belongsToAddress) { - allCachedTxs.push(cached); + // Raw mode returns the unmerged API page (the caller derives its + // pagination cursor from it); merged mode augments with cached txs + if (!opts?.rawAddressTxs) { + const allCachedTxs: any[] = []; + if (currentAddress) { + for (const txid of cachedTxIds) { + const cached = getCachedTransaction(txid); + // Only include cached transactions that: + // 1. Exist in cache + // 2. Are NOT in the fresh API response (to avoid duplicates) + // 3. Belong to the current address (check inputs and outputs) + if (cached && !data.find(tx => tx.txid === txid)) { + // Check if this transaction belongs to the current address + const belongsToAddress = + cached.vin?.some((input: any) => input.prevout?.scriptpubkey_address === currentAddress) || + cached.vout?.some((output: any) => output.scriptpubkey_address === currentAddress); + + if (belongsToAddress) { + allCachedTxs.push(cached); + } } } } + + if (allCachedTxs.length > 0) { + console.log(`📦 Merged ${allCachedTxs.length} additional cached transactions for address`); + // Return merged data: fresh API data + cached transactions not in API response + return [...data, ...allCachedTxs]; + } } - - if (allCachedTxs.length > 0) { - console.log(`📦 Merged ${allCachedTxs.length} additional cached transactions for address`); - // Return merged data: fresh API data + cached transactions not in API response - return [...data, ...allCachedTxs]; + } else if (addressTxChainMatch && Array.isArray(data)) { + // Confirmed-history continuation page: cache the tx bodies + // (permanent once confirmed) and the immutable page itself. + // The paginated caller owns the combined txid list — never + // write setCachedAddressTxIds here. + if (data.length > 0) { + await cacheTransactions(data); } + setCachedData(cacheKey, data, cacheTtlMs); } else if (addressStatsMatch) { const address = addressStatsMatch[1]; await setCachedAddressStats(address, data, xpubHint); @@ -836,6 +873,115 @@ export async function getAddressTransactions(address: string, xpubHint?: string) } } +/** + * Get an address's transactions with confirmed-history pagination. + * + * /address/{addr}/txs returns at most ~50 mempool + 25 confirmed txs; this + * walks /address/{addr}/txs/chain/{last_seen_txid} continuation pages (25 + * confirmed each, newest-first) up to MAX_TX_CHAIN_PAGES_PER_ADDRESS. + * + * Efficiency contract: + * - Fresh combined txid-list cache (< TXIDS_TTL_MS): zero network requests. + * - Expired list: page 1 is refetched; pagination early-stops as soon as a + * full page consists entirely of already-cached txs (confirmed history + * only prepends, so everything deeper was covered by a previous scan), + * and the deeper history is reassembled from the permanent body cache. + * - A mid-pagination failure returns the partial result (strictly better + * than the pre-pagination behavior of always truncating at page 1). + */ +export async function getAddressTransactionsPaginated(address: string, xpubHint?: string): Promise<{ data: any[] | null; error: string | null }> { + try { + // 1. Fresh combined-list cache hit → zero network requests + const cached = await getCachedAddressTransactions(address); + if (cached !== null) { + console.log(`📦 Address txs cache hit for ${address.substring(0, 10)}...: ${cached.length} txs (paginated)`); + return { data: cached, error: null }; + } + + // 2. Txids known from any previous scan (TTL ignored) — used for + // early-stop and for reassembling deep history without refetching + const staleTxids = (await getCachedAddressTxIdsAnyAge(address)) ?? []; + + // 3. Page 1, raw (no cached-body merge — the cursor must come from the + // actual API page). Bodies + page-1 txid list are cached internally. + const first = await esploraGet(`/address/${address}/txs`, 300000, xpubHint, { rawAddressTxs: true }); + const page1: any[] = Array.isArray(first) ? first : []; + const all: any[] = [...page1]; + const seen = new Set(page1.map((t: any) => t.txid)); + + const isConfirmed = (t: any) => + t.status?.confirmed === true || (t.status?.block_height !== undefined && t.status?.block_height !== null); + const confirmedPage1 = page1.filter(isConfirmed); + let cursor: string | null = confirmedPage1.length > 0 ? confirmedPage1[confirmedPage1.length - 1].txid : null; + let lastPageConfirmedCount = confirmedPage1.length; + + // 4. Sequential continuation pages (each esploraGet flows through the + // rate-limited RequestQueue) + for (let page = 0; page < MAX_TX_CHAIN_PAGES_PER_ADDRESS; page++) { + if (!cursor || lastPageConfirmedCount < ESPLORA_CONFIRMED_PAGE_SIZE) { + break; // confirmed history exhausted + } + + let chainPage: any[]; + try { + const res = await esploraGet(`/address/${address}/txs/chain/${cursor}`, TX_CHAIN_PAGE_TTL_MS, xpubHint); + chainPage = Array.isArray(res) ? res : []; + } catch (e: any) { + if (e?.message?.includes('Not found')) { + chainPage = []; // provider treats end-of-history as 404 + } else { + console.warn(`⚠️ Tx pagination stopped early for ${address.substring(0, 10)}... at chain page ${page + 1}:`, e?.message); + break; // partial result: pages 1..N-1 plus cache reassembly below + } + } + if (chainPage.length === 0) break; + + // Early-stop check BEFORE consuming the page: a full page of + // already-cached txs means every deeper tx was covered by a + // previous scan (confirmed history only prepends) + const allKnown = chainPage.length >= ESPLORA_CONFIRMED_PAGE_SIZE && + chainPage.every((t: any) => getCachedTransaction(t.txid) !== null); + + for (const tx of chainPage) { + if (!seen.has(tx.txid)) { + seen.add(tx.txid); + all.push(tx); + } + } + lastPageConfirmedCount = chainPage.length; // chain pages are all confirmed + cursor = chainPage[chainPage.length - 1]?.txid ?? null; + + if (allKnown) { + console.log(`📦 Early stop: full page of cached txs for ${address.substring(0, 10)}...`); + break; + } + } + + // 5. Reassemble deeper history fetched by a previous scan: confirmed + // bodies are cached permanently, expired unconfirmed correctly drop out + for (const txid of staleTxids) { + if (seen.has(txid)) continue; + const body = getCachedTransaction(txid); + if (body) { + seen.add(txid); + all.push(body); + } + } + + // 6. Persist the combined txid list with a fresh timestamp (overwrites + // the page-1-only list esploraGet wrote in step 3), making step 1 a + // pure cache hit for the next TXIDS_TTL_MS + await setCachedAddressTxIds(address, all.map((t: any) => t.txid), xpubHint); + + console.log(`📜 Paginated txs for ${address.substring(0, 10)}...: ${all.length} total`); + return { data: all, error: null }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + console.error(`❌ Failed to get paginated address transactions:`, message); + return { data: null, error: message }; + } +} + /** * Get detailed information about a transaction */ diff --git a/services/secure-pin-service.ts b/services/secure-pin-service.ts new file mode 100644 index 00000000..84e069e0 --- /dev/null +++ b/services/secure-pin-service.ts @@ -0,0 +1,63 @@ +/** + * Secure PIN Storage Service + * + * Stores the app unlock PIN in Expo SecureStore (hardware-backed keystore / + * keychain) instead of plaintext AsyncStorage. Reads transparently migrate + * any legacy plaintext PIN from AsyncStorage into SecureStore and delete the + * plaintext copy, so existing users keep their PIN without noticing. + * + * Follows the same key conventions as services/secure-mnemonic-service.ts. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; + +const PIN_KEY = 'unlock_pin'; +const LEGACY_ASYNC_STORAGE_PIN_KEY = 'pin'; + +/** + * Retrieve the stored PIN, migrating a legacy plaintext PIN if present. + * Returns null when no PIN is set or secure storage is unavailable. + */ +export async function getPin(): Promise { + try { + const securePin = await SecureStore.getItemAsync(PIN_KEY); + if (securePin) { + return securePin; + } + + // One-time migration: versions <= 1.2.2 kept the PIN in AsyncStorage + const legacyPin = await AsyncStorage.getItem(LEGACY_ASYNC_STORAGE_PIN_KEY); + if (legacyPin) { + await SecureStore.setItemAsync(PIN_KEY, legacyPin); + await AsyncStorage.removeItem(LEGACY_ASYNC_STORAGE_PIN_KEY); + console.log('🔐 Migrated unlock PIN from AsyncStorage to SecureStore'); + return legacyPin; + } + + return null; + } catch (error) { + console.error('❌ Failed to read PIN from secure storage:', error); + return null; + } +} + +/** Persist the PIN in SecureStore and remove any lingering plaintext copy. */ +export async function savePin(pin: string): Promise { + await SecureStore.setItemAsync(PIN_KEY, pin); + try { + await AsyncStorage.removeItem(LEGACY_ASYNC_STORAGE_PIN_KEY); + } catch { + // Non-fatal: the secure copy is already written + } +} + +/** Remove the PIN from both stores (used by wallet erase/logout). */ +export async function deletePin(): Promise { + await SecureStore.deleteItemAsync(PIN_KEY); + try { + await AsyncStorage.removeItem(LEGACY_ASYNC_STORAGE_PIN_KEY); + } catch { + // Non-fatal + } +} diff --git a/services/security-test-service.ts b/services/security-test-service.ts index 0fe597c2..41743e55 100644 --- a/services/security-test-service.ts +++ b/services/security-test-service.ts @@ -1,6 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { Alert } from 'react-native'; import { secureAuthService } from './secure-auth-service'; +import { getPin as getSecurePin } from './secure-pin-service'; export interface SecurityTestResult { testName: string; @@ -102,7 +103,7 @@ export class SecurityTestService { */ private async testPINSecurity(): Promise { try { - const pin = await AsyncStorage.getItem('pin'); + const pin = await getSecurePin(); if (!pin) { return { diff --git a/services/wallet-service.ts b/services/wallet-service.ts index c15a03c0..52c8833f 100644 --- a/services/wallet-service.ts +++ b/services/wallet-service.ts @@ -3,12 +3,12 @@ * Uses BIP32 derivation and gap limit for proper address discovery */ -import { ADDRESS_METADATA_CACHE_TTL_MS, ADDRESS_VERIFICATION_TIMEOUT_MS, ENABLE_ADDRESS_VERIFICATION_SAFEGUARD } from '../constants/cache'; +import { ADDRESS_METADATA_CACHE_TTL_MS, ADDRESS_VERIFICATION_TIMEOUT_MS, ENABLE_ADDRESS_VERIFICATION_SAFEGUARD, WALLET_TRANSACTIONS_DISPLAY_LIMIT } from '../constants/cache'; import type { Transaction, Wallet } from '../types/wallet'; import { recordWalletAssociationsXpub } from './address-cache-service'; import { loadBip32Module } from './bip32-loader'; import { ensureECC } from './bitcoin-service'; -import { esploraGet, getAddressStats, getAddressTransactions, getAddressUTXOs, getBTCPrice, getCurrentBlockHeight } from './esplora-service'; +import { esploraGet, getAddressStats, getAddressTransactionsPaginated, getAddressUTXOs, getBTCPrice, getCurrentBlockHeight } from './esplora-service'; import { getCacheStats, loadTransactionCache } from './transaction-cache-service'; // Import bip39 with better error handling @@ -267,10 +267,59 @@ export async function discoverUsedAddresses(xpub: string, returnMetadata: boolea } } +type WalletDataResult = { data: any | null; error: string | null }; + +// Memoization for getWalletData: the balance, transactions, and UTXO queries in +// wallet-store all call it independently on the same 30s poll cycle. Sharing +// in-flight promises plus a TTL just under the poll interval collapses those +// 3 pipeline runs into 1 without changing any query key or invalidation path. +const WALLET_DATA_MEMO_TTL_MS = 25 * 1000; +const walletDataInFlight = new Map>(); +const walletDataMemo = new Map(); + +// The "no transaction history" result comes from a completed, successful scan +// of an empty wallet — memoizing it avoids rescanning 3x per poll. Real +// failures (network, rate limit) are never memoized so retries stay immediate. +function isMemoizableResult(result: WalletDataResult): boolean { + return !result.error || result.error.includes('no transaction history'); +} + +/** Drops memoized wallet data so the next getWalletData call hits the network (used by pull-to-refresh). */ +export function clearWalletDataMemo(): void { + walletDataInFlight.clear(); + walletDataMemo.clear(); +} + +export async function getWalletData(xpub: string): Promise { + const cached = walletDataMemo.get(xpub); + if (cached && Date.now() - cached.timestamp < WALLET_DATA_MEMO_TTL_MS) { + return cached.result; + } + + const inFlight = walletDataInFlight.get(xpub); + if (inFlight) { + return inFlight; + } + + const promise = fetchWalletDataUncached(xpub) + .then(result => { + if (isMemoizableResult(result)) { + walletDataMemo.set(xpub, { result, timestamp: Date.now() }); + } + return result; + }) + .finally(() => { + walletDataInFlight.delete(xpub); + }); + + walletDataInFlight.set(xpub, promise); + return promise; +} + /** * Get comprehensive wallet data using address discovery */ -export async function getWalletData(xpub: string): Promise<{ data: any | null; error: string | null }> { +async function fetchWalletDataUncached(xpub: string): Promise { try { console.log(`🔄 Getting wallet data for xpub: ${xpub.substring(0, 20)}...`); @@ -311,22 +360,21 @@ export async function getWalletData(xpub: string): Promise<{ data: any | null; e // Sequential processing avoids race conditions and 429 errors const allTxs = new Map(); const utxos: any[] = []; - const addressInfos: any[] = []; + let activeAddressCount = 0; - // Process addresses with parallelized API calls per address - // Rate limiting is handled at the request queue level in esplora-service + // Process addresses with parallelized API calls per address. + // Rate limiting is handled at the request queue level in esplora-service. + // Note: no per-address /address/{addr} stats call — the wallet balance is + // computed from UTXOs below, and address activity is derivable from the + // txs response (which discovery just fetched, so this read is a cache hit). for (let idx = 0; idx < usedAddresses.length; idx++) { const address = usedAddresses[idx]; try { console.log(`📊 Processing address ${idx + 1}/${usedAddresses.length}: ${address.substring(0, 10)}...`); - // OPTIMIZATION: Fetch UTXOs, transactions, and stats in parallel - // These are independent requests for the same address - // Rate limiting is enforced by the request queue, not here - const [utxosResult, txsResult, statsResult] = await Promise.all([ + const [utxosResult, txsResult] = await Promise.all([ getAddressUTXOs(address, xpub), - getAddressTransactions(address, xpub), - getAddressStats(address, xpub) + getAddressTransactionsPaginated(address, xpub) ]); if (txsResult.data && Array.isArray(txsResult.data)) { @@ -335,6 +383,9 @@ export async function getWalletData(xpub: string): Promise<{ data: any | null; e for (const tx of txsResult.data) { allTxs.set(tx.txid, tx); } + if (txsResult.data.length > 0) { + activeAddressCount++; + } } // Record associations for this wallet for later deletion and reuse @@ -348,10 +399,10 @@ export async function getWalletData(xpub: string): Promise<{ data: any | null; e if (utxosResult.data && Array.isArray(utxosResult.data)) { utxosResult.data.forEach((utxo: any) => { // Include all UTXO fields needed for transactions - utxos.push({ - txid: utxo.txid, - vout: utxo.vout, - address, + utxos.push({ + txid: utxo.txid, + vout: utxo.vout, + address, value: utxo.value, status: utxo.status || { confirmed: false }, scriptPubKey: utxo.scriptpubkey @@ -359,16 +410,8 @@ export async function getWalletData(xpub: string): Promise<{ data: any | null; e }); } - if (statsResult.data && statsResult.data.chain_stats?.tx_count > 0) { - addressInfos.push({ - address, - n_tx: statsResult.data.chain_stats.tx_count, - balance: statsResult.data.chain_stats.funded_txo_sum - statsResult.data.chain_stats.spent_txo_sum, - }); - } + // No additional delay needed - rate limiting handled by request queue - // No additional delay needed - rate limiting handled by request queue (1000-1200ms per request) - } catch (error) { console.warn(`⚠️ Failed to process address ${address}:`, error); } @@ -531,9 +574,9 @@ export async function getWalletData(xpub: string): Promise<{ data: any | null; e const walletData = { balanceBTC, balanceUSD: balanceBTC * btcPrice, - transactions: transactions.slice(0, 50), // Limit to 50 most recent + transactions: transactions.slice(0, WALLET_TRANSACTIONS_DISPLAY_LIMIT), // Most recent first; history list is virtualized usedAddresses, - addressCount: addressInfos.length, + addressCount: activeAddressCount, utxoCount: utxos.length, utxos, // Include full UTXO array for automatic polling updates };