From 1bde6fa8a24e64298332bf4d5f93b377ddb4b6de Mon Sep 17 00:00:00 2001 From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:57:16 -0400 Subject: [PATCH] theme --- apps/mobile/package.json | 2 + apps/mobile/src/app/(tabs)/_layout.tsx | 44 +- apps/mobile/src/app/(tabs)/index.tsx | 2 +- .../src/app/(tabs)/settings/_layout.tsx | 14 +- apps/mobile/src/app/(tabs)/settings/index.tsx | 136 ++++- .../src/app/(tabs)/settings/key-manager.tsx | 4 +- apps/mobile/src/app/(tabs)/shell/_layout.tsx | 15 +- .../[channelId].tsx => detail.tsx} | 53 +- apps/mobile/src/app/(tabs)/shell/index.tsx | 488 ++++++++++++++++-- apps/mobile/src/lib/query-fns.ts | 8 +- apps/mobile/src/lib/theme.tsx | 66 ++- pnpm-lock.yaml | 22 + 12 files changed, 750 insertions(+), 104 deletions(-) rename apps/mobile/src/app/(tabs)/shell/{[connectionId]/[channelId].tsx => detail.tsx} (82%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f9e06dd..2c57415 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -35,6 +35,7 @@ "@shopify/flash-list": "2.0.2", "@tanstack/react-form": "^1.20.0", "@tanstack/react-query": "^5.87.4", + "date-fns": "^4.1.0", "expo": "54.0.7", "expo-clipboard": "~8.0.7", "expo-constants": "~18.0.8", @@ -57,6 +58,7 @@ "react-dom": "19.1.0", "react-native": "0.81.4", "react-native-gesture-handler": "~2.28.0", + "react-native-mmkv": "^3.3.1", "react-native-reanimated": "~4.1.0", "react-native-safe-area-context": "~5.6.1", "react-native-screens": "~4.16.0", diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 76df629..8f17171 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -5,18 +5,48 @@ import { useTheme } from '@/lib/theme'; export default function TabsLayout() { const theme = useTheme(); return ( - + - - + + - - + + - - + + ); diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 786d40f..20f78c5 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -22,7 +22,7 @@ import { connectionDetailsSchema, secretsManager, } from '@/lib/secrets-manager'; -import { useTheme ,type AppTheme } from '@/lib/theme'; +import { useTheme, type AppTheme } from '@/lib/theme'; export default function TabsIndex() { return ; diff --git a/apps/mobile/src/app/(tabs)/settings/_layout.tsx b/apps/mobile/src/app/(tabs)/settings/_layout.tsx index f662677..9c03f48 100644 --- a/apps/mobile/src/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/settings/_layout.tsx @@ -1,5 +1,17 @@ import { Stack } from 'expo-router'; +import { useTheme } from '@/lib/theme'; export default function SettingsStackLayout() { - return ; + const theme = useTheme(); + return ( + + + + ); } diff --git a/apps/mobile/src/app/(tabs)/settings/index.tsx b/apps/mobile/src/app/(tabs)/settings/index.tsx index 1505e66..eea2ad0 100644 --- a/apps/mobile/src/app/(tabs)/settings/index.tsx +++ b/apps/mobile/src/app/(tabs)/settings/index.tsx @@ -1,25 +1,129 @@ import { Link } from 'expo-router'; -import { View, Text, StyleSheet } from 'react-native'; +import React from 'react'; +import { Pressable, View, Text, StyleSheet } from 'react-native'; +import { useTheme, useThemeControls, type AppTheme } from '@/lib/theme'; export default function Tab() { + const theme = useTheme(); + const styles = React.useMemo(() => makeStyles(theme), [theme]); + const { themeName, setThemeName } = useThemeControls(); + return ( - Settings - - Manage Keys - + + Theme + + setThemeName('dark')} + /> + setThemeName('light')} + /> + + + + + Security + + + Manage Keys + + + + ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#0B1324', - }, -}); +function Row({ + label, + selected, + onPress, +}: { + label: string; + selected?: boolean; + onPress: () => void; +}) { + const theme = useTheme(); + const styles = React.useMemo(() => makeStyles(theme), [theme]); + return ( + + {label} + {selected ? '✔' : ''} + + ); +} + +function makeStyles(theme: AppTheme) { + return StyleSheet.create({ + container: { + flex: 1, + padding: 16, + backgroundColor: theme.colors.background, + }, + // Title removed; screen header provides the title + section: { + marginBottom: 24, + }, + sectionTitle: { + color: theme.colors.textSecondary, + fontSize: 14, + marginBottom: 8, + }, + rowGroup: { gap: 8 }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.colors.surface, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 12, + }, + rowSelected: { + borderColor: theme.colors.primary, + }, + rowLabel: { + color: theme.colors.textPrimary, + fontSize: 16, + fontWeight: '600', + }, + rowCheck: { + color: theme.colors.primary, + fontSize: 16, + fontWeight: '800', + }, + callout: { + backgroundColor: theme.colors.surface, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + calloutLabel: { + color: theme.colors.textPrimary, + fontSize: 16, + fontWeight: '600', + }, + calloutChevron: { + color: theme.colors.muted, + fontSize: 22, + paddingHorizontal: 4, + }, + }); +} diff --git a/apps/mobile/src/app/(tabs)/settings/key-manager.tsx b/apps/mobile/src/app/(tabs)/settings/key-manager.tsx index 55b2acb..c8c23ac 100644 --- a/apps/mobile/src/app/(tabs)/settings/key-manager.tsx +++ b/apps/mobile/src/app/(tabs)/settings/key-manager.tsx @@ -2,10 +2,12 @@ import { Stack } from 'expo-router'; import React from 'react'; import { SafeAreaView } from 'react-native-safe-area-context'; import { KeyList } from '@/components/key-manager/KeyList'; +import { useTheme } from '@/lib/theme'; export default function SettingsKeyManager() { + const theme = useTheme(); return ( - + diff --git a/apps/mobile/src/app/(tabs)/shell/_layout.tsx b/apps/mobile/src/app/(tabs)/shell/_layout.tsx index f190971..562de01 100644 --- a/apps/mobile/src/app/(tabs)/shell/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/shell/_layout.tsx @@ -1,19 +1,22 @@ import { Stack } from 'expo-router'; import React from 'react'; +import { useTheme } from '@/lib/theme'; export default function TabsShellStack() { + const theme = useTheme(); return ( - + ); } diff --git a/apps/mobile/src/app/(tabs)/shell/[connectionId]/[channelId].tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx similarity index 82% rename from apps/mobile/src/app/(tabs)/shell/[connectionId]/[channelId].tsx rename to apps/mobile/src/app/(tabs)/shell/detail.tsx index e828cc0..0e64b4a 100644 --- a/apps/mobile/src/app/(tabs)/shell/[connectionId]/[channelId].tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -1,3 +1,4 @@ +import { Ionicons } from '@expo/vector-icons'; import { RnRussh } from '@fressh/react-native-uniffi-russh'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import React, { useEffect, useRef, useState } from 'react'; @@ -19,40 +20,41 @@ export default function TabsShellDetail() { function ShellDetail() { const { connectionId, channelId } = useLocalSearchParams<{ - connectionId: string; - channelId: string; + connectionId?: string; + channelId?: string; }>(); const router = useRouter(); const theme = useTheme(); const channelIdNum = Number(channelId); - const connection = RnRussh.getSshConnection(connectionId); - const shell = RnRussh.getSshShell(connectionId, channelIdNum); + const connection = connectionId + ? RnRussh.getSshConnection(String(connectionId)) + : undefined; + const shell = + connectionId && channelId + ? RnRussh.getSshShell(String(connectionId), channelIdNum) + : undefined; const [shellData, setShellData] = useState(''); - // Subscribe to data frames on the connection useEffect(() => { if (!connection) return; const decoder = new TextDecoder('utf-8'); - const channelListenerId = connection.addChannelListener( - (data: ArrayBuffer) => { - try { - const bytes = new Uint8Array(data); - const chunk = decoder.decode(bytes); - setShellData((prev) => prev + chunk); - } catch (e) { - console.warn('Failed to decode shell data', e); - } - }, - ); + const listenerId = connection.addChannelListener((data: ArrayBuffer) => { + try { + const bytes = new Uint8Array(data); + const chunk = decoder.decode(bytes); + setShellData((prev) => prev + chunk); + } catch (e) { + console.warn('Failed to decode shell data', e); + } + }); return () => { - connection.removeChannelListener(channelListenerId); + connection.removeChannelListener(listenerId); }; }, [connection]); const scrollViewRef = useRef(null); - useEffect(() => { scrollViewRef.current?.scrollToEnd({ animated: true }); }, [shellData]); @@ -63,16 +65,17 @@ function ShellDetail() { options={{ headerLeft: () => ( { - router.back(); - }} + onPress={() => router.back()} + hitSlop={10} + style={{ paddingHorizontal: 4, paddingVertical: 4 }} > - - Back - + ), - headerRight: () => ( { diff --git a/apps/mobile/src/app/(tabs)/shell/index.tsx b/apps/mobile/src/app/(tabs)/shell/index.tsx index 46e97fc..bd2192c 100644 --- a/apps/mobile/src/app/(tabs)/shell/index.tsx +++ b/apps/mobile/src/app/(tabs)/shell/index.tsx @@ -1,16 +1,18 @@ +import { Ionicons } from '@expo/vector-icons'; import { - type RnRussh, + RnRussh, type SshConnection, type SshShellSession, } from '@fressh/react-native-uniffi-russh'; import { FlashList } from '@shopify/flash-list'; -import { useQuery } from '@tanstack/react-query'; -import { Link } from 'expo-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { formatDistanceToNow } from 'date-fns'; +import { Link, Stack, useRouter } from 'expo-router'; import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { listSshShellsQueryOptions } from '@/lib/query-fns'; -import { useTheme ,type AppTheme } from '@/lib/theme'; +import { useTheme, type AppTheme } from '@/lib/theme'; export default function TabsShellList() { const theme = useTheme(); @@ -24,53 +26,204 @@ export default function TabsShellList() { type ShellWithConnection = SshShellSession & { connection: SshConnection }; function ShellContent() { + const [viewIndex, setViewIndex] = React.useState(0); const connectionsWithShells = useQuery(listSshShellsQueryOptions); - if (!connectionsWithShells.data) { - return ; - } - return ; + return ( + + ( + + ), + }} + /> + {connectionsWithShells.isLoading || !connectionsWithShells.data ? ( + + ) : ( + + )} + + ); } function LoadingState() { const theme = useTheme(); const styles = React.useMemo(() => makeStyles(theme), [theme]); return ( - - Loading... + + Loading... ); } function LoadedState({ connectionsWithShells, + viewIndex, }: { connectionsWithShells: ReturnType< typeof RnRussh.listSshConnectionsWithShells >; + viewIndex: number; }) { - const shellsFirstList = connectionsWithShells.reduce( - (acc, curr) => { + const theme = useTheme(); + const styles = React.useMemo(() => makeStyles(theme), [theme]); + const [actionTarget, setActionTarget] = React.useState(null); + const queryClient = useQueryClient(); + + const flatShells = React.useMemo(() => { + return connectionsWithShells.reduce((acc, curr) => { acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr }))); return acc; - }, - [], - ); + }, []); + }, [connectionsWithShells]); + + const [expanded, setExpanded] = React.useState>({}); + React.useEffect(() => { + const init: Record = {}; + for (const c of connectionsWithShells) init[c.connectionId] = true; + setExpanded(init); + }, [connectionsWithShells]); + + async function handleCloseShell(connId: string, channelId: number) { + try { + const shell = RnRussh.getSshShell(connId, channelId); + await (shell as any)?.close?.(); + } catch {} + await queryClient.invalidateQueries({ + queryKey: listSshShellsQueryOptions.queryKey, + }); + setActionTarget(null); + } + + async function handleDisconnect(connId: string) { + try { + const conn = RnRussh.getSshConnection(connId); + await conn?.disconnect(); + } catch {} + await queryClient.invalidateQueries({ + queryKey: listSshShellsQueryOptions.queryKey, + }); + setActionTarget(null); + } + + if (viewIndex === 0) { + return ( + + {flatShells.length === 0 ? ( + + ) : ( + `${item.connectionId}:${item.channelId}`} + renderItem={({ item }) => ( + + setActionTarget({ + connectionId: item.connectionId as string, + channelId: item.channelId as number, + }) + } + /> + )} + ItemSeparatorComponent={() => } + contentContainerStyle={{ + paddingVertical: 16, + paddingHorizontal: 16, + }} + style={{ flex: 1 }} + /> + )} + setActionTarget(null)} + onCloseShell={() => + actionTarget && + handleCloseShell(actionTarget.connectionId, actionTarget.channelId) + } + onDisconnect={() => + actionTarget && handleDisconnect(actionTarget.connectionId) + } + /> + + ); + } return ( - {shellsFirstList.length === 0 ? ( + {connectionsWithShells.length === 0 ? ( ) : ( `${item.connectionId}:${item.channelId}`} - renderItem={({ item }) => } + data={connectionsWithShells} + // estimatedItemSize={80} + keyExtractor={(item) => item.connectionId} + renderItem={({ item }) => ( + + + setExpanded((prev) => ({ + ...prev, + [item.connectionId]: !prev[item.connectionId], + })) + } + > + + + {item.connectionDetails.username}@ + {item.connectionDetails.host} + + + Port {item.connectionDetails.port} • {item.shells.length}{' '} + shell{item.shells.length === 1 ? '' : 's'} + + + + {expanded[item.connectionId] ? '▾' : '▸'} + + + {expanded[item.connectionId] && ( + + {item.shells.map((sh) => ( + + setActionTarget({ + connectionId: sh.connectionId as string, + channelId: sh.channelId as number, + }) + } + /> + ))} + + )} + + )} ItemSeparatorComponent={() => } - contentContainerStyle={{ paddingVertical: 16 }} + contentContainerStyle={{ paddingVertical: 16, paddingHorizontal: 16 }} style={{ flex: 1 }} /> )} + setActionTarget(null)} + onCloseShell={() => + actionTarget && + handleCloseShell(actionTarget.connectionId, actionTarget.channelId) + } + onDisconnect={() => + actionTarget && handleDisconnect(actionTarget.connectionId) + } + /> ); } @@ -79,36 +232,291 @@ function EmptyState() { const theme = useTheme(); const styles = React.useMemo(() => makeStyles(theme), [theme]); return ( - - No active shells. Connect from Host tab. - Go to Host + + + No active shells. Connect from Host tab. + + + Go to Hosts + ); } -function ShellCard({ shell }: { shell: ShellWithConnection }) { +function ShellCard({ + shell, + onLongPress, +}: { + shell: ShellWithConnection; + onLongPress?: () => void; +}) { const theme = useTheme(); const styles = React.useMemo(() => makeStyles(theme), [theme]); + const router = useRouter(); + const since = formatDistanceToNow(new Date(shell.createdAtMs), { + addSuffix: true, + }); return ( - - {shell.connectionId} - {shell.createdAtMs} - {shell.pty} - {shell.connection.connectionDetails.host} - {shell.connection.connectionDetails.port} - - {shell.connection.connectionDetails.username} - - - {shell.connection.connectionDetails.security.type} - - + + router.push({ + pathname: '/shell/detail', + params: { + connectionId: String(shell.connectionId), + channelId: String(shell.channelId), + }, + }) + } + onLongPress={onLongPress} + > + + + {shell.connection.connectionDetails.username}@ + {shell.connection.connectionDetails.host} + + + Port {shell.connection.connectionDetails.port} • {shell.pty} + + Started {since} + + + + ); +} + +function ActionsSheet({ + target, + onClose, + onCloseShell, + onDisconnect, +}: { + target: null | { connectionId: string; channelId: number }; + onClose: () => void; + onCloseShell: () => void; + onDisconnect: () => void; +}) { + const theme = useTheme(); + const styles = React.useMemo(() => makeStyles(theme), [theme]); + const open = !!target; + return ( + + + + Shell Actions + + + Close Shell + + + + + Disconnect Connection + + + + + Cancel + + + + ); } function makeStyles(theme: AppTheme) { return StyleSheet.create({ - container: { flex: 1, alignItems: 'center', justifyContent: 'center' }, - text: { color: theme.colors.textPrimary, marginBottom: 8 }, + centerContent: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + mutedText: { color: theme.colors.muted }, + link: { color: theme.colors.primary, fontWeight: '600' }, + + // headerBar/title removed in favor of TopBarToggle + + groupContainer: { + gap: 12, + }, + groupHeader: { + backgroundColor: theme.colors.surface, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + groupTitle: { + color: theme.colors.textPrimary, + fontSize: 16, + fontWeight: '700', + }, + groupSubtitle: { + color: theme.colors.muted, + fontSize: 12, + marginTop: 2, + }, + groupChevron: { + color: theme.colors.muted, + fontSize: 18, + }, + + card: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.colors.inputBackground, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 12, + }, + cardTitle: { + color: theme.colors.textPrimary, + fontSize: 15, + fontWeight: '600', + }, + cardSubtitle: { + color: theme.colors.textSecondary, + fontSize: 12, + marginTop: 2, + }, + cardMeta: { + color: theme.colors.muted, + fontSize: 12, + marginTop: 6, + }, + cardChevron: { + color: theme.colors.muted, + fontSize: 22, + paddingHorizontal: 4, + }, + + modalOverlay: { + flex: 1, + backgroundColor: theme.colors.overlay, + justifyContent: 'flex-end', + }, + modalSheet: { + backgroundColor: theme.colors.background, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + padding: 16, + borderColor: theme.colors.borderStrong, + borderWidth: 1, + }, + title: { + color: theme.colors.textPrimary, + fontSize: 18, + fontWeight: '700', + }, + + primaryButton: { + backgroundColor: theme.colors.primary, + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + }, + primaryButtonText: { + color: theme.colors.buttonTextOnPrimary, + fontWeight: '700', + fontSize: 14, + letterSpacing: 0.3, + }, + secondaryButton: { + backgroundColor: theme.colors.transparent, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + }, + secondaryButtonText: { + color: theme.colors.textSecondary, + fontWeight: '600', + fontSize: 14, + letterSpacing: 0.3, + }, }); } + +function TopBarToggle({ + viewIndex, + onChange, +}: { + viewIndex: number; + onChange: (index: number) => void; +}) { + const theme = useTheme(); + const styles = React.useMemo( + () => + StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 8, + alignItems: 'flex-end', + }, + toggle: { + flexDirection: 'row', + backgroundColor: theme.colors.surface, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 10, + overflow: 'hidden', + }, + segment: { + paddingHorizontal: 10, + paddingVertical: 6, + alignItems: 'center', + justifyContent: 'center', + }, + active: { + backgroundColor: theme.colors.inputBackground, + }, + iconActive: { color: theme.colors.textPrimary }, + iconInactive: { color: theme.colors.muted }, + }), + [theme], + ); + + return ( + + + onChange(0)} + style={[styles.segment, viewIndex === 0 && styles.active]} + > + + + onChange(1)} + style={[styles.segment, viewIndex === 1 && styles.active]} + > + + + + + ); +} diff --git a/apps/mobile/src/lib/query-fns.ts b/apps/mobile/src/lib/query-fns.ts index 1d56ccd..264a783 100644 --- a/apps/mobile/src/lib/query-fns.ts +++ b/apps/mobile/src/lib/query-fns.ts @@ -23,9 +23,9 @@ export const useSshConnMutation = () => { security: connectionDetails.security.type === 'password' ? { - type: 'password', - password: connectionDetails.security.password, - } + type: 'password', + password: connectionDetails.security.password, + } : { type: 'key', privateKey: 'TODO' }, onStatusChange: (status) => { console.log('SSH connection status', status); @@ -56,7 +56,7 @@ export const useSshConnMutation = () => { queryKey: listSshShellsQueryOptions.queryKey, }); router.push({ - pathname: '/shell/[connectionId]/[channelId]', + pathname: '/shell/detail', params: { connectionId: connectionId, channelId: String(channelId), diff --git a/apps/mobile/src/lib/theme.tsx b/apps/mobile/src/lib/theme.tsx index 1a89ac7..b4a938d 100644 --- a/apps/mobile/src/lib/theme.tsx +++ b/apps/mobile/src/lib/theme.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { MMKV } from 'react-native-mmkv'; export type AppTheme = { colors: { @@ -42,8 +43,38 @@ export const darkTheme: AppTheme = { }, }; +export const lightTheme: AppTheme = { + colors: { + background: '#F9FAFB', + surface: '#FFFFFF', + terminalBackground: '#F3F4F6', + border: '#E5E7EB', + borderStrong: '#D1D5DB', + textPrimary: '#111827', + textSecondary: '#374151', + muted: '#6B7280', + primary: '#2563EB', + buttonTextOnPrimary: '#FFFFFF', + inputBackground: '#FFFFFF', + danger: '#DC2626', + overlay: 'rgba(0,0,0,0.2)', + transparent: 'transparent', + shadow: '#000000', + primaryDisabled: '#93C5FD', + }, +}; + +export type ThemeName = 'dark' | 'light'; +export const themes: Record = { + dark: darkTheme, + light: lightTheme, +}; + type ThemeContextValue = { theme: AppTheme; + themeName: ThemeName; + setThemeName: (name: ThemeName) => void; + // Back-compat; not used externally but kept to avoid breaking imports setTheme: (theme: AppTheme) => void; }; @@ -51,10 +82,31 @@ const ThemeContext = React.createContext( undefined, ); -export function ThemeProvider(props: { children: React.ReactNode }) { - const [theme, setTheme] = React.useState(darkTheme); +const storage = new MMKV({ id: 'settings' }); +const THEME_KEY = 'theme'; - const value = React.useMemo(() => ({ theme, setTheme }), [theme]); +export function ThemeProvider(props: { children: React.ReactNode }) { + const [themeName, setThemeName] = React.useState(() => { + const stored = storage.getString(THEME_KEY); + return stored === 'light' ? 'light' : 'dark'; + }); + + const theme = themes[themeName]; + + const handleSetThemeName = React.useCallback((name: ThemeName) => { + setThemeName(name); + storage.set(THEME_KEY, name); + }, []); + + const value = React.useMemo( + () => ({ + theme, + themeName, + setThemeName: handleSetThemeName, + setTheme: () => {}, + }), + [theme, themeName, handleSetThemeName], + ); return ( @@ -68,3 +120,11 @@ export function useTheme() { if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); return ctx.theme; } + +export function useThemeControls() { + const ctx = React.useContext(ThemeContext); + if (!ctx) + throw new Error('useThemeControls must be used within ThemeProvider'); + const { themeName, setThemeName } = ctx; + return { themeName, setThemeName }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f9ea5e..caa2b6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: '@tanstack/react-query': specifier: ^5.87.4 version: 5.87.4(react@19.1.0) + date-fns: + specifier: ^4.1.0 + version: 4.1.0 expo: specifier: 54.0.7 version: 54.0.7(@babel/core@7.28.3)(@expo/metro-runtime@6.1.1)(expo-router@6.0.4)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0) @@ -136,6 +139,9 @@ importers: react-native-gesture-handler: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0) + react-native-mmkv: + specifier: ^3.3.1 + version: 3.3.1(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0) react-native-reanimated: specifier: ~4.1.0 version: 4.1.0(@babel/core@7.28.3)(react-native-worklets@0.5.1(@babel/core@7.28.3)(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0) @@ -4066,6 +4072,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.18: resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} @@ -7179,6 +7188,12 @@ packages: react: '*' react-native: '*' + react-native-mmkv@3.3.1: + resolution: {integrity: sha512-LYamDWQirPTUJZ9Re+BkCD+zLRGNr+EVJDeIeblvoJXGatWy9PXnChtajDSLqwjX3EXVeUyjgrembs7wlBw9ug==} + peerDependencies: + react: '*' + react-native: '*' + react-native-monorepo-config@0.1.10: resolution: {integrity: sha512-v0rlaLZiCUg95Mpw6xNRQce5k9yio0qscKjNQaPtFYMNL75YugS2UPUItIPLIRbZubK+s2/LRzBjX+mdyUgh4g==} @@ -13388,6 +13403,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.1.0: {} + dayjs@1.11.18: {} debug@2.6.9: @@ -17286,6 +17303,11 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0) + react-native-mmkv@3.3.1(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0) + react-native-monorepo-config@0.1.10: dependencies: escape-string-regexp: 5.0.0