diff --git a/App.tsx b/App.tsx index 09b9d7b..faa55f1 100644 --- a/App.tsx +++ b/App.tsx @@ -111,7 +111,6 @@ export default function App() { ); } -export default function App() { return ( diff --git a/package-lock.json b/package-lock.json index 74f8dc8..33587e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "mindvault-mobile", "version": "1.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/native": "^6.1.18", "@react-navigation/native-stack": "^6.11.0", "expo": "~52.0.46", @@ -3277,6 +3278,18 @@ "node": ">=14" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz", + "integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.60 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.9", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", @@ -6839,6 +6852,15 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -7728,6 +7750,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index 2f4aebc..7c0be6a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/native": "^6.1.18", "@react-navigation/native-stack": "^6.11.0", "expo": "~52.0.46", diff --git a/src/components/ErrorBanner.tsx b/src/components/ErrorBanner.tsx index e4588ad..9b926e3 100644 --- a/src/components/ErrorBanner.tsx +++ b/src/components/ErrorBanner.tsx @@ -1,12 +1,50 @@ +import { useMemo } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; -import { colors, spacing } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; +import type { ThemeColors } from "../theme"; interface ErrorBannerProps { message: string; onRetry: () => void; } +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + container: { + borderRadius: 12, + borderWidth: 1, + borderColor: colors.dangerBg, + backgroundColor: colors.dangerBg, + padding: 12, + gap: 8, + }, + message: { + color: colors.danger, + fontSize: 14, + }, + retryButton: { + alignSelf: "flex-start", + borderRadius: 8, + backgroundColor: colors.danger, + paddingHorizontal: 12, + paddingVertical: 8, + minHeight: 44, + minWidth: 44, + justifyContent: "center", + alignItems: "center", + }, + retryText: { + color: "#ffffff", + fontWeight: "600", + fontSize: 13, + }, + }); +} + export function ErrorBanner({ message, onRetry }: ErrorBannerProps) { + const { colors } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + return ( {message} @@ -16,34 +54,3 @@ export function ErrorBanner({ message, onRetry }: ErrorBannerProps) { ); } - -const styles = StyleSheet.create({ - container: { - borderRadius: 12, - borderWidth: 1, - borderColor: "#fecaca", - backgroundColor: "#fef2f2", - padding: spacing.md, - gap: spacing.sm, - }, - message: { - color: colors.danger, - fontSize: 14, - }, - retryButton: { - alignSelf: "flex-start", - borderRadius: 8, - backgroundColor: colors.danger, - paddingHorizontal: 12, - paddingVertical: 8, - minHeight: 44, - minWidth: 44, - justifyContent: "center", - alignItems: "center", - }, - retryText: { - color: "#ffffff", - fontWeight: "600", - fontSize: 13, - }, -}); diff --git a/src/components/PaywallModal.tsx b/src/components/PaywallModal.tsx index 7a114b3..5823a93 100644 --- a/src/components/PaywallModal.tsx +++ b/src/components/PaywallModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Linking, @@ -11,7 +11,9 @@ import { } from "react-native"; import { accessResource } from "../api/payment"; -import { colors, shared, spacing, typography } from "../theme"; +import { spacing } from "../theme"; +import type { ThemeColors } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; import type { AccessResult } from "../types"; interface PaywallModalProps { @@ -24,6 +26,61 @@ interface PaywallModalProps { type Phase = "confirm" | "loading" | "result" | "error"; +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.45)", + justifyContent: "flex-end", + }, + sheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: "80%", + paddingBottom: spacing.xxl, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + padding: spacing.lg, + borderBottomWidth: 1, + borderColor: colors.border, + }, + closeBtn: { + padding: spacing.xs, + }, + closeBtnText: { + fontSize: 16, + color: colors.textMuted, + }, + body: { + padding: spacing.lg, + gap: spacing.md, + }, + centered: { + alignItems: "center", + paddingVertical: spacing.xxl, + }, + actionBtn: { + alignItems: "center", + paddingVertical: 12, + }, + scrollArea: { + flexGrow: 0, + }, + scrollContent: { + padding: spacing.lg, + }, + contentText: { + fontSize: 14, + color: colors.text, + lineHeight: 22, + }, + }); +} + export function PaywallModal({ visible, accessUrl, @@ -31,11 +88,13 @@ export function PaywallModal({ price, onClose, }: PaywallModalProps) { + const { colors, shared, typography } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const [phase, setPhase] = useState("confirm"); const [result, setResult] = useState(null); const [errorMsg, setErrorMsg] = useState(null); - // Reset state each time the modal opens useEffect(() => { if (visible) { setPhase("confirm"); @@ -71,7 +130,6 @@ export function PaywallModal({ > - {/* Header */} {resourceTitle} @@ -81,7 +139,6 @@ export function PaywallModal({ - {/* Confirm phase */} {phase === "confirm" && ( @@ -102,7 +159,6 @@ export function PaywallModal({ )} - {/* Loading phase */} {phase === "loading" && ( @@ -110,14 +166,12 @@ export function PaywallModal({ )} - {/* Result phase — text content */} {phase === "result" && result?.type === "text" && ( {result.content} )} - {/* Error phase */} {phase === "error" && ( {errorMsg} @@ -134,56 +188,3 @@ export function PaywallModal({ ); } - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.45)", - justifyContent: "flex-end", - }, - sheet: { - backgroundColor: colors.surface, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - maxHeight: "80%", - paddingBottom: spacing.xxl, - }, - header: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - padding: spacing.lg, - borderBottomWidth: 1, - borderColor: colors.border, - }, - closeBtn: { - padding: spacing.xs, - }, - closeBtnText: { - fontSize: 16, - color: colors.textMuted, - }, - body: { - padding: spacing.lg, - gap: spacing.md, - }, - centered: { - alignItems: "center", - paddingVertical: spacing.xxl, - }, - actionBtn: { - alignItems: "center", - paddingVertical: 12, - }, - scrollArea: { - flexGrow: 0, - }, - scrollContent: { - padding: spacing.lg, - }, - contentText: { - fontSize: 14, - color: colors.text, - lineHeight: 22, - }, -}); diff --git a/src/components/RegisterModal.tsx b/src/components/RegisterModal.tsx index 406da01..42447c5 100644 --- a/src/components/RegisterModal.tsx +++ b/src/components/RegisterModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, Modal, @@ -11,7 +11,8 @@ import { import { prepareRegister, submitRegister } from "../api/resources"; import type { Resource } from "../types"; -import { colors, shared, typography } from "../theme"; +import type { ThemeColors } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; import { openExternalUrl, stellarExpertTxUrl } from "../utils/stellarExpert"; interface RegisterModalProps { @@ -24,6 +25,85 @@ interface RegisterModalProps { type RegistrationStep = "idle" | "preparing" | "signing" | "submitting" | "success" | "error"; +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "center", + alignItems: "center", + padding: 20, + }, + modal: { + backgroundColor: colors.surface, + borderRadius: 12, + width: "100%", + maxWidth: 500, + maxHeight: "80%", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 5, + }, + content: { + padding: 20, + }, + actions: { + flexDirection: "row", + gap: 12, + marginTop: 20, + }, + buttonSecondary: { + backgroundColor: "transparent", + borderWidth: 1, + borderColor: colors.border, + }, + buttonTextSecondary: { + color: colors.text, + }, + loadingContainer: { + alignItems: "center", + gap: 12, + paddingVertical: 20, + }, + xdrPreview: { + fontFamily: "monospace", + fontSize: 11, + color: colors.textMuted, + }, + successContainer: { + gap: 16, + paddingVertical: 12, + }, + successText: { + color: colors.success, + fontSize: 18, + fontWeight: "600", + }, + txHashContainer: { + gap: 4, + }, + txHash: { + fontFamily: "monospace", + fontSize: 11, + color: colors.textMuted, + backgroundColor: colors.neutralBg, + padding: 8, + borderRadius: 4, + }, + errorContainer: { + gap: 12, + paddingVertical: 12, + }, + errorText: { + color: colors.danger, + fontSize: 18, + fontWeight: "600", + }, + }); +} + export function RegisterModal({ visible, resource, @@ -31,6 +111,9 @@ export function RegisterModal({ onSuccess, onError, }: RegisterModalProps) { + const { colors, shared, typography } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const [step, setStep] = useState("idle"); const [xdr, setXdr] = useState(""); const [txHash, setTxHash] = useState(""); @@ -206,80 +289,3 @@ export function RegisterModal({ ); } - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.5)", - justifyContent: "center", - alignItems: "center", - padding: 20, - }, - modal: { - backgroundColor: colors.surface, - borderRadius: 12, - width: "100%", - maxWidth: 500, - maxHeight: "80%", - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 5, - }, - content: { - padding: 20, - }, - actions: { - flexDirection: "row", - gap: 12, - marginTop: 20, - }, - buttonSecondary: { - backgroundColor: "transparent", - borderWidth: 1, - borderColor: colors.border, - }, - buttonTextSecondary: { - color: colors.text, - }, - loadingContainer: { - alignItems: "center", - gap: 12, - paddingVertical: 20, - }, - xdrPreview: { - fontFamily: "monospace", - fontSize: 11, - color: colors.textMuted, - }, - successContainer: { - gap: 16, - paddingVertical: 12, - }, - successText: { - color: colors.success, - fontSize: 18, - fontWeight: "600", - }, - txHashContainer: { - gap: 4, - }, - txHash: { - fontFamily: "monospace", - fontSize: 11, - color: colors.textMuted, - backgroundColor: colors.neutralBg, - padding: 8, - borderRadius: 4, - }, - errorContainer: { - gap: 12, - paddingVertical: 12, - }, - errorText: { - color: colors.danger, - fontSize: 18, - fontWeight: "600", - }, -}); diff --git a/src/components/ResourceCard.tsx b/src/components/ResourceCard.tsx index a450396..5c86c0f 100644 --- a/src/components/ResourceCard.tsx +++ b/src/components/ResourceCard.tsx @@ -1,5 +1,5 @@ import * as Clipboard from "expo-clipboard"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, Pressable, @@ -26,7 +26,7 @@ function shortenAddress(address: string): string { return `${address.slice(0, 6)}…${address.slice(-4)}`; } -function verificationStyle(status: Resource["verificationStatus"]) { +function verificationStyle(status: Resource["verificationStatus"], colors: ThemeColors) { switch (status) { case "verified": return { backgroundColor: colors.successBg, color: colors.success }; @@ -37,7 +37,7 @@ function verificationStyle(status: Resource["verificationStatus"]) { } } -function onchainStyle(status: Resource["onchainStatus"]) { +function onchainStyle(status: Resource["onchainStatus"], colors: ThemeColors) { switch (status) { case "registered": return { backgroundColor: colors.primaryMuted, color: colors.primary }; @@ -270,4 +270,4 @@ const styles = StyleSheet.create({ secondaryButton: { backgroundColor: colors.neutralBg, }, -}); \ No newline at end of file +}); diff --git a/src/components/SkeletonCard.tsx b/src/components/SkeletonCard.tsx index c18ae5a..f2391fb 100644 --- a/src/components/SkeletonCard.tsx +++ b/src/components/SkeletonCard.tsx @@ -1,12 +1,41 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Animated, StyleSheet, View } from "react-native"; -import { colors, shared } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; +import type { ThemeColors } from "../theme"; -function Bone({ width, height = 14 }: { width: number | `${number}%`; height?: number }) { - return ; +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + bone: { + backgroundColor: colors.border, + }, + badges: { + flexDirection: "row", + gap: 6, + }, + footer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginTop: 4, + }, + }); +} + +function Bone({ + width, + height = 14, + style, +}: { + width: number | `${number}%`; + height?: number; + style: ReturnType["bone"]; +}) { + return ; } export function SkeletonCard() { + const { colors, shared } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); const opacity = useRef(new Animated.Value(0.4)).current; useEffect(() => { @@ -22,40 +51,18 @@ export function SkeletonCard() { return ( - {/* title */} - - {/* publisher */} - - {/* owner caption */} - - {/* badges */} + + + - - + + - {/* footer: price + button */} - - + + - {/* edit button */} - + ); } - -const styles = StyleSheet.create({ - bone: { - backgroundColor: colors.border, - }, - badges: { - flexDirection: "row", - gap: 6, - }, - footer: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - marginTop: 4, - }, -}); diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..ccd407f --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,103 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { useAppTheme } from "../theme/ThemeProvider"; +import type { ThemeMode } from "../theme"; + +export function ThemeToggle() { + const { colors, themeMode, setThemeMode } = useAppTheme(); + + const themes: { mode: ThemeMode; label: string; symbol: string }[] = [ + { mode: "light", label: "Light", symbol: "☀" }, + { mode: "dark", label: "Dark", symbol: "☾" }, + { mode: "system", label: "System", symbol: "◐" }, + ]; + + function handleThemeChange(mode: ThemeMode) { + setThemeMode(mode); + } + + return ( + + + Theme + + Choose your preferred theme + + + {themes.map((theme) => ( + handleThemeChange(theme.mode)} + > + + {theme.symbol} + + + {theme.label} + + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 16, + }, + card: { + borderRadius: 16, + borderWidth: 1, + padding: 16, + gap: 12, + }, + title: { + fontSize: 18, + fontWeight: "600", + }, + subtitle: { + fontSize: 13, + lineHeight: 18, + }, + options: { + flexDirection: "row", + flexWrap: "wrap", + gap: 12, + }, + option: { + flex: 1, + minWidth: 100, + borderRadius: 12, + borderWidth: 1, + padding: 16, + alignItems: "center", + gap: 8, + }, + optionSymbol: { + fontSize: 20, + lineHeight: 24, + }, + optionText: { + fontSize: 13, + fontWeight: "600", + }, +}); diff --git a/src/components/TransferOwnershipModal.tsx b/src/components/TransferOwnershipModal.tsx index 958de82..896a57b 100644 --- a/src/components/TransferOwnershipModal.tsx +++ b/src/components/TransferOwnershipModal.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { ActivityIndicator, Modal, @@ -7,7 +8,9 @@ import { TextInput, View, } from "react-native"; -import { colors, shared, spacing, typography } from "../theme"; +import { spacing } from "../theme"; +import type { ThemeColors } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; import { useTransferOwnership } from "../hooks/useTransferOwnership"; import type { Resource } from "../types"; import { openExternalUrl, stellarExpertTxUrl } from "../utils/stellarExpert"; @@ -28,12 +31,109 @@ const STEP_LABELS: Record = { error: "", }; +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + justifyContent: "flex-end", + }, + sheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: spacing.lg, + gap: spacing.md, + paddingBottom: spacing.xxl, + }, + subtitle: { + color: colors.textMuted, + }, + label: { + fontSize: 13, + fontWeight: "600", + color: colors.text, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 13, + color: colors.text, + fontFamily: "monospace", + }, + inputError: { + borderColor: colors.danger, + }, + validationHint: { + fontSize: 12, + color: colors.danger, + marginTop: -8, + }, + errorText: { + fontSize: 13, + color: colors.danger, + backgroundColor: colors.dangerBg, + borderRadius: 8, + padding: 8, + }, + processingRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + actions: { + flexDirection: "row", + gap: 8, + marginTop: 8, + }, + cancelButton: { + flex: 1, + backgroundColor: colors.neutralBg, + }, + cancelText: { + color: colors.text, + fontWeight: "600", + textAlign: "center", + }, + confirmButton: { + flex: 1, + backgroundColor: colors.primary, + }, + disabledButton: { + opacity: 0.4, + }, + successContainer: { + gap: 12, + alignItems: "center", + }, + successText: { + fontSize: 16, + fontWeight: "700", + color: colors.success, + }, + txHash: { + fontSize: 11, + fontFamily: "monospace", + color: colors.primary, + width: "100%", + textAlign: "center", + }, + }); +} + export function TransferOwnershipModal({ resource, visible, onClose, onSuccess, }: TransferOwnershipModalProps) { + const { colors, shared, typography } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { step, error, @@ -51,9 +151,7 @@ export function TransferOwnershipModal({ function handleOpenExplorer() { if (!txHash) return; const url = stellarExpertTxUrl({ txHash }); - openExternalUrl(url).catch(() => { - // no-op; this modal doesn't have an error callback and should remain usable - }); + openExternalUrl(url).catch(() => {}); } function handleClose() { @@ -161,95 +259,3 @@ export function TransferOwnershipModal({ ); } - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.5)", - justifyContent: "flex-end", - }, - sheet: { - backgroundColor: colors.surface, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - padding: spacing.lg, - gap: spacing.md, - paddingBottom: spacing.xxl, - }, - subtitle: { - color: colors.textMuted, - }, - label: { - fontSize: 13, - fontWeight: "600", - color: colors.text, - }, - input: { - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - borderRadius: 12, - paddingHorizontal: 14, - paddingVertical: 12, - fontSize: 13, - color: colors.text, - fontFamily: "monospace", - }, - inputError: { - borderColor: colors.danger, - }, - validationHint: { - fontSize: 12, - color: colors.danger, - marginTop: -8, - }, - errorText: { - fontSize: 13, - color: colors.danger, - backgroundColor: "#fef2f2", - borderRadius: 8, - padding: 8, - }, - processingRow: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - actions: { - flexDirection: "row", - gap: 8, - marginTop: 8, - }, - cancelButton: { - flex: 1, - backgroundColor: colors.neutralBg, - }, - cancelText: { - color: colors.text, - fontWeight: "600", - textAlign: "center", - }, - confirmButton: { - flex: 1, - backgroundColor: colors.primary, - }, - disabledButton: { - opacity: 0.4, - }, - successContainer: { - gap: 12, - alignItems: "center", - }, - successText: { - fontSize: 16, - fontWeight: "700", - color: colors.success, - }, - txHash: { - fontSize: 11, - fontFamily: "monospace", - color: colors.textMuted, - width: "100%", - textAlign: "center", - }, -}); diff --git a/src/navigation.ts b/src/navigation.ts index 6132454..a20d1d3 100644 --- a/src/navigation.ts +++ b/src/navigation.ts @@ -6,6 +6,7 @@ import { ResourceDetailScreen } from "./screens/ResourceDetailScreen"; export type RootStackParamList = { Catalog: undefined; ResourceDetail: { resourceId: string }; + Settings: undefined; }; const Stack = createNativeStackNavigator(); diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 873c17b..cef588d 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -18,13 +18,130 @@ import { ResourceCard } from "../components/ResourceCard"; import { SkeletonCard } from "../components/SkeletonCard"; import type { RootStackParamList } from "../navigation"; import type { Resource } from "../types"; -import { colors, shared, spacing, typography } from "../theme"; +import { spacing } from "../theme"; +import type { ThemeColors } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; interface CatalogScreenProps { navigation: NativeStackNavigationProp; } +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + listContent: { + padding: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.md, + }, + header: { + gap: spacing.md, + marginBottom: spacing.lg, + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + gap: spacing.md, + }, + settingsButton: { + borderRadius: 10, + backgroundColor: colors.neutralBg, + paddingHorizontal: 12, + paddingVertical: 8, + minHeight: 44, + minWidth: 44, + justifyContent: "center", + alignItems: "center", + }, + settingsButtonText: { + fontSize: 20, + color: colors.textMuted, + }, + registry: { + marginTop: spacing.xs, + fontSize: 13, + fontWeight: "600", + color: colors.primary, + }, + searchInput: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + color: colors.text, + }, + apiHint: { + fontSize: 11, + color: colors.textSubtle, + }, + skeletons: { + gap: 12, + }, + separator: { + height: spacing.md, + }, + emptyState: { + alignItems: "center", + paddingVertical: spacing.xxl, + gap: spacing.sm, + }, + emptyTitle: { + fontSize: 18, + fontWeight: "600", + color: colors.text, + }, + emptyBody: { + textAlign: "center", + fontSize: 14, + color: colors.textMuted, + maxWidth: 320, + lineHeight: 20, + }, + fab: { + position: "absolute", + right: spacing.lg, + bottom: spacing.xl, + backgroundColor: colors.primary, + borderRadius: 28, + paddingHorizontal: 20, + paddingVertical: 14, + elevation: 4, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + }, + fabText: { + color: "#ffffff", + fontWeight: "700", + fontSize: 15, + }, + toast: { + position: "absolute", + bottom: spacing.xl, + left: spacing.lg, + right: spacing.lg, + backgroundColor: colors.text, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + toastText: { + color: colors.background, + textAlign: "center", + fontSize: 14, + fontWeight: "500", + }, + }); +} + export function CatalogScreen({ navigation }: CatalogScreenProps) { + const { colors, shared, typography, colorScheme } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const [resources, setResources] = useState([]); const [registryCount, setRegistryCount] = useState(null); const [search, setSearch] = useState(""); @@ -93,7 +210,7 @@ export function CatalogScreen({ navigation }: CatalogScreenProps) { return ( - + item.id} @@ -103,16 +220,26 @@ export function CatalogScreen({ navigation }: CatalogScreenProps) { } ListHeaderComponent={ - - MindVault - - Payment-protected digital resources on Stellar - - {registryCount !== null ? ( - - {registryCount} resource{registryCount === 1 ? "" : "s"} on-chain + + + MindVault + + Payment-protected digital resources on Stellar - ) : null} + {registryCount !== null ? ( + + {registryCount} resource{registryCount === 1 ? "" : "s"} on-chain + + ) : null} + + navigation.navigate("Settings")} + accessibilityRole="button" + accessibilityLabel="Open settings" + > + + ; @@ -24,7 +26,7 @@ function shortenAddress(address: string): string { return `${address.slice(0, 6)}…${address.slice(-4)}`; } -function verificationStyle(status: Resource["verificationStatus"]) { +function verificationStyle(status: Resource["verificationStatus"], colors: ThemeColors) { switch (status) { case "verified": return { backgroundColor: colors.successBg, color: colors.success }; @@ -35,7 +37,7 @@ function verificationStyle(status: Resource["verificationStatus"]) { } } -function onchainStyle(status: Resource["onchainStatus"]) { +function onchainStyle(status: Resource["onchainStatus"], colors: ThemeColors) { switch (status) { case "registered": return { backgroundColor: colors.primaryMuted, color: colors.primary }; @@ -48,7 +50,84 @@ function onchainStyle(status: Resource["onchainStatus"]) { } } +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: spacing.lg, + gap: spacing.md, + }, + errorText: { + color: colors.danger, + fontSize: 16, + fontWeight: "500", + }, + content: { + flex: 1, + padding: spacing.lg, + gap: spacing.md, + }, + price: { + fontSize: 24, + }, + badges: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, + infoRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + label: { + fontSize: 14, + fontWeight: "600", + color: colors.textMuted, + }, + actionButton: { + alignSelf: "stretch", + alignItems: "center", + paddingVertical: 12, + }, + urlText: { + fontSize: 13, + color: colors.textSubtle, + lineHeight: 18, + }, + linkText: { + color: colors.primary, + }, + mono: { + fontFamily: "monospace", + fontSize: 12, + maxWidth: 220, + }, + toast: { + position: "absolute", + bottom: spacing.xl, + left: spacing.lg, + right: spacing.lg, + backgroundColor: colors.text, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + toastText: { + color: colors.background, + textAlign: "center", + fontSize: 14, + fontWeight: "500", + }, + }); +} + export function ResourceDetailScreen({ route, navigation }: Props) { + const { colors, shared, typography } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { resourceId } = route.params; const [resource, setResource] = useState(null); const [loading, setLoading] = useState(true); @@ -86,7 +165,7 @@ export function ResourceDetailScreen({ route, navigation }: Props) { if (!resource) return; try { await Sharing.shareAsync(resource.accessUrl); - } catch (error) { + } catch { setToast("Unable to share URL"); } } @@ -131,8 +210,8 @@ export function ResourceDetailScreen({ route, navigation }: Props) { ); } - const verification = verificationStyle(resource.verificationStatus); - const onchain = onchainStyle(resource.onchainStatus); + const verification = verificationStyle(resource.verificationStatus, colors); + const onchain = onchainStyle(resource.onchainStatus, colors); return ( @@ -222,75 +301,3 @@ export function ResourceDetailScreen({ route, navigation }: Props) { ); } - -const styles = StyleSheet.create({ - centered: { - flex: 1, - justifyContent: "center", - alignItems: "center", - padding: spacing.lg, - gap: spacing.md, - }, - errorText: { - color: colors.danger, - fontSize: 16, - fontWeight: "500", - }, - content: { - flex: 1, - padding: spacing.lg, - gap: spacing.md, - }, - price: { - fontSize: 24, - }, - badges: { - flexDirection: "row", - flexWrap: "wrap", - gap: 6, - }, - infoRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - label: { - fontSize: 14, - fontWeight: "600", - color: colors.textMuted, - }, - actionButton: { - alignSelf: "stretch", - alignItems: "center", - paddingVertical: 12, - }, - urlText: { - fontSize: 13, - color: colors.textSubtle, - lineHeight: 18, - }, - linkText: { - color: colors.primary, - }, - mono: { - fontFamily: "monospace", - fontSize: 12, - maxWidth: 220, - }, - toast: { - position: "absolute", - bottom: spacing.xl, - left: spacing.lg, - right: spacing.lg, - backgroundColor: colors.text, - borderRadius: 12, - paddingVertical: 12, - paddingHorizontal: 16, - }, - toastText: { - color: "#ffffff", - textAlign: "center", - fontSize: 14, - fontWeight: "500", - }, -}); diff --git a/src/screens/ScannerScreen.tsx b/src/screens/ScannerScreen.tsx index 9268228..55ad875 100644 --- a/src/screens/ScannerScreen.tsx +++ b/src/screens/ScannerScreen.tsx @@ -1,10 +1,12 @@ import { CameraView, useCameraPermissions } from "expo-camera"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Alert, Linking, Pressable, StyleSheet, Text, View } from "react-native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { RootStackParamList } from "../navigation"; -import { colors, shared, spacing, typography } from "../theme"; +import { spacing } from "../theme"; +import type { ThemeColors } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; const RESOURCE_URL_RE = /\/resources\/([^\/?#\s]+)/; @@ -12,7 +14,84 @@ interface ScannerScreenProps { navigation: NativeStackNavigationProp; } +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#000", + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: spacing.lg, + backgroundColor: colors.background, + gap: spacing.md, + }, + heading: { + fontSize: 18, + fontWeight: "600", + color: colors.text, + }, + permissionBody: { + textAlign: "center", + }, + overlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: "center", + alignItems: "center", + }, + viewfinder: { + width: 250, + height: 250, + }, + corner: { + position: "absolute", + width: 30, + height: 30, + borderColor: "#ffffff", + }, + cornerTopLeft: { + top: 0, + left: 0, + borderTopWidth: 3, + borderLeftWidth: 3, + borderTopLeftRadius: 4, + }, + cornerTopRight: { + top: 0, + right: 0, + borderTopWidth: 3, + borderRightWidth: 3, + borderTopRightRadius: 4, + }, + cornerBottomLeft: { + bottom: 0, + left: 0, + borderBottomWidth: 3, + borderLeftWidth: 3, + borderBottomLeftRadius: 4, + }, + cornerBottomRight: { + bottom: 0, + right: 0, + borderBottomWidth: 3, + borderRightWidth: 3, + borderBottomRightRadius: 4, + }, + hint: { + color: "#ffffff", + fontSize: 15, + fontWeight: "500", + marginTop: spacing.xl, + }, + }); +} + export function ScannerScreen({ navigation }: ScannerScreenProps) { + const { colors, shared, typography } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const [permission, requestPermission] = useCameraPermissions(); const [scanned, setScanned] = useState(false); @@ -97,75 +176,3 @@ export function ScannerScreen({ navigation }: ScannerScreenProps) { ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#000", - }, - centered: { - flex: 1, - justifyContent: "center", - alignItems: "center", - padding: spacing.lg, - backgroundColor: colors.background, - gap: spacing.md, - }, - heading: { - fontSize: 18, - fontWeight: "600", - color: colors.text, - }, - permissionBody: { - textAlign: "center", - }, - overlay: { - ...StyleSheet.absoluteFillObject, - justifyContent: "center", - alignItems: "center", - }, - viewfinder: { - width: 250, - height: 250, - }, - corner: { - position: "absolute", - width: 30, - height: 30, - borderColor: "#ffffff", - }, - cornerTopLeft: { - top: 0, - left: 0, - borderTopWidth: 3, - borderLeftWidth: 3, - borderTopLeftRadius: 4, - }, - cornerTopRight: { - top: 0, - right: 0, - borderTopWidth: 3, - borderRightWidth: 3, - borderTopRightRadius: 4, - }, - cornerBottomLeft: { - bottom: 0, - left: 0, - borderBottomWidth: 3, - borderLeftWidth: 3, - borderBottomLeftRadius: 4, - }, - cornerBottomRight: { - bottom: 0, - right: 0, - borderBottomWidth: 3, - borderRightWidth: 3, - borderBottomRightRadius: 4, - }, - hint: { - color: "#ffffff", - fontSize: 15, - fontWeight: "500", - marginTop: spacing.xl, - }, -}); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..2b82bcb --- /dev/null +++ b/src/screens/SettingsScreen.tsx @@ -0,0 +1,60 @@ +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { ThemeToggle } from "../components/ThemeToggle"; +import { useAppTheme } from "../theme/ThemeProvider"; + +export function SettingsScreen() { + const { colors, shared } = useAppTheme(); + + return ( + + + + Settings + + Customize your app experience + + + + + + + + Theme preferences are saved locally and persist across app launches. + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingBottom: 32, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 24, + gap: 4, + }, + title: { + fontSize: 32, + fontWeight: "700", + }, + subtitle: { + fontSize: 15, + }, + infoCard: { + marginHorizontal: 16, + marginTop: 16, + borderRadius: 12, + padding: 16, + }, + infoText: { + fontSize: 13, + lineHeight: 20, + }, +}); diff --git a/src/theme.ts b/src/theme.ts deleted file mode 100644 index 02a8c5c..0000000 --- a/src/theme.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { StyleSheet } from "react-native"; - -export const colors = { - background: "#f9fafb", - surface: "#ffffff", - text: "#111827", - textMuted: "#6b7280", - textSubtle: "#9ca3af", - border: "#e5e7eb", - primary: "#4f46e5", - primaryMuted: "#eef2ff", - success: "#15803d", - successBg: "#dcfce7", - warning: "#b45309", - warningBg: "#fef3c7", - danger: "#b91c1c", - dangerBg: "#fee2e2", - neutralBg: "#f3f4f6", -}; - -export const spacing = { - xs: 4, - sm: 8, - md: 12, - lg: 16, - xl: 24, - xxl: 32, -}; - -export const typography = StyleSheet.create({ - title: { - fontSize: 28, - fontWeight: "700", - color: colors.text, - }, - subtitle: { - fontSize: 14, - color: colors.textMuted, - }, - cardTitle: { - fontSize: 16, - fontWeight: "600", - color: colors.text, - }, - body: { - fontSize: 14, - color: colors.textMuted, - }, - caption: { - fontSize: 12, - color: colors.textSubtle, - }, - price: { - fontSize: 15, - fontWeight: "600", - color: colors.primary, - }, -}); - -export const shared = StyleSheet.create({ - screen: { - flex: 1, - backgroundColor: colors.background, - }, - card: { - backgroundColor: colors.surface, - borderRadius: 16, - borderWidth: 1, - borderColor: colors.border, - padding: 16, - gap: 8, - }, - badge: { - borderRadius: 999, - paddingHorizontal: 8, - paddingVertical: 4, - }, - badgeText: { - fontSize: 11, - fontWeight: "600", - textTransform: "capitalize", - }, - button: { - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: 8, - backgroundColor: colors.neutralBg, - // Minimum 44x44 touch target for accessibility (WCAG 2.5.5 / Apple HIG). - minHeight: 44, - minWidth: 44, - justifyContent: "center", - alignItems: "center", - }, - buttonText: { - fontSize: 12, - fontWeight: "600", - color: colors.text, - }, - primaryButton: { - backgroundColor: colors.primary, - }, - primaryButtonText: { - color: "#ffffff", - }, -}); diff --git a/src/theme/ThemeProvider.tsx b/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000..5402f0d --- /dev/null +++ b/src/theme/ThemeProvider.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext, ReactNode } from "react"; +import { useTheme } from "./index"; + +type ThemeContextType = ReturnType; + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const theme = useTheme(); + + return {children}; +} + +export function useAppTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useAppTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/theme/index.ts b/src/theme/index.ts new file mode 100644 index 0000000..0451121 --- /dev/null +++ b/src/theme/index.ts @@ -0,0 +1,230 @@ +import { StyleSheet } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useColorScheme } from "react-native"; +import { useState, useEffect } from "react"; + +// Theme type definitions +export type ThemeMode = "light" | "dark" | "system"; + +export type ThemeColors = { + background: string; + surface: string; + text: string; + textMuted: string; + textSubtle: string; + border: string; + primary: string; + primaryMuted: string; + success: string; + successBg: string; + warning: string; + warningBg: string; + danger: string; + dangerBg: string; + neutralBg: string; +}; + +export type ColorScheme = "light" | "dark"; + +// Light theme colors +export const lightColors: ThemeColors = { + background: "#f9fafb", + surface: "#ffffff", + text: "#111827", + textMuted: "#6b7280", + textSubtle: "#9ca3af", + border: "#e5e7eb", + primary: "#4f46e5", + primaryMuted: "#eef2ff", + success: "#15803d", + successBg: "#dcfce7", + warning: "#b45309", + warningBg: "#fef3c7", + danger: "#b91c1c", + dangerBg: "#fee2e2", + neutralBg: "#f3f4f6", +}; + +// Dark theme colors +export const darkColors: ThemeColors = { + background: "#1f2937", + surface: "#374151", + text: "#f9fafb", + textMuted: "#d1d5db", + textSubtle: "#9ca3af", + border: "#4b5563", + primary: "#6366f1", + primaryMuted: "#3730a3", + success: "#34d399", + successBg: "#064e3b", + warning: "#fbbf24", + warningBg: "#78350f", + danger: "#f87171", + dangerBg: "#7f1d1d", + neutralBg: "#374151", +}; + +// Spacing constants (same for both themes) +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, +}; + +// Storage key for theme preference +const THEME_STORAGE_KEY = "@mindvault_theme_mode"; + +// Helper to get colors based on theme mode +export function getColors( + mode: ThemeMode, + systemColorScheme: ColorScheme | null | undefined +): ThemeColors { + if (mode === "system") { + return systemColorScheme === "dark" ? darkColors : lightColors; + } + return mode === "dark" ? darkColors : lightColors; +} + +export function resolveColorScheme( + mode: ThemeMode, + systemColorScheme: ColorScheme | null | undefined +): ColorScheme { + if (mode === "system") { + return systemColorScheme === "dark" ? "dark" : "light"; + } + return mode; +} + +// Theme hook for components +export function useTheme() { + const systemColorScheme = useColorScheme(); + const [themeMode, setThemeMode] = useState("system"); + const [isLoading, setIsLoading] = useState(true); + + // Load saved theme preference + useEffect(() => { + async function loadTheme() { + try { + const savedMode = await AsyncStorage.getItem(THEME_STORAGE_KEY); + if (savedMode && ["light", "dark", "system"].includes(savedMode)) { + setThemeMode(savedMode as ThemeMode); + } + } catch (error) { + console.error("Failed to load theme preference:", error); + } finally { + setIsLoading(false); + } + } + loadTheme(); + }, []); + + // Save theme preference + async function saveTheme(mode: ThemeMode) { + try { + await AsyncStorage.setItem(THEME_STORAGE_KEY, mode); + setThemeMode(mode); + } catch (error) { + console.error("Failed to save theme preference:", error); + } + } + + const colors = getColors(themeMode, systemColorScheme); + const colorScheme = resolveColorScheme(themeMode, systemColorScheme); + + // Create typography styles with current colors + const typography = StyleSheet.create({ + heading: { + fontSize: 20, + fontWeight: "700", + color: colors.text, + }, + title: { + fontSize: 28, + fontWeight: "700", + color: colors.text, + }, + subtitle: { + fontSize: 14, + color: colors.textMuted, + }, + cardTitle: { + fontSize: 16, + fontWeight: "600", + color: colors.text, + }, + body: { + fontSize: 14, + color: colors.textMuted, + }, + caption: { + fontSize: 12, + color: colors.textSubtle, + }, + price: { + fontSize: 15, + fontWeight: "600", + color: colors.primary, + }, + }); + + // Create shared styles with current colors + const shared = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: colors.background, + }, + card: { + backgroundColor: colors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: colors.border, + padding: 16, + gap: 8, + }, + badge: { + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 4, + }, + badgeText: { + fontSize: 11, + fontWeight: "600", + textTransform: "capitalize", + }, + button: { + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: colors.neutralBg, + minHeight: 44, + minWidth: 44, + justifyContent: "center", + alignItems: "center", + }, + buttonText: { + fontSize: 12, + fontWeight: "600", + color: colors.text, + }, + primaryButton: { + backgroundColor: colors.primary, + }, + primaryButtonText: { + color: "#ffffff", + }, + }); + + return { + colors, + colorScheme, + spacing, + typography, + shared, + themeMode, + setThemeMode: saveTheme, + isLoading, + }; +}