From 6cbb4a7222261dc9691245c6fc1c666c9493f4a6 Mon Sep 17 00:00:00 2001 From: Jopsan-gm Date: Thu, 26 Mar 2026 00:06:27 -0600 Subject: [PATCH] feat(mobile): implement dark mode and dynamic theming (#149) - Add ThemeContext with light/dark/system preference support persisted in AsyncStorage (no restart required) - Extend Colors token palette in constants/theme.ts with full light and dark token sets (text, surface, border, button, input, status, skeleton, pill) - Add Theme selector in Settings screen (Light / Dark / System) with radio buttons and accessibility roles - Audit and replace hardcoded colors in all mobile screens: index, security, wallet-connect, payment-confirmation, transactions, quick-receive, transaction-item component - QR code in quick-receive keeps white background in dark mode to ensure readability (per issue requirement) - Dynamic StatusBar style based on active color scheme - Wire AppThemeProvider in root _layout.tsx Closes #149 --- app/mobile/app/_layout.tsx | 16 +- app/mobile/app/index.tsx | 214 +++++++------- app/mobile/app/payment-confirmation.tsx | 238 ++++++++-------- app/mobile/app/quick-receive.tsx | 188 ++++++------ app/mobile/app/security.tsx | 209 +++++++------- app/mobile/app/settings.tsx | 135 +++++++-- app/mobile/app/transactions.tsx | 225 ++++++--------- app/mobile/app/wallet-connect.tsx | 315 +++++++++++---------- app/mobile/components/transaction-item.tsx | 155 +++++----- app/mobile/constants/theme.ts | 83 +++++- app/mobile/context/ThemeContext.tsx | 95 +++++++ 11 files changed, 1069 insertions(+), 804 deletions(-) create mode 100644 app/mobile/context/ThemeContext.tsx diff --git a/app/mobile/app/_layout.tsx b/app/mobile/app/_layout.tsx index 3748c20..5b374bd 100644 --- a/app/mobile/app/_layout.tsx +++ b/app/mobile/app/_layout.tsx @@ -7,7 +7,7 @@ import * as Linking from "expo-linking"; import { Stack, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useEffect } from "react"; -import { useColorScheme } from "react-native"; +import { ThemeProvider as AppThemeProvider, useAppTheme } from "@/context/ThemeContext"; // Ensure web build or Expo web uses the local backend during development if (typeof document !== "undefined" && !(global as any).API_BASE_URL) { // Expo web typically runs on localhost; ensure the app hits the backend on port 4000 @@ -64,7 +64,15 @@ function DevPoller() { } export default function RootLayout() { - const colorScheme = useColorScheme(); + return ( + + + + ); +} + +function ThemedNavigationProvider() { + const { colorScheme } = useAppTheme(); return ( @@ -81,7 +89,7 @@ export default function RootLayout() { - + ); } @@ -100,6 +108,8 @@ function AppShell() { + + {isReady && settings.biometricLockEnabled ? ( diff --git a/app/mobile/app/index.tsx b/app/mobile/app/index.tsx index 58e8b9b..b28850b 100644 --- a/app/mobile/app/index.tsx +++ b/app/mobile/app/index.tsx @@ -3,58 +3,62 @@ import React from "react"; import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import NotificationCenter from "../components/notifications/NotificationCenter"; +import { useAppTheme } from "@/context/ThemeContext"; export default function HomeScreen() { + const { colors } = useAppTheme(); + const s = makeStyles(colors); + return ( - + {/* Bell */} - - QuickEx + + QuickEx - + Fast, privacy-focused payment link platform built on Stellar. - - Instant Payments - + + Instant Payments + Receive USDC, XLM, or any Stellar asset directly to your self-custody wallet. - - Scan to Pay + + Scan to Pay - - Connect Wallet + + Connect Wallet - - Security Settings + + Security Settings {/* Quick Receive */} - - Quick Receive + + Quick Receive {/* Transaction History */} - - Transaction History + + Transaction History @@ -62,92 +66,94 @@ export default function HomeScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#ffffff", - }, - content: { - flex: 1, - padding: 24, - justifyContent: "center", - alignItems: "center", - }, - title: { - fontSize: 42, - fontWeight: "bold", - color: "#000", - marginBottom: 8, - }, - subtitle: { - fontSize: 18, - color: "#666", - textAlign: "center", - marginBottom: 40, - }, - card: { - width: "100%", - padding: 20, - borderRadius: 12, - backgroundColor: "#f5f5f5", - marginBottom: 30, - }, - cardTitle: { - fontSize: 20, - fontWeight: "600", - color: "#333", - marginBottom: 8, - }, - cardText: { - fontSize: 16, - color: "#555", - lineHeight: 22, - }, +function makeStyles(colors: ReturnType["colors"]) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + flex: 1, + padding: 24, + justifyContent: "center", + alignItems: "center", + }, + title: { + fontSize: 42, + fontWeight: "bold", + color: colors.text, + marginBottom: 8, + }, + subtitle: { + fontSize: 18, + color: colors.textSecondary, + textAlign: "center", + marginBottom: 40, + }, + card: { + width: "100%", + padding: 20, + borderRadius: 12, + backgroundColor: colors.card, + marginBottom: 30, + }, + cardTitle: { + fontSize: 20, + fontWeight: "600", + color: colors.text, + marginBottom: 8, + }, + cardText: { + fontSize: 16, + color: colors.textSecondary, + lineHeight: 22, + }, - /* Primary Button */ - primaryButton: { - backgroundColor: "#000", - paddingVertical: 16, - paddingHorizontal: 32, - borderRadius: 8, - width: "100%", - alignItems: "center", - marginBottom: 12, - }, - primaryButtonText: { - color: "#fff", - fontSize: 18, - fontWeight: "bold", - }, - /* Quick Receive Button */ - quickReceiveButton: { - backgroundColor: "#10B981", - paddingVertical: 16, - paddingHorizontal: 32, - borderRadius: 8, - width: "100%", - alignItems: "center", - marginBottom: 12, - }, - quickReceiveButtonText: { - color: "#fff", - fontSize: 18, - fontWeight: "600", - }, + /* Primary Button */ + primaryButton: { + backgroundColor: colors.primaryBtn, + paddingVertical: 16, + paddingHorizontal: 32, + borderRadius: 8, + width: "100%", + alignItems: "center", + marginBottom: 12, + }, + primaryButtonText: { + color: colors.primaryBtnText, + fontSize: 18, + fontWeight: "bold", + }, + /* Quick Receive Button */ + quickReceiveButton: { + backgroundColor: colors.success, + paddingVertical: 16, + paddingHorizontal: 32, + borderRadius: 8, + width: "100%", + alignItems: "center", + marginBottom: 12, + }, + quickReceiveButtonText: { + color: "#fff", + fontSize: 18, + fontWeight: "600", + }, - /* Secondary Button */ - secondaryButton: { - paddingVertical: 16, - paddingHorizontal: 32, - borderRadius: 8, - width: "100%", - alignItems: "center", - borderWidth: 1, - borderColor: "#000", - }, - secondaryButtonText: { - color: "#000", - fontSize: 18, - fontWeight: "600", - }, -}); + /* Secondary Button */ + secondaryButton: { + paddingVertical: 16, + paddingHorizontal: 32, + borderRadius: 8, + width: "100%", + alignItems: "center", + borderWidth: 1, + borderColor: colors.secondaryBtnBorder, + }, + secondaryButtonText: { + color: colors.secondaryBtnText, + fontSize: 18, + fontWeight: "600", + }, + }); +} diff --git a/app/mobile/app/payment-confirmation.tsx b/app/mobile/app/payment-confirmation.tsx index 98071a0..760b8db 100644 --- a/app/mobile/app/payment-confirmation.tsx +++ b/app/mobile/app/payment-confirmation.tsx @@ -5,10 +5,14 @@ import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useSecurity } from "@/hooks/use-security"; +import { useAppTheme } from "@/context/ThemeContext"; export default function PaymentConfirmationScreen() { const router = useRouter(); const { authenticateForSensitiveAction } = useSecurity(); + const { colors } = useAppTheme(); + const s = makeStyles(colors); + const params = useLocalSearchParams<{ username: string; amount: string; @@ -48,21 +52,21 @@ export default function PaymentConfirmationScreen() { if (!isValid) { return ( - - - - ! - Invalid Payment Link - + + + + ! + Invalid Payment Link + This payment link is missing required information. Please try scanning again or check the link. router.replace("/")} > - Go Back + Go Back @@ -70,40 +74,40 @@ export default function PaymentConfirmationScreen() { } return ( - - - Confirm Payment - + + + Confirm Payment + Review the details below before paying - - - - + + + + {memo ? ( <> - - + + ) : null} {isPrivate ? ( <> - - + + ) : null} - - - Pay with Wallet + + + Pay with Wallet router.replace("/")} > - Cancel + Cancel @@ -111,108 +115,114 @@ export default function PaymentConfirmationScreen() { ); } +type Styles = ReturnType; + function Row({ label, value, highlight, + s, }: { label: string; value: string; highlight?: boolean; + s: Styles; }) { return ( - - {label} - + + {label} + {value} ); } -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#fff" }, - content: { - flex: 1, - padding: 24, - justifyContent: "center", - }, - heading: { - fontSize: 32, - fontWeight: "bold", - color: "#000", - marginBottom: 4, - }, - subheading: { - fontSize: 16, - color: "#888", - marginBottom: 32, - }, - card: { - backgroundColor: "#F5F5F5", - borderRadius: 16, - padding: 20, - marginBottom: 40, - }, - row: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 14, - }, - rowLabel: { fontSize: 15, color: "#888" }, - rowValue: { - fontSize: 16, - fontWeight: "500", - color: "#222", - flexShrink: 1, - textAlign: "right", - }, - rowValueHighlight: { fontSize: 20, fontWeight: "700", color: "#000" }, - divider: { height: 1, backgroundColor: "#E5E5E5" }, - actions: { gap: 12 }, - primaryBtn: { - backgroundColor: "#000", - paddingVertical: 16, - borderRadius: 12, - alignItems: "center", - }, - primaryBtnText: { color: "#fff", fontSize: 18, fontWeight: "700" }, - secondaryBtn: { - paddingVertical: 14, - alignItems: "center", - }, - secondaryBtnText: { color: "#888", fontSize: 16, fontWeight: "500" }, - errorCard: { - backgroundColor: "#FFF3F3", - borderRadius: 16, - padding: 32, - alignItems: "center", - marginBottom: 24, - }, - errorIcon: { - fontSize: 36, - fontWeight: "bold", - color: "#FF3B30", - backgroundColor: "#FFE5E5", - width: 56, - height: 56, - lineHeight: 56, - borderRadius: 28, - textAlign: "center", - marginBottom: 16, - overflow: "hidden", - }, - errorTitle: { - fontSize: 22, - fontWeight: "700", - color: "#222", - marginBottom: 8, - }, - errorBody: { - fontSize: 15, - color: "#888", - textAlign: "center", - lineHeight: 22, - }, -}); +function makeStyles(colors: ReturnType["colors"]) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + content: { + flex: 1, + padding: 24, + justifyContent: "center", + }, + heading: { + fontSize: 32, + fontWeight: "bold", + color: colors.text, + marginBottom: 4, + }, + subheading: { + fontSize: 16, + color: colors.textSecondary, + marginBottom: 32, + }, + card: { + backgroundColor: colors.card, + borderRadius: 16, + padding: 20, + marginBottom: 40, + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 14, + }, + rowLabel: { fontSize: 15, color: colors.textSecondary }, + rowValue: { + fontSize: 16, + fontWeight: "500", + color: colors.text, + flexShrink: 1, + textAlign: "right", + }, + rowValueHighlight: { fontSize: 20, fontWeight: "700", color: colors.text }, + divider: { height: 1, backgroundColor: colors.border }, + actions: { gap: 12 }, + primaryBtn: { + backgroundColor: colors.primaryBtn, + paddingVertical: 16, + borderRadius: 12, + alignItems: "center", + }, + primaryBtnText: { color: colors.primaryBtnText, fontSize: 18, fontWeight: "700" }, + secondaryBtn: { + paddingVertical: 14, + alignItems: "center", + }, + secondaryBtnText: { color: colors.textSecondary, fontSize: 16, fontWeight: "500" }, + errorCard: { + backgroundColor: colors.errorSurface, + borderRadius: 16, + padding: 32, + alignItems: "center", + marginBottom: 24, + }, + errorIcon: { + fontSize: 36, + fontWeight: "bold", + color: colors.error, + backgroundColor: colors.errorSurface, + width: 56, + height: 56, + lineHeight: 56, + borderRadius: 28, + textAlign: "center", + marginBottom: 16, + overflow: "hidden", + }, + errorTitle: { + fontSize: 22, + fontWeight: "700", + color: colors.text, + marginBottom: 8, + }, + errorBody: { + fontSize: 15, + color: colors.textSecondary, + textAlign: "center", + lineHeight: 22, + }, + }); +} diff --git a/app/mobile/app/quick-receive.tsx b/app/mobile/app/quick-receive.tsx index a1cba0d..b0ae41d 100644 --- a/app/mobile/app/quick-receive.tsx +++ b/app/mobile/app/quick-receive.tsx @@ -5,11 +5,11 @@ import { StyleSheet, TouchableOpacity, Share, - useColorScheme, Alert, } from "react-native"; import QRCode from "react-native-qrcode-svg"; import * as Clipboard from "expo-clipboard"; +import { useAppTheme } from "@/context/ThemeContext"; // TODO: Replace this with real auth hook const useUser = () => { @@ -20,8 +20,8 @@ const useUser = () => { export default function QuickReceiveScreen() { const { username } = useUser(); - const colorScheme = useColorScheme(); - const isDark = colorScheme === "dark"; + const { colors, colorScheme } = useAppTheme(); + const s = makeStyles(colors); const receiveLink = useMemo(() => { if (!username) return null; @@ -43,27 +43,24 @@ export default function QuickReceiveScreen() { }; return ( - - - Quick Receive - + + Quick Receive {!username ? ( - - - No username found. - - - Claim one to start receiving payments. - + + No username found. + Claim one to start receiving payments. ) : ( <> - - @{username} - + @{username} - + {/* + * QR code always uses black-on-white for maximum readability. + * The white container ensures contrast in dark mode. + * This is the correct approach per issue #149. + */} + - - Copy Link + + {colorScheme === "dark" + ? "QR code shown in high-contrast mode for readability" + : "Scan this QR code to send a payment"} + + + + Copy Link - - Share + + Share )} @@ -91,65 +88,76 @@ export default function QuickReceiveScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 24, - alignItems: "center", - justifyContent: "center", - backgroundColor: "#ffffff", - }, - darkContainer: { - backgroundColor: "#121212", - }, - title: { - fontSize: 22, - fontWeight: "600", - marginBottom: 24, - }, - username: { - fontSize: 20, - fontWeight: "bold", - marginBottom: 20, - }, - darkText: { - color: "#ffffff", - }, - qrWrapper: { - padding: 16, - backgroundColor: "#ffffff", - borderRadius: 16, - marginBottom: 30, - }, - primaryButton: { - width: "100%", - backgroundColor: "#2563EB", - padding: 14, - borderRadius: 12, - alignItems: "center", - marginBottom: 12, - }, - secondaryButton: { - width: "100%", - backgroundColor: "#10B981", - padding: 14, - borderRadius: 12, - alignItems: "center", - }, - buttonText: { - color: "#ffffff", - fontWeight: "600", - }, - emptyContainer: { - alignItems: "center", - }, - warning: { - fontSize: 16, - fontWeight: "600", - marginBottom: 6, - }, - subText: { - fontSize: 14, - opacity: 0.7, - }, -}); \ No newline at end of file +function makeStyles(colors: ReturnType["colors"]) { + return StyleSheet.create({ + container: { + flex: 1, + padding: 24, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.background, + }, + title: { + fontSize: 22, + fontWeight: "600", + marginBottom: 24, + color: colors.text, + }, + username: { + fontSize: 20, + fontWeight: "bold", + marginBottom: 20, + color: colors.text, + }, + /* + * The QR wrapper uses a fixed white background so the QR code is always + * readable regardless of the app theme. This is intentional per the issue: + * "Ensure all charts and QR codes remain readable in dark mode." + */ + qrWrapper: { + padding: 16, + backgroundColor: "#ffffff", + borderRadius: 16, + marginBottom: 12, + }, + qrHint: { + fontSize: 12, + color: colors.textMuted, + marginBottom: 24, + textAlign: "center", + maxWidth: 260, + }, + primaryButton: { + width: "100%", + backgroundColor: colors.tint, + padding: 14, + borderRadius: 12, + alignItems: "center", + marginBottom: 12, + }, + secondaryButton: { + width: "100%", + backgroundColor: colors.success, + padding: 14, + borderRadius: 12, + alignItems: "center", + }, + buttonText: { + color: "#ffffff", + fontWeight: "600", + }, + emptyContainer: { + alignItems: "center", + }, + warning: { + fontSize: 16, + fontWeight: "600", + marginBottom: 6, + color: colors.text, + }, + subText: { + fontSize: 14, + color: colors.textSecondary, + }, + }); +} \ No newline at end of file diff --git a/app/mobile/app/security.tsx b/app/mobile/app/security.tsx index cd4415d..20d5181 100644 --- a/app/mobile/app/security.tsx +++ b/app/mobile/app/security.tsx @@ -11,6 +11,7 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import { useSecurity } from "@/hooks/use-security"; +import { useAppTheme } from "@/context/ThemeContext"; export default function SecurityScreen() { const { @@ -21,6 +22,9 @@ export default function SecurityScreen() { savePin, } = useSecurity(); + const { colors } = useAppTheme(); + const s = makeStyles(colors); + const [pin, setPin] = useState(""); const [confirmPin, setConfirmPin] = useState(""); const [savingPin, setSavingPin] = useState(false); @@ -67,18 +71,18 @@ export default function SecurityScreen() { }; return ( - - - Security - + + + Security + Protect sensitive flows with biometrics and a fallback PIN. - - - - Enable Biometric Lock - + + + + Enable Biometric Lock + Prompt on app open and before critical transactions. @@ -89,28 +93,28 @@ export default function SecurityScreen() { /> - + - + {isBiometricAvailable ? "Biometric hardware is available on this device." : "Biometrics unavailable. You can still set fallback PIN now and enable biometrics when available."} - - + + {hasPinConfigured ? "Change Fallback PIN" : "Set Fallback PIN"} - + PIN is stored as a hash in secure storage and used when biometrics fail or are unavailable. setPin(value.replace(/[^0-9]/g, ""))} secureTextEntry @@ -118,9 +122,9 @@ export default function SecurityScreen() { maxLength={6} /> setConfirmPin(value.replace(/[^0-9]/g, "")) @@ -131,11 +135,11 @@ export default function SecurityScreen() { /> - + {savingPin ? "Saving..." : "Save PIN"} @@ -145,84 +149,87 @@ export default function SecurityScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#FFFFFF", - }, - content: { - flex: 1, - padding: 24, - }, - title: { - fontSize: 34, - fontWeight: "800", - color: "#111827", - }, - subtitle: { - marginTop: 8, - fontSize: 16, - color: "#6B7280", - marginBottom: 26, - lineHeight: 22, - }, - card: { - backgroundColor: "#F9FAFB", - borderRadius: 16, - padding: 16, - marginBottom: 16, - borderWidth: 1, - borderColor: "#E5E7EB", - }, - row: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - gap: 16, - }, - rowTextWrap: { - flex: 1, - }, - rowTitle: { - fontSize: 17, - fontWeight: "700", - color: "#111827", - marginBottom: 4, - }, - rowBody: { - color: "#6B7280", - fontSize: 14, - lineHeight: 20, - }, - supportText: { - color: "#4B5563", - fontSize: 13, - }, - divider: { - height: 1, - backgroundColor: "#E5E7EB", - marginVertical: 14, - }, - input: { - backgroundColor: "#fff", - borderRadius: 10, - borderWidth: 1, - borderColor: "#D1D5DB", - paddingHorizontal: 14, - paddingVertical: 12, - marginTop: 12, - fontSize: 15, - }, - saveBtn: { - marginTop: 14, - backgroundColor: "#111827", - borderRadius: 12, - alignItems: "center", - paddingVertical: 14, - }, - saveBtnText: { - color: "#fff", - fontWeight: "700", - fontSize: 16, - }, -}); +function makeStyles(colors: ReturnType["colors"]) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + flex: 1, + padding: 24, + }, + title: { + fontSize: 34, + fontWeight: "800", + color: colors.text, + }, + subtitle: { + marginTop: 8, + fontSize: 16, + color: colors.textSecondary, + marginBottom: 26, + lineHeight: 22, + }, + card: { + backgroundColor: colors.surface, + borderRadius: 16, + padding: 16, + marginBottom: 16, + borderWidth: 1, + borderColor: colors.border, + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: 16, + }, + rowTextWrap: { + flex: 1, + }, + rowTitle: { + fontSize: 17, + fontWeight: "700", + color: colors.text, + marginBottom: 4, + }, + rowBody: { + color: colors.textSecondary, + fontSize: 14, + lineHeight: 20, + }, + supportText: { + color: colors.textMuted, + fontSize: 13, + }, + divider: { + height: 1, + backgroundColor: colors.border, + marginVertical: 14, + }, + input: { + backgroundColor: colors.input, + borderRadius: 10, + borderWidth: 1, + borderColor: colors.inputBorder, + paddingHorizontal: 14, + paddingVertical: 12, + marginTop: 12, + fontSize: 15, + color: colors.text, + }, + saveBtn: { + marginTop: 14, + backgroundColor: colors.primaryBtn, + borderRadius: 12, + alignItems: "center", + paddingVertical: 14, + }, + saveBtnText: { + color: colors.primaryBtnText, + fontWeight: "700", + fontSize: 16, + }, + }); +} diff --git a/app/mobile/app/settings.tsx b/app/mobile/app/settings.tsx index c19e596..7440315 100644 --- a/app/mobile/app/settings.tsx +++ b/app/mobile/app/settings.tsx @@ -1,36 +1,129 @@ import React from "react"; import { SafeAreaView } from "react-native-safe-area-context"; -import { View, Text, StyleSheet, Switch } from "react-native"; +import { View, Text, StyleSheet, Switch, TouchableOpacity } from "react-native"; import { useNotifications } from "../components/notifications/NotificationContext"; +import { useAppTheme, ThemePreference } from "@/context/ThemeContext"; + +type ThemeOption = { label: string; value: ThemePreference; emoji: string }; + +const THEME_OPTIONS: ThemeOption[] = [ + { label: "Light", value: "light", emoji: "☀️" }, + { label: "Dark", value: "dark", emoji: "🌙" }, + { label: "System", value: "system", emoji: "📱" }, +]; export default function SettingsScreen() { const { soundEnabled, setSoundEnabled } = useNotifications(); + const { themePreference, setThemePreference, colors } = useAppTheme(); + + const s = makeStyles(colors); return ( - - - Settings + + + Settings - - 🔔 Sound Effects - + {/* ── Theme selector ── */} + 🎨 App Theme + + {THEME_OPTIONS.map((opt, i) => { + const isSelected = themePreference === opt.value; + return ( + + {i > 0 && } + setThemePreference(opt.value)} + activeOpacity={0.7} + accessibilityRole="radio" + accessibilityState={{ checked: isSelected }} + > + + {opt.emoji}{" "}{opt.label} + + + {isSelected && } + + + + ); + })} + + + {/* ── Notifications ── */} + 🔔 Notifications + + + Sound Effects + + ); } -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#fff" }, - content: { padding: 24 }, - title: { fontSize: 28, fontWeight: "700", marginBottom: 16 }, - row: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 12, - borderBottomWidth: 1, - borderColor: "#f3f4f6", - }, - label: { fontSize: 16 }, -}); +function makeStyles(colors: ReturnType["colors"]) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + content: { padding: 24 }, + title: { + fontSize: 28, + fontWeight: "700", + color: colors.text, + marginBottom: 24, + }, + sectionHeader: { + fontSize: 13, + fontWeight: "600", + color: colors.textSecondary, + textTransform: "uppercase", + letterSpacing: 0.8, + marginBottom: 8, + marginTop: 8, + }, + card: { + backgroundColor: colors.surface, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border, + marginBottom: 20, + overflow: "hidden", + }, + themeRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 14, + }, + themeLabel: { fontSize: 16, color: colors.text }, + radioOuter: { + width: 22, + height: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: colors.border, + alignItems: "center", + justifyContent: "center", + }, + radioOuterSelected: { borderColor: colors.tint }, + radioInner: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: colors.tint, + }, + separator: { height: 1, backgroundColor: colors.border, marginHorizontal: 16 }, + row: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 14, + }, + label: { fontSize: 16, color: colors.text }, + }); +} diff --git a/app/mobile/app/transactions.tsx b/app/mobile/app/transactions.tsx index ccceacc..3e777cb 100644 --- a/app/mobile/app/transactions.tsx +++ b/app/mobile/app/transactions.tsx @@ -17,6 +17,7 @@ import { useTransactions } from '../hooks/use-transactions'; import type { TransactionItem as TransactionItemType } from '../types/transaction'; import { ErrorState } from '../components/resilience/error-state'; import { EmptyState } from '../components/resilience/empty-state'; +import { useAppTheme } from '@/context/ThemeContext'; /** * Placeholder account used when no accountId is passed via route params. @@ -26,15 +27,15 @@ const DEMO_ACCOUNT_ID = // ─── Loading Skeleton ──────────────────────────────────────────────────────── -function SkeletonRow() { +function SkeletonRow({ skeletonColor }: { skeletonColor: string }) { return ( - - + + - - + + - + ); } @@ -46,20 +47,18 @@ const skeleton = StyleSheet.create({ paddingHorizontal: 20, paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#E5E7EB', + borderBottomColor: 'transparent', }, circle: { width: 44, height: 44, borderRadius: 22, - backgroundColor: '#E5E7EB', marginRight: 14, }, lines: { flex: 1 }, line: { height: 12, borderRadius: 6, - backgroundColor: '#E5E7EB', }, }); @@ -67,6 +66,9 @@ const skeleton = StyleSheet.create({ export default function TransactionsScreen() { const router = useRouter(); + const { colors } = useAppTheme(); + const s = makeStyles(colors); + const params = useLocalSearchParams<{ accountId?: string }>(); const accountId = (params.accountId ?? DEMO_ACCOUNT_ID).trim(); @@ -80,15 +82,15 @@ export default function TransactionsScreen() { ); const ListHeader = ( - - {shortAccount} + + {shortAccount} ); const ListEmpty = loading ? ( {[...Array(6)].map((_, i) => ( - + ))} ) : error ? ( @@ -105,24 +107,24 @@ export default function TransactionsScreen() { ); const ListFooter = hasMore ? ( - - + + ) : null; return ( - + {/* ── Header ── */} - + router.back()} - style={styles.backBtn} + style={s.backBtn} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} > - + - Transaction History - + Transaction History + {/* ── Transaction List ── */} @@ -137,14 +139,14 @@ export default function TransactionsScreen() { } onEndReached={loadMore} onEndReachedThreshold={0.8} contentContainerStyle={ (transactions.length === 0 || error) && !loading - ? styles.emptyFill + ? s.emptyFill : undefined } showsVerticalScrollIndicator={false} @@ -155,118 +157,67 @@ export default function TransactionsScreen() { // ─── Styles ────────────────────────────────────────────────────────────────── -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F9FAFB', - }, - - // Header - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 12, - backgroundColor: '#fff', - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#E5E7EB', - }, - headerTitle: { - fontSize: 17, - fontWeight: '600', - color: '#111827', - }, - backBtn: { - width: 36, - alignItems: 'center', - }, - backChevron: { - fontSize: 28, - color: '#111827', - lineHeight: 32, - }, - - // Error banner - errorBanner: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#FEF2F2', - borderBottomWidth: 1, - borderBottomColor: '#FECACA', - paddingHorizontal: 16, - paddingVertical: 10, - gap: 12, - }, - errorText: { - flex: 1, - fontSize: 13, - color: '#991B1B', - lineHeight: 18, - }, - retryBtn: { - backgroundColor: '#DC2626', - borderRadius: 6, - paddingHorizontal: 14, - paddingVertical: 6, - }, - retryText: { - color: '#fff', - fontSize: 13, - fontWeight: '600', - }, - - // List header - listHeader: { - paddingHorizontal: 20, - paddingTop: 16, - paddingBottom: 8, - }, - accountPill: { - alignSelf: 'flex-start', - backgroundColor: '#E5E7EB', - color: '#374151', - fontSize: 12, - fontWeight: '600', - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 99, - overflow: 'hidden', - fontFamily: 'monospace', - }, - - // Empty state - emptyFill: { - flexGrow: 1, - }, - emptyContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 40, - paddingTop: 80, - }, - emptyIcon: { - fontSize: 48, - marginBottom: 16, - }, - emptyTitle: { - fontSize: 18, - fontWeight: '700', - color: '#111827', - marginBottom: 8, - textAlign: 'center', - }, - emptySubtitle: { - fontSize: 14, - color: '#6B7280', - textAlign: 'center', - lineHeight: 20, - }, - - // Footer (load-more indicator) - footer: { - paddingVertical: 20, - alignItems: 'center', - }, -}); +function makeStyles(colors: ReturnType["colors"]) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + + // Header + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: colors.surface, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + headerTitle: { + fontSize: 17, + fontWeight: '600', + color: colors.text, + }, + backBtn: { + width: 36, + alignItems: 'center', + }, + backChevron: { + fontSize: 28, + color: colors.text, + lineHeight: 32, + }, + + // List header + listHeader: { + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 8, + }, + accountPill: { + alignSelf: 'flex-start', + backgroundColor: colors.pillBg, + color: colors.pillText, + fontSize: 12, + fontWeight: '600', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 99, + overflow: 'hidden', + fontFamily: 'monospace', + }, + + // Empty state + emptyFill: { + flexGrow: 1, + }, + + // Footer (load-more indicator) + footer: { + paddingVertical: 20, + alignItems: 'center', + }, + }); +} diff --git a/app/mobile/app/wallet-connect.tsx b/app/mobile/app/wallet-connect.tsx index 67f5264..986eb8a 100644 --- a/app/mobile/app/wallet-connect.tsx +++ b/app/mobile/app/wallet-connect.tsx @@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { useNetworkStatus } from "../hooks/use-network-status"; import { useSecurity } from "../hooks/use-security"; import { usePaymentListener } from "../hooks/usePaymentListener"; +import { useAppTheme } from "@/context/ThemeContext"; type Network = "testnet" | "mainnet"; @@ -24,6 +25,8 @@ export default function WalletConnectScreen() { getSensitiveSessionToken, saveSensitiveSessionToken, } = useSecurity(); + const { colors } = useAppTheme(); + const s = makeStyles(colors); const [connected, setConnected] = useState(false); const [network, setNetwork] = useState("testnet"); @@ -81,216 +84,218 @@ export default function WalletConnectScreen() { }; return ( - - - Wallet Connection - + + + Wallet Connection + Connect your Stellar wallet and protect sensitive wallet data with biometric security. - - - Network + + + Network - {network.toUpperCase()} + {network.toUpperCase()} - - Status - + + Status + {connected ? "Connected" : "Not Connected"} {!isConnected ? ( - + - + Internet connection is required to link a new wallet. ) : null} {connected && publicKey ? ( - {publicKey} + {publicKey} ) : null} {!connected ? ( - - Connect Wallet + + Connect Wallet ) : ( <> - + Reveal Secure Session Token {sessionTokenPreview ? ( - + Token: {sessionTokenPreview} ) : null} - Disconnect + Disconnect )} - router.back()}> - Go Back + router.back()}> + Go Back ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#fff", - }, - content: { - flex: 1, - padding: 24, - }, - title: { - fontSize: 32, - fontWeight: "bold", - marginTop: 40, - marginBottom: 12, - color: "#111827", - }, - subtitle: { - fontSize: 16, - color: "#6B7280", - marginBottom: 28, - lineHeight: 22, - }, - card: { - backgroundColor: "#F9FAFB", - borderRadius: 16, - borderWidth: 1, - borderColor: "#E5E7EB", - padding: 16, - }, - row: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 14, - }, - label: { - fontSize: 16, - fontWeight: "700", - color: "#111827", - }, - networkBadge: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 8, - }, - mainnet: { - backgroundColor: "#10B981", - }, - testnet: { - backgroundColor: "#F59E0B", - }, - networkText: { - color: "#fff", - fontWeight: "700", - }, - connected: { - color: "#10B981", - fontWeight: "700", - }, - disconnected: { - color: "#EF4444", - fontWeight: "700", - }, - offlineAdvice: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "#FEF2F2", - paddingVertical: 10, - paddingHorizontal: 12, - borderRadius: 8, - marginBottom: 16, - gap: 8, - borderWidth: 1, - borderColor: "#FECACA", - }, - offlineAdviceText: { - color: "#991B1B", - fontSize: 13, - fontWeight: "500", - flex: 1, - }, - address: { - fontSize: 12, - color: "#374151", - marginBottom: 16, - }, - connectButton: { - backgroundColor: "#111827", - padding: 16, - borderRadius: 10, - alignItems: "center", - }, - disconnectButton: { - backgroundColor: "#EF4444", - padding: 16, - borderRadius: 10, - alignItems: "center", - marginTop: 12, - }, - secondaryButton: { - borderColor: "#111827", - borderWidth: 1, - padding: 14, - borderRadius: 10, - alignItems: "center", - }, - secondaryButtonText: { - color: "#111827", - fontWeight: "700", - }, - tokenPreview: { - marginTop: 10, - fontSize: 13, - color: "#4B5563", - }, - buttonText: { - color: "#fff", - fontWeight: "700", - fontSize: 16, - }, - backButton: { - marginTop: 22, - alignItems: "center", - }, - backButtonText: { - color: "#6B7280", - fontSize: 16, - }, -}); +function makeStyles(colors: ReturnType["colors"]) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + flex: 1, + padding: 24, + }, + title: { + fontSize: 32, + fontWeight: "bold", + marginTop: 40, + marginBottom: 12, + color: colors.text, + }, + subtitle: { + fontSize: 16, + color: colors.textSecondary, + marginBottom: 28, + lineHeight: 22, + }, + card: { + backgroundColor: colors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: colors.border, + padding: 16, + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 14, + }, + label: { + fontSize: 16, + fontWeight: "700", + color: colors.text, + }, + networkBadge: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + }, + mainnet: { + backgroundColor: colors.success, + }, + testnet: { + backgroundColor: colors.warning, + }, + networkText: { + color: "#fff", + fontWeight: "700", + }, + connected: { + color: colors.success, + fontWeight: "700", + }, + disconnected: { + color: colors.error, + fontWeight: "700", + }, + offlineAdvice: { + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.errorSurface, + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 8, + marginBottom: 16, + gap: 8, + borderWidth: 1, + borderColor: colors.errorBorder, + }, + offlineAdviceText: { + color: colors.errorText, + fontSize: 13, + fontWeight: "500", + flex: 1, + }, + address: { + fontSize: 12, + color: colors.textSecondary, + marginBottom: 16, + }, + connectButton: { + backgroundColor: colors.primaryBtn, + padding: 16, + borderRadius: 10, + alignItems: "center", + }, + disconnectButton: { + backgroundColor: colors.error, + padding: 16, + borderRadius: 10, + alignItems: "center", + marginTop: 12, + }, + secondaryButton: { + borderColor: colors.secondaryBtnBorder, + borderWidth: 1, + padding: 14, + borderRadius: 10, + alignItems: "center", + }, + secondaryButtonText: { + color: colors.secondaryBtnText, + fontWeight: "700", + }, + tokenPreview: { + marginTop: 10, + fontSize: 13, + color: colors.textSecondary, + }, + buttonText: { + color: colors.primaryBtnText, + fontWeight: "700", + fontSize: 16, + }, + backButton: { + marginTop: 22, + alignItems: "center", + }, + backButtonText: { + color: colors.textSecondary, + fontSize: 16, + }, + }); +} diff --git a/app/mobile/components/transaction-item.tsx b/app/mobile/components/transaction-item.tsx index 133ae72..4f9ca32 100644 --- a/app/mobile/components/transaction-item.tsx +++ b/app/mobile/components/transaction-item.tsx @@ -7,6 +7,7 @@ import { Clipboard, } from 'react-native'; import type { TransactionItem as TransactionItemType } from '../types/transaction'; +import { useAppTheme } from '@/context/ThemeContext'; interface Props { item: TransactionItemType; @@ -36,6 +37,8 @@ function shortenHash(hash: string): string { } export default function TransactionItem({ item }: Props) { + const { colors } = useAppTheme(); + const s = makeStyles(colors); const assetLabel = formatAsset(item.asset); const handleCopyHash = () => { @@ -43,100 +46,102 @@ export default function TransactionItem({ item }: Props) { }; return ( - + {/* Left: icon + asset */} - - {assetLabel.slice(0, 3)} + + {assetLabel.slice(0, 3)} {/* Middle: asset name, memo, date */} - - + + {assetLabel} {item.memo ? ( - + {item.memo} ) : null} - {shortenHash(item.txHash)} + {shortenHash(item.txHash)} - {formatDate(item.timestamp)} + {formatDate(item.timestamp)} {/* Right: amount */} - - + + {parseFloat(item.amount).toFixed(2)} - {assetLabel} + {assetLabel} ); } -const styles = StyleSheet.create({ - row: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 14, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#E5E7EB', - backgroundColor: '#fff', - }, - iconWrap: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: '#F3F4F6', - alignItems: 'center', - justifyContent: 'center', - marginRight: 14, - }, - assetIcon: { - fontSize: 13, - fontWeight: '700', - color: '#374151', - letterSpacing: -0.5, - }, - middle: { - flex: 1, - gap: 2, - }, - assetName: { - fontSize: 15, - fontWeight: '600', - color: '#111827', - }, - memo: { - fontSize: 13, - color: '#6B7280', - }, - txHash: { - fontSize: 11, - color: '#9CA3AF', - fontFamily: 'monospace', - }, - date: { - fontSize: 12, - color: '#9CA3AF', - marginTop: 1, - }, - right: { - alignItems: 'flex-end', - marginLeft: 8, - maxWidth: 110, - }, - amount: { - fontSize: 15, - fontWeight: '700', - color: '#111827', - }, - assetCode: { - fontSize: 12, - color: '#6B7280', - marginTop: 2, - }, -}); +function makeStyles(colors: ReturnType["colors"]) { + return StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + backgroundColor: colors.background, + }, + iconWrap: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.surfaceAlt, + alignItems: 'center', + justifyContent: 'center', + marginRight: 14, + }, + assetIcon: { + fontSize: 13, + fontWeight: '700', + color: colors.textSecondary, + letterSpacing: -0.5, + }, + middle: { + flex: 1, + gap: 2, + }, + assetName: { + fontSize: 15, + fontWeight: '600', + color: colors.text, + }, + memo: { + fontSize: 13, + color: colors.textSecondary, + }, + txHash: { + fontSize: 11, + color: colors.textMuted, + fontFamily: 'monospace', + }, + date: { + fontSize: 12, + color: colors.textMuted, + marginTop: 1, + }, + right: { + alignItems: 'flex-end', + marginLeft: 8, + maxWidth: 110, + }, + amount: { + fontSize: 15, + fontWeight: '700', + color: colors.text, + }, + assetCode: { + fontSize: 12, + color: colors.textSecondary, + marginTop: 2, + }, + }); +} diff --git a/app/mobile/constants/theme.ts b/app/mobile/constants/theme.ts index f06facd..78aaa81 100644 --- a/app/mobile/constants/theme.ts +++ b/app/mobile/constants/theme.ts @@ -1,6 +1,7 @@ /** - * Below are the colors that are used in the app. The colors are defined in the light and dark mode. - * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. + * Theme tokens for the QuickEx mobile app. + * All colours are defined here in light and dark mode. + * Screens should consume these via `useAppTheme()` – never use hardcoded values. */ import { Platform } from 'react-native'; @@ -10,20 +11,94 @@ const tintColorDark = '#fff'; export const Colors = { light: { + // Base text: '#11181C', - background: '#fff', + textSecondary: '#6B7280', + textMuted: '#9CA3AF', + background: '#ffffff', + surface: '#F9FAFB', + surfaceAlt: '#F3F4F6', + border: '#E5E7EB', + borderLight: '#F3F4F6', + + // Brand / tint tint: tintColorLight, icon: '#687076', tabIconDefault: '#687076', tabIconSelected: tintColorLight, + + // Buttons + primaryBtn: '#111827', + primaryBtnText: '#ffffff', + secondaryBtnBorder: '#111827', + secondaryBtnText: '#111827', + + // Cards / inputs + card: '#F5F5F5', + cardBorder: '#E5E7EB', + input: '#ffffff', + inputBorder: '#D1D5DB', + inputPlaceholder: '#9CA3AF', + + // Status colours (unchanged between themes – semantic) + success: '#10B981', + warning: '#F59E0B', + error: '#EF4444', + errorSurface: '#FEF2F2', + errorBorder: '#FECACA', + errorText: '#991B1B', + + // Account pill + pillBg: '#E5E7EB', + pillText: '#374151', + + // Skeleton / shimmer + skeleton: '#E5E7EB', }, dark: { + // Base text: '#ECEDEE', - background: '#151718', + textSecondary: '#9CA3AF', + textMuted: '#6B7280', + background: '#0F1117', + surface: '#1A1D27', + surfaceAlt: '#23273A', + border: '#2D3147', + borderLight: '#1E2235', + + // Brand / tint tint: tintColorDark, icon: '#9BA1A6', tabIconDefault: '#9BA1A6', tabIconSelected: tintColorDark, + + // Buttons + primaryBtn: '#ECEDEE', + primaryBtnText: '#0F1117', + secondaryBtnBorder: '#4B5563', + secondaryBtnText: '#ECEDEE', + + // Cards / inputs + card: '#1A1D27', + cardBorder: '#2D3147', + input: '#23273A', + inputBorder: '#374151', + inputPlaceholder: '#6B7280', + + // Status colours + success: '#10B981', + warning: '#F59E0B', + error: '#EF4444', + errorSurface: '#2D1515', + errorBorder: '#7F1D1D', + errorText: '#FCA5A5', + + // Account pill + pillBg: '#23273A', + pillText: '#9CA3AF', + + // Skeleton / shimmer + skeleton: '#23273A', }, }; diff --git a/app/mobile/context/ThemeContext.tsx b/app/mobile/context/ThemeContext.tsx new file mode 100644 index 0000000..8fc282e --- /dev/null +++ b/app/mobile/context/ThemeContext.tsx @@ -0,0 +1,95 @@ +/** + * ThemeContext + * + * Wraps the whole app and provides: + * - `themePreference` – what the user explicitly chose ('light' | 'dark' | 'system') + * - `colorScheme` – the resolved scheme that is currently active ('light' | 'dark') + * - `colors` – the Colors token set for the resolved scheme + * - `setThemePreference` – persist and apply a new preference + * + * Preference is stored in AsyncStorage so it survives app restarts without + * requiring a reload. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { useColorScheme } from 'react-native'; + +import { Colors } from '@/constants/theme'; + +export type ThemePreference = 'light' | 'dark' | 'system'; +export type ColorScheme = 'light' | 'dark'; + +interface ThemeContextValue { + /** What the user explicitly selected */ + themePreference: ThemePreference; + /** The resolved scheme that is currently active */ + colorScheme: ColorScheme; + /** Color tokens for the active scheme – use these in StyleSheet */ + colors: typeof Colors.light; + /** Persist and immediately apply a new preference */ + setThemePreference: (preference: ThemePreference) => Promise; +} + +const STORAGE_KEY = '@quickex/theme_preference'; + +const ThemeContext = createContext({ + themePreference: 'system', + colorScheme: 'light', + colors: Colors.light, + setThemePreference: async () => {}, +}); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const systemScheme = useColorScheme(); // 'light' | 'dark' | null + const [preference, setPreference] = useState('system'); + + // Load persisted preference on mount + useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY) + .then((stored) => { + if (stored === 'light' || stored === 'dark' || stored === 'system') { + setPreference(stored); + } + }) + .catch(() => { + // If storage fails, fall back to 'system' – already the default state + }); + }, []); + + const setThemePreference = useCallback(async (pref: ThemePreference) => { + setPreference(pref); + try { + await AsyncStorage.setItem(STORAGE_KEY, pref); + } catch { + // Silently ignore storage errors; the in-memory state change still applies + } + }, []); + + // Resolve the effective scheme + const colorScheme: ColorScheme = + preference === 'system' + ? (systemScheme ?? 'light') + : preference; + + const colors = Colors[colorScheme]; + + return ( + + {children} + + ); +} + +/** Hook – use this in every screen/component instead of useColorScheme directly */ +export function useAppTheme(): ThemeContextValue { + return useContext(ThemeContext); +}