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 */}
{/* 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
};