diff --git a/app.json b/app.json index f45b9b2..48e337b 100644 --- a/app.json +++ b/app.json @@ -1,35 +1,75 @@ { "expo": { - "name": "codexbar-mobile", + "name": "Codexbar", "slug": "codexbar-mobile", + "description": "Monitor Codex CLI and Claude.ai usage limits, resets, and credits at a glance.", "scheme": "codexbar", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", - "userInterfaceStyle": "dark", + "backgroundColor": "#090D11", + "primaryColor": "#10A37F", + "userInterfaceStyle": "automatic", "ios": { - "supportsTablet": true + "bundleIdentifier": "dev.reversed.codexbar", + "buildNumber": "1", + "supportsTablet": true, + "config": { + "usesNonExemptEncryption": false + } }, "android": { "package": "dev.reversed.codexbar", + "versionCode": 1, "adaptiveIcon": { - "backgroundColor": "#E6F4FE", + "backgroundColor": "#090D11", "foregroundImage": "./assets/android-icon-foreground.png", "backgroundImage": "./assets/android-icon-background.png", "monochromeImage": "./assets/android-icon-monochrome.png" }, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": true }, "web": { + "bundler": "metro", + "output": "static", "favicon": "./assets/favicon.png" }, "plugins": [ "expo-router", - "expo-status-bar", - "expo-secure-store", + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon.png", + "imageWidth": 220, + "resizeMode": "contain", + "backgroundColor": "#090D11", + "dark": { + "image": "./assets/splash-icon.png", + "backgroundColor": "#090D11" + } + } + ], + [ + "expo-secure-store", + { + "configureAndroidBackup": true, + "faceIDPermission": "Allow Codexbar to access your securely stored credentials." + } + ], "expo-document-picker", - "expo-notifications" + [ + "expo-notifications", + { + "icon": "./assets/notification-icon.png", + "color": "#10A37F", + "defaultChannel": "usage-alerts" + } + ], + "expo-font" ], + "experiments": { + "typedRoutes": true + }, "extra": { "router": {}, "eas": { diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 6394ccd..8da326f 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -2,6 +2,7 @@ import { Tabs } from "expo-router"; import { useColorScheme } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; +import { COLORS } from "@/lib/constants"; export default function TabLayout() { const colorScheme = useColorScheme(); @@ -12,7 +13,7 @@ export default function TabLayout() { } > @@ -57,9 +61,9 @@ export default function ClaudeTab() { - + @@ -111,6 +115,11 @@ export default function ClaudeTab() { secureTextEntry className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-white dark:bg-neutral-800 mb-2" /> + {keyValidationError && ( + + {keyValidationError} + + )} Save Session Key @@ -162,11 +173,11 @@ export default function ClaudeTab() { )} - {status === "loading" && ( - + {status === "loading" && !usage && ( + )} {status === "error" && error && } - {status === "success" && usage && ( + {usage && ( )} + + + )} @@ -206,3 +220,11 @@ export default function ClaudeTab() { ); } + +export default function ClaudeTab() { + return ( + + + + ); +} diff --git a/app/(tabs)/codex.tsx b/app/(tabs)/codex.tsx index 9b6405f..be1e209 100644 --- a/app/(tabs)/codex.tsx +++ b/app/(tabs)/codex.tsx @@ -11,10 +11,22 @@ import { MaterialIcons } from "@expo/vector-icons"; import { useCodexUsage } from "@/hooks/useCodexUsage"; import { UsageStat } from "@/components/UsageStat"; import { ErrorMessage } from "@/components/ErrorMessage"; +import { LastUpdated } from "@/components/LastUpdated"; +import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary"; +import { COLORS } from "@/lib/constants"; import { windowLabel } from "@/lib/timeUtils"; -export default function CodexTab() { - const { auth, usage, status, error, importAuthFile, refresh, clearAuth } = +function CodexTabContent() { + const { + auth, + usage, + lastFetchedAt, + status, + error, + importAuthFile, + refresh, + clearAuth, + } = useCodexUsage(); return ( @@ -29,7 +41,7 @@ export default function CodexTab() { } > @@ -37,9 +49,9 @@ export default function CodexTab() { - + @@ -84,7 +96,7 @@ export default function CodexTab() { @@ -112,11 +124,11 @@ export default function CodexTab() { )} - {status === "loading" && ( - + {status === "loading" && !usage && ( + )} {status === "error" && error && } - {status === "success" && usage && ( + {usage && ( {/* Plan badge */} @@ -145,7 +157,7 @@ export default function CodexTab() { {usage.credits.unlimited ? ( - + Unlimited @@ -158,6 +170,9 @@ export default function CodexTab() { No credits )} + + + )} @@ -165,3 +180,11 @@ export default function CodexTab() { ); } + +export default function CodexTab() { + return ( + + + + ); +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 2172b71..3db092b 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -15,6 +15,9 @@ import { useClaudeUsage } from "@/hooks/useClaudeUsage"; import { onUsageDataLoaded } from "@/lib/notifications"; import { ProgressBar } from "@/components/ProgressBar"; import { ResetCountdown } from "@/components/ResetCountdown"; +import { LastUpdated } from "@/components/LastUpdated"; +import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary"; +import { COLORS, getUsageColor } from "@/lib/constants"; import { windowLabel } from "@/lib/timeUtils"; function UsageRow({ @@ -28,8 +31,7 @@ function UsageRow({ resetAtSeconds?: number; resetAtISO?: string; }) { - const color = - percent >= 90 ? "#ef4444" : percent >= 70 ? "#f59e0b" : "#10a37f"; + const color = getUsageColor(percent); return ( @@ -65,33 +67,33 @@ function SetupPrompt({ label }: { label: string }) { ); } -export default function DashboardTab() { +function DashboardTabContent() { const codex = useCodexUsage(); const claude = useClaudeUsage(); // Fire daily digest reschedule + threshold check whenever fresh data arrives useEffect(() => { - if (codex.status === "success" || claude.status === "success") { + if (codex.usage || claude.usage) { void onUsageDataLoaded( - claude.status === "success" ? claude.usage : null, - codex.status === "success" ? codex.usage : null + claude.usage, + codex.usage ); } - }, [codex.status, claude.status]); + }, [codex.lastFetchedAt, claude.lastFetchedAt]); useFocusEffect( useCallback(() => { - codex.reload(); - claude.reload(); - }, []) + void codex.reloadCredentials(); + void claude.reloadCredentials(); + }, [codex.reloadCredentials, claude.reloadCredentials]) ); const isRefreshing = codex.status === "loading" || claude.status === "loading"; const handleRefresh = () => { - codex.refresh(); - claude.refresh(); + void codex.refresh(); + void claude.refresh(); }; const connectedCount = [codex.auth, claude.auth].filter(Boolean).length; @@ -108,7 +110,7 @@ export default function DashboardTab() { } > @@ -135,21 +137,21 @@ export default function DashboardTab() { onPress={handleRefresh} className="w-9 h-9 rounded-xl bg-neutral-100 dark:bg-neutral-800 items-center justify-center" > - + {/* Codex card */} - + - + Codex CLI @@ -175,9 +177,9 @@ export default function DashboardTab() { {!codex.auth && ( )} - {codex.auth && codex.status === "loading" && ( + {codex.auth && codex.status === "loading" && !codex.usage && ( )} @@ -189,7 +191,7 @@ export default function DashboardTab() { )} - {codex.status === "success" && codex.usage && ( + {codex.usage && ( Unlimited credits )} + + + )} @@ -234,15 +239,15 @@ export default function DashboardTab() { {/* Claude card */} - + - + Claude.ai @@ -259,9 +264,9 @@ export default function DashboardTab() { {!claude.auth && ( )} - {claude.auth && claude.status === "loading" && ( + {claude.auth && claude.status === "loading" && !claude.usage && ( )} @@ -273,7 +278,7 @@ export default function DashboardTab() { )} - {claude.status === "success" && claude.usage && ( + {claude.usage && ( )} + + + )} @@ -313,3 +321,11 @@ export default function DashboardTab() { ); } + +export default function DashboardTab() { + return ( + + + + ); +} diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 1d029a8..8aae580 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -8,7 +8,6 @@ import { TextInput, Switch, KeyboardAvoidingView, - Platform, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { router, useFocusEffect } from "expo-router"; @@ -26,22 +25,16 @@ import { import { pickAndReadCodexAuth } from "@/lib/fileReader"; import { resetOnboarding } from "@/lib/setupState"; import { useNotificationSettings } from "@/hooks/useNotificationSettings"; +import { + isClaudeSessionKeyValid, + validateClaudeSessionKey, +} from "@/lib/claudeCredentials"; +import { COLORS } from "@/lib/constants"; +import { formatTime, parseTimeInput } from "@/lib/timeUtils"; +import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary"; import type { CodexAuth } from "@/types/codex"; import Constants from "expo-constants"; -function parseTimeInput(s: string): { hour: number; minute: number } | null { - const m = s.trim().match(/^(\d{1,2}):(\d{2})$/); - if (!m) return null; - const h = parseInt(m[1], 10); - const min = parseInt(m[2], 10); - if (h < 0 || h > 23 || min < 0 || min > 59) return null; - return { hour: h, minute: min }; -} - -function formatTime(hour: number, minute: number): string { - return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; -} - function SettingRow({ icon, label, @@ -120,7 +113,7 @@ function Divider() { return ; } -export default function SettingsTab() { +function SettingsTabContent() { const [codexAuth, setCodexAuth] = useState(null); const [claudeKey, setClaudeKey] = useState(null); const [pendingKey, setPendingKey] = useState(""); @@ -129,6 +122,7 @@ export default function SettingsTab() { const notif = useNotificationSettings(); const [timeInput, setTimeInput] = useState(""); + const [timeError, setTimeError] = useState(null); const [thresholdInput, setThresholdInput] = useState(""); // Sync local text inputs when settings load @@ -173,8 +167,9 @@ export default function SettingsTab() { const handleSaveClaude = async () => { const trimmed = pendingKey.trim(); - if (!trimmed.startsWith("sk-ant-")) { - setClaudeError("Key must start with sk-ant-"); + const validationError = validateClaudeSessionKey(trimmed); + if (validationError) { + setClaudeError(validationError); return; } const orgTrimmed = pendingOrg.trim() || undefined; @@ -249,9 +244,10 @@ export default function SettingsTab() { const parsed = parseTimeInput(timeInput); if (parsed) { void notif.update({ dailyHour: parsed.hour, dailyMinute: parsed.minute }); + setTimeInput(formatTime(parsed.hour, parsed.minute)); + setTimeError(null); } else { - // Reset to last valid value - setTimeInput(formatTime(notif.settings.dailyHour, notif.settings.dailyMinute)); + setTimeError("Use HH:MM format, from 00:00 to 23:59."); } }, [timeInput, notif]); @@ -264,7 +260,9 @@ export default function SettingsTab() { } }, [thresholdInput, notif]); - const canSaveClaude = pendingKey.trim().startsWith("sk-ant-"); + const canSaveClaude = isClaudeSessionKeyValid(pendingKey); + const inlineClaudeError = + claudeError ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null); const appVersion = Constants.expoConfig?.version ?? "1.0.0"; return ( @@ -274,7 +272,7 @@ export default function SettingsTab() { > ) : ( - {claudeError && ( - - {claudeError} + {inlineClaudeError && ( + + {inlineClaudeError} )} Save Session Key @@ -446,7 +446,7 @@ export default function SettingsTab() { void notif.update({ dailyEnabled: v })} - trackColor={{ false: "#e5e5e5", true: "#10a37f" }} + trackColor={{ false: COLORS.disabled, true: COLORS.codex }} thumbColor="white" /> @@ -454,25 +454,35 @@ export default function SettingsTab() { {notif.settings.dailyEnabled && ( <> - - - + + + + + + + Time + + { + setTimeInput(value); + setTimeError(null); + }} + onBlur={handleTimeBlur} + placeholder="09:00" + placeholderTextColor={COLORS.muted} + keyboardType="numbers-and-punctuation" + returnKeyType="done" + maxLength={5} + className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right" + style={{ minWidth: 52 }} + /> - - Time - - + {timeError && ( + + {timeError} + + )} )} @@ -495,7 +505,7 @@ export default function SettingsTab() { void notif.update({ thresholdEnabled: v })} - trackColor={{ false: "#e5e5e5", true: "#d97706" }} + trackColor={{ false: COLORS.disabled, true: COLORS.claude }} thumbColor="white" /> @@ -554,3 +564,11 @@ export default function SettingsTab() { ); } + +export default function SettingsTab() { + return ( + + + + ); +} diff --git a/app/index.tsx b/app/index.tsx index facf87e..9d8b5e9 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { View, ActivityIndicator } from "react-native"; import { router } from "expo-router"; import { hasCompletedOnboarding } from "@/lib/setupState"; +import { COLORS } from "@/lib/constants"; export default function Index() { useEffect(() => { @@ -12,7 +13,7 @@ export default function Index() { return ( - + ); } diff --git a/app/onboarding/claude.tsx b/app/onboarding/claude.tsx index 04101a8..4eaabb3 100644 --- a/app/onboarding/claude.tsx +++ b/app/onboarding/claude.tsx @@ -5,7 +5,6 @@ import { TouchableOpacity, TextInput, KeyboardAvoidingView, - Platform, ScrollView, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -18,6 +17,11 @@ import { loadClaudeSessionKey, loadClaudeLastActiveOrg, } from "@/lib/storage"; +import { + isClaudeSessionKeyValid, + validateClaudeSessionKey, +} from "@/lib/claudeCredentials"; +import { COLORS } from "@/lib/constants"; export default function OnboardingClaudeScreen() { const [savedKey, setSavedKey] = useState(null); @@ -35,12 +39,15 @@ export default function OnboardingClaudeScreen() { ); }, []); - const canSave = pendingKey.trim().startsWith("sk-ant-"); + const canSave = isClaudeSessionKeyValid(pendingKey); + const inlineError = + error ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null); const handleSave = useCallback(async () => { const trimmed = pendingKey.trim(); - if (!trimmed.startsWith("sk-ant-")) { - setError("Session key must start with sk-ant-"); + const validationError = validateClaudeSessionKey(trimmed); + if (validationError) { + setError(validationError); return; } const orgTrimmed = pendingOrg.trim() || undefined; @@ -58,7 +65,7 @@ export default function OnboardingClaudeScreen() { - + 2 @@ -96,9 +103,9 @@ export default function OnboardingClaudeScreen() { - + Connect Claude.ai @@ -112,9 +119,9 @@ export default function OnboardingClaudeScreen() { - + Connected @@ -143,9 +150,9 @@ export default function OnboardingClaudeScreen() { ) : ( - {error && ( + {inlineError && ( - {error} + {inlineError} )} router.push("/onboarding/done")} className="rounded-2xl py-4 items-center" - style={{ backgroundColor: savedKey ? "#d97706" : "#1a1a1a" }} + style={{ + backgroundColor: savedKey ? COLORS.claude : "#1a1a1a", + }} > (null); @@ -45,7 +46,7 @@ export default function OnboardingCodexScreen() { - + 1 @@ -60,9 +61,9 @@ export default function OnboardingCodexScreen() { - + Connect Codex CLI @@ -80,9 +81,9 @@ export default function OnboardingCodexScreen() { - + Connected @@ -107,10 +108,10 @@ export default function OnboardingCodexScreen() { onPress={importFile} disabled={loading} className="rounded-2xl py-4 items-center mb-3" - style={{ backgroundColor: loading ? "#1a1a1a" : "#10a37f" }} + style={{ backgroundColor: loading ? "#1a1a1a" : COLORS.codex }} > {loading ? ( - + ) : ( @@ -132,7 +133,7 @@ export default function OnboardingCodexScreen() { router.push("/onboarding/claude")} className="rounded-2xl py-4 items-center" - style={{ backgroundColor: auth ? "#10a37f" : "#1a1a1a" }} + style={{ backgroundColor: auth ? COLORS.codex : "#1a1a1a" }} > @@ -57,13 +58,15 @@ export default function OnboardingDoneScreen() { @@ -83,13 +86,15 @@ export default function OnboardingDoneScreen() { @@ -110,7 +115,7 @@ export default function OnboardingDoneScreen() { Open App diff --git a/app/onboarding/index.tsx b/app/onboarding/index.tsx index 51f4321..462dd4d 100644 --- a/app/onboarding/index.tsx +++ b/app/onboarding/index.tsx @@ -2,6 +2,7 @@ import { View, Text, TouchableOpacity } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { router } from "expo-router"; import { MaterialIcons } from "@expo/vector-icons"; +import { COLORS } from "@/lib/constants"; export default function WelcomeScreen() { return ( @@ -12,13 +13,13 @@ export default function WelcomeScreen() { @@ -36,9 +37,9 @@ export default function WelcomeScreen() { - + Codex CLI @@ -50,9 +51,9 @@ export default function WelcomeScreen() { - + Claude.ai @@ -68,7 +69,7 @@ export default function WelcomeScreen() { router.push("/onboarding/codex")} className="rounded-2xl py-4 items-center" - style={{ backgroundColor: "#10a37f" }} + style={{ backgroundColor: COLORS.codex }} > Get Started diff --git a/assets/android-icon-background.png b/assets/android-icon-background.png index 5ffefc5..8f0d024 100644 Binary files a/assets/android-icon-background.png and b/assets/android-icon-background.png differ diff --git a/assets/android-icon-foreground.png b/assets/android-icon-foreground.png index 3a9e501..4aa3c05 100644 Binary files a/assets/android-icon-foreground.png and b/assets/android-icon-foreground.png differ diff --git a/assets/android-icon-monochrome.png b/assets/android-icon-monochrome.png index 77484eb..ef4a0dd 100644 Binary files a/assets/android-icon-monochrome.png and b/assets/android-icon-monochrome.png differ diff --git a/assets/favicon.png b/assets/favicon.png index 408bd74..7bb5cd6 100644 Binary files a/assets/favicon.png and b/assets/favicon.png differ diff --git a/assets/icon.png b/assets/icon.png index 7165a53..0fac318 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/notification-icon.png b/assets/notification-icon.png new file mode 100644 index 0000000..a4a6585 Binary files /dev/null and b/assets/notification-icon.png differ diff --git a/assets/splash-icon.png b/assets/splash-icon.png index 03d6f6b..0fac318 100644 Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ diff --git a/components/ErrorMessage.tsx b/components/ErrorMessage.tsx index a5e57e5..94d3564 100644 --- a/components/ErrorMessage.tsx +++ b/components/ErrorMessage.tsx @@ -5,6 +5,12 @@ const ERROR_MESSAGES: Record = { MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.", INVALID_JSON: "The selected file is not valid JSON.", NO_ORGS_FOUND: "No Claude organizations found for this session key.", + INVALID_CODEX_RESPONSE: + "Codex returned an unexpected response. The app was kept safe from invalid data.", + INVALID_CLAUDE_RESPONSE: + "Claude returned an unexpected usage response. The app was kept safe from invalid data.", + INVALID_CLAUDE_ORGS_RESPONSE: + "Claude returned an unexpected organizations response.", }; function humanize(code: string): string { diff --git a/components/LastUpdated.tsx b/components/LastUpdated.tsx new file mode 100644 index 0000000..2b3c553 --- /dev/null +++ b/components/LastUpdated.tsx @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { Text } from "react-native"; +import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants"; +import { formatLastUpdated } from "@/lib/timeUtils"; + +export function LastUpdated({ timestamp }: { timestamp: number | null }) { + const [now, setNow] = useState(Date.now); + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), COUNTDOWN_INTERVAL_MS); + return () => clearInterval(interval); + }, []); + + if (!timestamp) return null; + + return ( + + {formatLastUpdated(timestamp, now)} + + ); +} diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx index 456d9f0..ea5e709 100644 --- a/components/ProgressBar.tsx +++ b/components/ProgressBar.tsx @@ -1,12 +1,13 @@ import { View } from "react-native"; +import { PROGRESS_THRESHOLDS } from "@/lib/constants"; interface ProgressBarProps { percent: number; } function getBarColor(percent: number): string { - if (percent >= 85) return "bg-red-500"; - if (percent >= 60) return "bg-yellow-400"; + if (percent >= PROGRESS_THRESHOLDS.danger) return "bg-red-500"; + if (percent >= PROGRESS_THRESHOLDS.warning) return "bg-yellow-400"; return "bg-green-500"; } diff --git a/components/ResetCountdown.tsx b/components/ResetCountdown.tsx index fcf1875..1c2f04f 100644 --- a/components/ResetCountdown.tsx +++ b/components/ResetCountdown.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Text } from "react-native"; +import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants"; import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils"; interface ResetCountdownProps { @@ -8,18 +9,28 @@ interface ResetCountdownProps { } export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) { + const latest = useRef({ resetAtSeconds, resetAtISO }); + latest.current = { resetAtSeconds, resetAtISO }; + const getLabel = () => { - if (resetAtSeconds !== undefined) return formatResetCountdown(resetAtSeconds); - if (resetAtISO) return formatResetCountdownISO(resetAtISO); + if (latest.current.resetAtSeconds !== undefined) { + return formatResetCountdown(latest.current.resetAtSeconds); + } + if (latest.current.resetAtISO) return formatResetCountdownISO(latest.current.resetAtISO); return ""; }; const [label, setLabel] = useState(getLabel); useEffect(() => { - setLabel(getLabel()); - const interval = setInterval(() => setLabel(getLabel()), 60_000); + const update = () => setLabel(getLabel()); + update(); + const interval = setInterval(update, COUNTDOWN_INTERVAL_MS); return () => clearInterval(interval); + }, []); + + useEffect(() => { + setLabel(getLabel()); }, [resetAtSeconds, resetAtISO]); if (!label) return null; diff --git a/components/ScreenErrorBoundary.tsx b/components/ScreenErrorBoundary.tsx new file mode 100644 index 0000000..d6be1c7 --- /dev/null +++ b/components/ScreenErrorBoundary.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { COLORS } from "@/lib/constants"; + +interface Props { + children: React.ReactNode; + screenName: string; +} + +interface State { + error: Error | null; +} + +export class ScreenErrorBoundary extends React.Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + render() { + if (!this.state.error) return this.props.children; + + return ( + + + {this.props.screenName} hit a snag + + + This screen could not be rendered. The rest of Codexbar is still available. + + this.setState({ error: null })} + className="mt-5 rounded-xl px-4 py-3" + style={{ backgroundColor: COLORS.codex }} + > + Try again + + + ); + } +} diff --git a/hooks/useClaudeUsage.ts b/hooks/useClaudeUsage.ts index 23c8e49..02f905b 100644 --- a/hooks/useClaudeUsage.ts +++ b/hooks/useClaudeUsage.ts @@ -1,47 +1,52 @@ -import { useState, useCallback, useEffect } from "react"; -import { fetchClaudeOrgs, fetchClaudeUsage } from "@/lib/api/claudeApi"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + fetchClaudeOrgs, + fetchClaudeUsage, + parseClaudeUsageResponse, +} from "@/lib/api/claudeApi"; +import { + isClaudeSessionKeyValid, + validateClaudeSessionKey, +} from "@/lib/claudeCredentials"; +import { RETRY_DELAY_MS } from "@/lib/constants"; import { - loadClaudeSessionKey, - saveClaudeSessionKey, - clearClaudeSessionKey, - loadClaudeLastActiveOrg, - saveClaudeLastActiveOrg, clearClaudeLastActiveOrg, + clearClaudeSessionKey, + clearClaudeUsageCache, + loadClaudeLastActiveOrg, + loadClaudeSessionKey, + loadClaudeUsageCache, + saveClaudeLastActiveOrg, + saveClaudeSessionKey, + saveClaudeUsageCache, } from "@/lib/storage"; import type { ClaudeAuth, ClaudeUsageResponse } from "@/types/claude"; type Status = "idle" | "loading" | "success" | "error"; -async function doFetch( - auth: ClaudeAuth, - set: { - auth: (v: ClaudeAuth | null) => void; - usage: (v: ClaudeUsageResponse | null) => void; - status: (v: Status) => void; - error: (v: string | null) => void; - } -) { - set.status("loading"); - set.error(null); - try { - let orgUuid = auth.lastActiveOrg; - if (!orgUuid) { - const orgs = await fetchClaudeOrgs(auth); - if (!orgs.length) throw new Error("NO_ORGS_FOUND"); - orgUuid = orgs[0].uuid; - } - const data = await fetchClaudeUsage(auth, orgUuid); - set.usage(data); - set.status("success"); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR"; - set.error(msg); - set.status("error"); - if (msg === "TOKEN_EXPIRED") { - await clearClaudeSessionKey(); - set.auth(null); - } +function shouldRetry(error: unknown): boolean { + const message = error instanceof Error ? error.message : ""; + return ![ + "TOKEN_EXPIRED", + "NO_ORGS_FOUND", + "INVALID_CLAUDE_RESPONSE", + "INVALID_CLAUDE_ORGS_RESPONSE", + ].includes(message); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function requestClaudeUsage(auth: ClaudeAuth): Promise { + let orgUuid = auth.lastActiveOrg?.trim(); + if (!orgUuid) { + const orgs = await fetchClaudeOrgs(auth); + if (!orgs.length) throw new Error("NO_ORGS_FOUND"); + orgUuid = orgs[0].uuid; + await saveClaudeLastActiveOrg(orgUuid); } + return fetchClaudeUsage(auth, orgUuid); } export function useClaudeUsage() { @@ -49,60 +54,171 @@ export function useClaudeUsage() { const [pendingKey, setPendingKey] = useState(""); const [pendingOrg, setPendingOrg] = useState(""); const [usage, setUsage] = useState(null); + const [lastFetchedAt, setLastFetchedAt] = useState(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); - - const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError }; + const mounted = useRef(true); + const inFlight = useRef<{ + key: string; + promise: Promise; + } | null>(null); + const requestVersion = useRef(0); useEffect(() => { - Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]).then( - ([key, org]) => { - if (key) setAuth({ sessionKey: key, lastActiveOrg: org ?? undefined }); + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const fetchUsage = useCallback((credentials: ClaudeAuth): Promise => { + const sanitized = { + sessionKey: credentials.sessionKey.trim(), + lastActiveOrg: credentials.lastActiveOrg?.trim() || undefined, + }; + const requestKey = `${sanitized.lastActiveOrg ?? ""}:${sanitized.sessionKey}`; + if (inFlight.current?.key === requestKey) return inFlight.current.promise; + const version = ++requestVersion.current; + + const promise = (async () => { + if (mounted.current) { + setStatus("loading"); + setError(null); } - ); + + try { + let data: ClaudeUsageResponse; + try { + data = await requestClaudeUsage(sanitized); + } catch (firstError) { + if (!shouldRetry(firstError)) throw firstError; + await wait(RETRY_DELAY_MS); + if (version !== requestVersion.current) return; + data = await requestClaudeUsage(sanitized); + } + + if (version !== requestVersion.current) return; + const fetchedAt = Date.now(); + await saveClaudeUsageCache({ data, lastFetchedAt: fetchedAt }); + if (mounted.current && version === requestVersion.current) { + setUsage(data); + setLastFetchedAt(fetchedAt); + setStatus("success"); + } + } catch (caught: unknown) { + if (version !== requestVersion.current) return; + const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR"; + if (message === "TOKEN_EXPIRED") { + await clearClaudeSessionKey(); + if (mounted.current) { + setAuth(null); + setUsage(null); + setLastFetchedAt(null); + } + } + if (mounted.current) { + setError(message); + setStatus("error"); + } + } + })().finally(() => { + if (inFlight.current?.promise === promise) inFlight.current = null; + }); + + inFlight.current = { key: requestKey, promise }; + return promise; }, []); useEffect(() => { - if (auth) void doFetch(auth, setters); - }, [auth]); + let active = true; + + void Promise.all([ + loadClaudeSessionKey(), + loadClaudeLastActiveOrg(), + loadClaudeUsageCache(), + ]).then(async ([key, org, cache]) => { + if (!active) return; + + const storedAuth = key + ? { sessionKey: key.trim(), lastActiveOrg: org?.trim() || undefined } + : null; + + if (storedAuth && cache) { + try { + if (!Number.isFinite(cache.lastFetchedAt)) { + throw new Error("INVALID_CACHE_TIMESTAMP"); + } + const cachedUsage = parseClaudeUsageResponse(cache.data); + setUsage(cachedUsage); + setLastFetchedAt(cache.lastFetchedAt); + setStatus("success"); + } catch { + await clearClaudeUsageCache(); + } + } + + setAuth(storedAuth); + if (storedAuth) await fetchUsage(storedAuth); + }); + + return () => { + active = false; + }; + }, [fetchUsage]); const saveKey = useCallback(async () => { - const trimmed = pendingKey.trim(); - if (!trimmed.startsWith("sk-ant-")) { - setError("Session key must start with sk-ant-"); + const trimmedKey = pendingKey.trim(); + const validationError = validateClaudeSessionKey(trimmedKey); + if (validationError) { + setError(validationError); return; } - const orgTrimmed = pendingOrg.trim() || undefined; - await saveClaudeSessionKey(trimmed); - if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed); + + const trimmedOrg = pendingOrg.trim() || undefined; + await saveClaudeSessionKey(trimmedKey); + if (trimmedOrg) await saveClaudeLastActiveOrg(trimmedOrg); else await clearClaudeLastActiveOrg(); - setAuth({ sessionKey: trimmed, lastActiveOrg: orgTrimmed }); + + const nextAuth = { sessionKey: trimmedKey, lastActiveOrg: trimmedOrg }; + setAuth(nextAuth); setPendingKey(""); setPendingOrg(""); setError(null); - }, [pendingKey, pendingOrg]); + await fetchUsage(nextAuth); + }, [fetchUsage, pendingKey, pendingOrg]); const refresh = useCallback(async () => { - if (!auth) return; - await doFetch(auth, setters); - }, [auth]); + if (auth) await fetchUsage(auth); + }, [auth, fetchUsage]); - const reload = useCallback(async () => { - const [key, org] = await Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]); + const reloadCredentials = useCallback(async () => { + const [key, org] = await Promise.all([ + loadClaudeSessionKey(), + loadClaudeLastActiveOrg(), + ]); if (!key) { + requestVersion.current += 1; setAuth(null); setUsage(null); + setLastFetchedAt(null); setStatus("idle"); return; } - // Creating a new object always triggers the [auth] useEffect even if values are identical - setAuth({ sessionKey: key, lastActiveOrg: org ?? undefined }); - }, []); + + const stored = { + sessionKey: key.trim(), + lastActiveOrg: org?.trim() || undefined, + }; + setAuth(stored); + await fetchUsage(stored); + }, [fetchUsage]); const clearKey = useCallback(async () => { + requestVersion.current += 1; await clearClaudeSessionKey(); setAuth(null); setUsage(null); + setLastFetchedAt(null); setStatus("idle"); setError(null); }, []); @@ -114,11 +230,16 @@ export function useClaudeUsage() { pendingOrg, setPendingOrg, usage, + lastFetchedAt, status, error, + keyValidationError: pendingKey + ? validateClaudeSessionKey(pendingKey) + : null, + canSaveKey: isClaudeSessionKeyValid(pendingKey), saveKey, refresh, - reload, + reloadCredentials, clearKey, }; } diff --git a/hooks/useCodexUsage.ts b/hooks/useCodexUsage.ts index 87079a1..9644ae7 100644 --- a/hooks/useCodexUsage.ts +++ b/hooks/useCodexUsage.ts @@ -1,54 +1,138 @@ -import { useState, useCallback, useEffect } from "react"; -import { fetchCodexUsage } from "@/lib/api/codexApi"; -import { loadCodexAuth, saveCodexAuth, clearCodexAuth } from "@/lib/storage"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + fetchCodexUsage, + parseCodexUsageResponse, +} from "@/lib/api/codexApi"; +import { RETRY_DELAY_MS } from "@/lib/constants"; import { pickAndReadCodexAuth } from "@/lib/fileReader"; -import type { CodexUsageResponse, CodexAuth } from "@/types/codex"; +import { + clearCodexAuth, + clearCodexUsageCache, + loadCodexAuth, + loadCodexUsageCache, + saveCodexAuth, + saveCodexUsageCache, +} from "@/lib/storage"; +import type { CodexAuth, CodexUsageResponse } from "@/types/codex"; type Status = "idle" | "loading" | "success" | "error"; -async function doFetch( - auth: CodexAuth, - set: { - auth: (v: CodexAuth | null) => void; - usage: (v: CodexUsageResponse | null) => void; - status: (v: Status) => void; - error: (v: string | null) => void; - } -) { - set.status("loading"); - set.error(null); - try { - const data = await fetchCodexUsage(auth); - set.usage(data); - set.status("success"); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR"; - set.error(msg); - set.status("error"); - if (msg === "TOKEN_EXPIRED") { - await clearCodexAuth(); - set.auth(null); - } - } +function shouldRetry(error: unknown): boolean { + const message = error instanceof Error ? error.message : ""; + return ( + message !== "TOKEN_EXPIRED" && + message !== "INVALID_CODEX_RESPONSE" + ); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } export function useCodexUsage() { const [auth, setAuth] = useState(null); const [usage, setUsage] = useState(null); + const [lastFetchedAt, setLastFetchedAt] = useState(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); - - const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError }; + const mounted = useRef(true); + const inFlight = useRef<{ + key: string; + promise: Promise; + } | null>(null); + const requestVersion = useRef(0); useEffect(() => { - loadCodexAuth().then((stored) => { - if (stored) setAuth(stored); + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const fetchUsage = useCallback((credentials: CodexAuth): Promise => { + const requestKey = `${credentials.accountId ?? ""}:${credentials.accessToken}`; + if (inFlight.current?.key === requestKey) return inFlight.current.promise; + const version = ++requestVersion.current; + + const promise = (async () => { + if (mounted.current) { + setStatus("loading"); + setError(null); + } + + try { + let data: CodexUsageResponse; + try { + data = await fetchCodexUsage(credentials); + } catch (firstError) { + if (!shouldRetry(firstError)) throw firstError; + await wait(RETRY_DELAY_MS); + if (version !== requestVersion.current) return; + data = await fetchCodexUsage(credentials); + } + + if (version !== requestVersion.current) return; + const fetchedAt = Date.now(); + await saveCodexUsageCache({ data, lastFetchedAt: fetchedAt }); + if (mounted.current && version === requestVersion.current) { + setUsage(data); + setLastFetchedAt(fetchedAt); + setStatus("success"); + } + } catch (caught: unknown) { + if (version !== requestVersion.current) return; + const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR"; + if (message === "TOKEN_EXPIRED") { + await clearCodexAuth(); + if (mounted.current) { + setAuth(null); + setUsage(null); + setLastFetchedAt(null); + } + } + if (mounted.current) { + setError(message); + setStatus("error"); + } + } + })().finally(() => { + if (inFlight.current?.promise === promise) inFlight.current = null; }); + + inFlight.current = { key: requestKey, promise }; + return promise; }, []); useEffect(() => { - if (auth) void doFetch(auth, setters); - }, [auth]); + let active = true; + + void Promise.all([loadCodexAuth(), loadCodexUsageCache()]).then( + async ([storedAuth, cache]) => { + if (!active) return; + + if (storedAuth && cache) { + try { + if (!Number.isFinite(cache.lastFetchedAt)) { + throw new Error("INVALID_CACHE_TIMESTAMP"); + } + const cachedUsage = parseCodexUsageResponse(cache.data); + setUsage(cachedUsage); + setLastFetchedAt(cache.lastFetchedAt); + setStatus("success"); + } catch { + await clearCodexUsageCache(); + } + } + + setAuth(storedAuth); + if (storedAuth) await fetchUsage(storedAuth); + } + ); + + return () => { + active = false; + }; + }, [fetchUsage]); const importAuthFile = useCallback(async () => { try { @@ -56,39 +140,50 @@ export function useCodexUsage() { await saveCodexAuth(parsed); setAuth(parsed); setError(null); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR"; - if (msg !== "PICKER_CANCELLED") setError(msg); + await fetchUsage(parsed); + } catch (caught: unknown) { + const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR"; + if (message !== "PICKER_CANCELLED") setError(message); } - }, []); + }, [fetchUsage]); const refresh = useCallback(async () => { - if (!auth) return; - await doFetch(auth, setters); - }, [auth]); + if (auth) await fetchUsage(auth); + }, [auth, fetchUsage]); - // Re-reads auth from storage and fetches fresh data. Call from useFocusEffect - // so the dashboard stays current when credentials are saved from another tab. - const reload = useCallback(async () => { + const reloadCredentials = useCallback(async () => { const stored = await loadCodexAuth(); if (!stored) { + requestVersion.current += 1; setAuth(null); setUsage(null); + setLastFetchedAt(null); setStatus("idle"); return; } - // Always pass a fresh object so the [auth] effect fires even if the content - // is identical (JSON.parse returns a new reference each time, which is fine). setAuth(stored); - }, []); + await fetchUsage(stored); + }, [fetchUsage]); const clearAuth = useCallback(async () => { + requestVersion.current += 1; await clearCodexAuth(); setAuth(null); setUsage(null); + setLastFetchedAt(null); setStatus("idle"); setError(null); }, []); - return { auth, usage, status, error, importAuthFile, refresh, reload, clearAuth }; + return { + auth, + usage, + lastFetchedAt, + status, + error, + importAuthFile, + refresh, + reloadCredentials, + clearAuth, + }; } diff --git a/lib/api/claudeApi.ts b/lib/api/claudeApi.ts index 37201b8..a89bce6 100644 --- a/lib/api/claudeApi.ts +++ b/lib/api/claudeApi.ts @@ -19,6 +19,78 @@ function buildHeaders(auth: ClaudeAuth): Record { }; } +function isClaudeWindow(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const window = value as Record; + return ( + typeof window.utilization === "number" && + Number.isFinite(window.utilization) && + (window.resets_at === undefined || + window.resets_at === null || + typeof window.resets_at === "string") + ); +} + +export function parseClaudeOrgsResponse(data: unknown): ClaudeOrg[] { + if ( + !Array.isArray(data) || + data.some( + (org) => + !org || + typeof org !== "object" || + typeof (org as Record).uuid !== "string" || + typeof (org as Record).name !== "string" + ) + ) { + throw new Error("INVALID_CLAUDE_ORGS_RESPONSE"); + } + return data.map((org) => { + const value = org as Record; + return { uuid: value.uuid as string, name: value.name as string }; + }); +} + +export function parseClaudeUsageResponse(data: unknown): ClaudeUsageResponse { + if (!data || typeof data !== "object") { + throw new Error("INVALID_CLAUDE_RESPONSE"); + } + + const value = data as Record; + if ( + !isClaudeWindow(value.five_hour) || + !isClaudeWindow(value.seven_day) || + (value.seven_day_sonnet !== undefined && + value.seven_day_sonnet !== null && + !isClaudeWindow(value.seven_day_sonnet)) || + (value.seven_day_opus !== undefined && + value.seven_day_opus !== null && + !isClaudeWindow(value.seven_day_opus)) + ) { + throw new Error("INVALID_CLAUDE_RESPONSE"); + } + + const toWindow = (window: unknown) => { + const item = window as Record; + return { + utilization: item.utilization as number, + ...(typeof item.resets_at === "string" + ? { resets_at: item.resets_at } + : {}), + }; + }; + + return { + five_hour: toWindow(value.five_hour), + seven_day: toWindow(value.seven_day), + ...(value.seven_day_sonnet + ? { seven_day_sonnet: toWindow(value.seven_day_sonnet) } + : {}), + ...(value.seven_day_opus + ? { seven_day_opus: toWindow(value.seven_day_opus) } + : {}), + }; +} + export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise { const response = await fetch(`${BASE_URL}/organizations`, { headers: buildHeaders(auth), @@ -31,7 +103,8 @@ export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise { throw new Error(`HTTP_ERROR_${response.status}`); } - return response.json() as Promise; + const data: unknown = await response.json(); + return parseClaudeOrgsResponse(data); } export async function fetchClaudeUsage( @@ -49,5 +122,6 @@ export async function fetchClaudeUsage( throw new Error(`HTTP_ERROR_${response.status}`); } - return response.json() as Promise; + const data: unknown = await response.json(); + return parseClaudeUsageResponse(data); } diff --git a/lib/api/codexApi.ts b/lib/api/codexApi.ts index 0fa1731..79ef483 100644 --- a/lib/api/codexApi.ts +++ b/lib/api/codexApi.ts @@ -2,6 +2,64 @@ import type { CodexAuth, CodexUsageResponse } from "@/types/codex"; const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; +function isNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function parseCodexUsageResponse(data: unknown): CodexUsageResponse { + if (!data || typeof data !== "object") { + throw new Error("INVALID_CODEX_RESPONSE"); + } + + const value = data as Record; + const rateLimit = value.rate_limit as Record | undefined; + const primary = rateLimit?.primary_window as Record | undefined; + const secondary = rateLimit?.secondary_window as Record | undefined; + const credits = value.credits as Record | undefined; + + const validWindow = (window: Record | undefined) => + !!window && + isNumber(window.used_percent) && + isNumber(window.reset_at) && + isNumber(window.limit_window_seconds); + + if ( + typeof value.plan_type !== "string" || + !validWindow(primary) || + !validWindow(secondary) || + !credits || + typeof credits.has_credits !== "boolean" || + typeof credits.unlimited !== "boolean" || + !isNumber(credits.balance) + ) { + throw new Error("INVALID_CODEX_RESPONSE"); + } + + const primaryWindow = primary as Record; + const secondaryWindow = secondary as Record; + + return { + plan_type: value.plan_type, + rate_limit: { + primary_window: { + used_percent: primaryWindow.used_percent as number, + reset_at: primaryWindow.reset_at as number, + limit_window_seconds: primaryWindow.limit_window_seconds as number, + }, + secondary_window: { + used_percent: secondaryWindow.used_percent as number, + reset_at: secondaryWindow.reset_at as number, + limit_window_seconds: secondaryWindow.limit_window_seconds as number, + }, + }, + credits: { + has_credits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance, + }, + }; +} + export async function fetchCodexUsage(auth: CodexAuth): Promise { const headers: Record = { Authorization: `Bearer ${auth.accessToken}`, @@ -22,5 +80,6 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise; + const data: unknown = await response.json(); + return parseCodexUsageResponse(data); } diff --git a/lib/claudeCredentials.ts b/lib/claudeCredentials.ts new file mode 100644 index 0000000..46da57f --- /dev/null +++ b/lib/claudeCredentials.ts @@ -0,0 +1,19 @@ +const CLAUDE_SESSION_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]+$/; + +export function validateClaudeSessionKey(value: string): string | null { + const key = value.trim(); + if (!key.startsWith("sk-ant-")) { + return "Session key must start with sk-ant-"; + } + if (key.length <= 40) { + return "Session key looks too short. Paste the complete cookie value."; + } + if (!CLAUDE_SESSION_KEY_PATTERN.test(key)) { + return "Session key contains unexpected characters. Check the pasted value."; + } + return null; +} + +export function isClaudeSessionKeyValid(value: string): boolean { + return validateClaudeSessionKey(value) === null; +} diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 0000000..2ca2a64 --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,23 @@ +export const COLORS = { + codex: "#10a37f", + claude: "#d97706", + danger: "#ef4444", + warning: "#f59e0b", + disabled: "#e5e5e5", + muted: "#a3a3a3", + appBackground: "#090d11", +} as const; + +export const PROGRESS_THRESHOLDS = { + warning: 60, + danger: 85, +} as const; + +export const COUNTDOWN_INTERVAL_MS = 60_000; +export const RETRY_DELAY_MS = 5_000; + +export function getUsageColor(percent: number): string { + if (percent >= PROGRESS_THRESHOLDS.danger) return COLORS.danger; + if (percent >= PROGRESS_THRESHOLDS.warning) return COLORS.warning; + return COLORS.codex; +} diff --git a/lib/storage.ts b/lib/storage.ts index 2a603df..31f6f14 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,9 +1,13 @@ import * as SecureStore from "expo-secure-store"; +import type { ClaudeUsageResponse } from "@/types/claude"; +import type { CodexUsageResponse } from "@/types/codex"; const KEYS = { CODEX_AUTH: "codexbar_codex_auth", CLAUDE_SESSION_KEY: "codexbar_claude_session_key", CLAUDE_LAST_ACTIVE_ORG: "codexbar_claude_last_active_org", + CODEX_USAGE_CACHE: "codexbar_codex_usage_cache", + CLAUDE_USAGE_CACHE: "codexbar_claude_usage_cache", NOTIF_DAILY_ENABLED: "notifDailyEnabled", NOTIF_DAILY_HOUR: "notifDailyHour", NOTIF_DAILY_MINUTE: "notifDailyMinute", @@ -12,6 +16,27 @@ const KEYS = { NOTIF_THRESHOLD_LAST_FIRED: "notifThresholdLastFired", } as const; +export interface UsageCache { + data: T; + lastFetchedAt: number; +} + +async function loadJson(key: string): Promise { + const raw = await SecureStore.getItemAsync(key); + if (!raw) return null; + + try { + return JSON.parse(raw) as T; + } catch { + await SecureStore.deleteItemAsync(key); + return null; + } +} + +async function saveJson(key: string, value: unknown): Promise { + await SecureStore.setItemAsync(key, JSON.stringify(value)); +} + export interface NotifSettings { dailyEnabled: boolean; dailyHour: number; @@ -60,20 +85,46 @@ export async function saveCodexAuth(auth: { accessToken: string; accountId?: string; }): Promise { - await SecureStore.setItemAsync(KEYS.CODEX_AUTH, JSON.stringify(auth)); + await saveJson(KEYS.CODEX_AUTH, auth); } export async function loadCodexAuth(): Promise<{ accessToken: string; accountId?: string; } | null> { - const raw = await SecureStore.getItemAsync(KEYS.CODEX_AUTH); - if (!raw) return null; - return JSON.parse(raw) as { accessToken: string; accountId?: string }; + return loadJson(KEYS.CODEX_AUTH); +} + +export function loadCodexUsageCache(): Promise | null> { + return loadJson(KEYS.CODEX_USAGE_CACHE); +} + +export function saveCodexUsageCache( + cache: UsageCache +): Promise { + return saveJson(KEYS.CODEX_USAGE_CACHE, cache); +} + +export function clearCodexUsageCache(): Promise { + return SecureStore.deleteItemAsync(KEYS.CODEX_USAGE_CACHE); +} + +export function loadClaudeUsageCache(): Promise | null> { + return loadJson(KEYS.CLAUDE_USAGE_CACHE); +} + +export function saveClaudeUsageCache( + cache: UsageCache +): Promise { + return saveJson(KEYS.CLAUDE_USAGE_CACHE, cache); +} + +export function clearClaudeUsageCache(): Promise { + return SecureStore.deleteItemAsync(KEYS.CLAUDE_USAGE_CACHE); } export async function saveClaudeSessionKey(key: string): Promise { - await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key); + await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key.trim()); } export async function loadClaudeSessionKey(): Promise { @@ -81,7 +132,7 @@ export async function loadClaudeSessionKey(): Promise { } export async function saveClaudeLastActiveOrg(orgId: string): Promise { - await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId); + await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId.trim()); } export async function loadClaudeLastActiveOrg(): Promise { @@ -93,10 +144,16 @@ export async function clearClaudeLastActiveOrg(): Promise { } export async function clearCodexAuth(): Promise { - await SecureStore.deleteItemAsync(KEYS.CODEX_AUTH); + await Promise.all([ + SecureStore.deleteItemAsync(KEYS.CODEX_AUTH), + clearCodexUsageCache(), + ]); } export async function clearClaudeSessionKey(): Promise { - await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY); - await SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG); + await Promise.all([ + SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY), + SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG), + clearClaudeUsageCache(), + ]); } diff --git a/lib/timeUtils.ts b/lib/timeUtils.ts index 75e9416..b5bf488 100644 --- a/lib/timeUtils.ts +++ b/lib/timeUtils.ts @@ -19,6 +19,37 @@ export function formatResetCountdown(resetAtSeconds: number): string { return `resets in ${minutes}m`; } +export function parseTimeInput( + value: string +): { hour: number; minute: number } | null { + const match = value.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if (hour > 23 || minute > 59) return null; + + return { hour, minute }; +} + +export function formatTime(hour: number, minute: number): string { + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +} + +export function formatLastUpdated(timestamp: number, now = Date.now()): string { + const elapsedSeconds = Math.max(0, Math.floor((now - timestamp) / 1000)); + if (elapsedSeconds < 60) return "Updated just now"; + + const minutes = Math.floor(elapsedSeconds / 60); + if (minutes < 60) return `Updated ${minutes} min ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Updated ${hours} hr${hours === 1 ? "" : "s"} ago`; + + const days = Math.floor(hours / 24); + return `Updated ${days} day${days === 1 ? "" : "s"} ago`; +} + export function formatResetCountdownISO(isoString: string): string { const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000); return formatResetCountdown(resetAtSeconds); diff --git a/package-lock.json b/package-lock.json index f6b7011..d4f4b3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,21 @@ "name": "codexbar.mobile", "version": "1.0.0", "dependencies": { + "@expo/metro-runtime": "~56.0.15", "@expo/vector-icons": "^15.1.1", "babel-preset-expo": "~56.0.0", "expo": "~56.0.12", "expo-constants": "~56.0.18", "expo-document-picker": "~56.0.4", "expo-file-system": "~56.0.8", + "expo-font": "~56.0.7", "expo-linking": "~56.0.14", "expo-notifications": "~56.0.18", "expo-router": "~56.2.11", "expo-secure-store": "~56.0.4", + "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", + "expo-system-ui": "~56.0.5", "nativewind": "^4.2.6", "react": "19.2.3", "react-dom": "^19.2.3", @@ -26,6 +30,7 @@ "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", + "react-native-web": "^0.21.2", "react-native-worklets": "^0.8.3" }, "devDependencies": { @@ -1440,6 +1445,31 @@ "walker": "^1.0.8" } }, + "node_modules/@expo/metro-runtime": { + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.15.tgz", + "integrity": "sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "^56.0.13", + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "@expo/log-box": "^56.0.13", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/@expo/osascript": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", @@ -3427,6 +3457,15 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3441,6 +3480,15 @@ "node": ">= 8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3962,31 +4010,6 @@ } } }, - "node_modules/expo-router/node_modules/@expo/metro-runtime": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.15.tgz", - "integrity": "sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==", - "license": "MIT", - "dependencies": { - "@expo/log-box": "^56.0.13", - "anser": "^1.4.9", - "pretty-format": "^29.7.0", - "stacktrace-parser": "^0.1.10", - "whatwg-fetch": "^3.0.0" - }, - "peerDependencies": { - "@expo/log-box": "^56.0.13", - "expo": "*", - "react": "*", - "react-dom": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, "node_modules/expo-router/node_modules/@expo/ui": { "version": "56.0.18", "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.18.tgz", @@ -4074,6 +4097,20 @@ "node": ">=20.16.0" } }, + "node_modules/expo-splash-screen": { + "version": "56.0.10", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-56.0.10.tgz", + "integrity": "sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~56.0.8", + "@expo/image-utils": "^0.10.1", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-status-bar": { "version": "56.0.4", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-56.0.4.tgz", @@ -4101,6 +4138,26 @@ "react-native": "*" } }, + "node_modules/expo-system-ui": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-56.0.5.tgz", + "integrity": "sha512-n1MmnUArV4cc3gVed9fGtluPme00PE9axKVx+NHbKxHFMam5l4GcOI7PxbYKFNx8o7WA1LRD7eLW33agmZrxGg==", + "license": "MIT", + "dependencies": { + "@react-native/normalize-colors": "0.85.3", + "debug": "^4.3.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "56.1.16", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz", @@ -4465,6 +4522,36 @@ "bser": "2.1.1" } }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/fetch-nodeshim": { "version": "0.4.10", "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", @@ -4742,6 +4829,12 @@ "node": ">= 14" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4781,6 +4874,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -5976,6 +6078,26 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -7090,6 +7212,38 @@ "react-native": ">=0.82.0" } }, + "node_modules/react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", + "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/react-native-worklets": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz", @@ -7541,6 +7695,12 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7777,6 +7937,12 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -8027,6 +8193,12 @@ "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", "license": "MIT" }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -8062,6 +8234,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -8285,12 +8483,28 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/whatwg-url-minimum": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", diff --git a/package.json b/package.json index f4f260f..1ec01d2 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,21 @@ "version": "1.0.0", "main": "expo-router/entry", "dependencies": { + "@expo/metro-runtime": "~56.0.15", "@expo/vector-icons": "^15.1.1", "babel-preset-expo": "~56.0.0", "expo": "~56.0.12", "expo-constants": "~56.0.18", "expo-document-picker": "~56.0.4", "expo-file-system": "~56.0.8", + "expo-font": "~56.0.7", "expo-linking": "~56.0.14", "expo-notifications": "~56.0.18", "expo-router": "~56.2.11", "expo-secure-store": "~56.0.4", + "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", + "expo-system-ui": "~56.0.5", "nativewind": "^4.2.6", "react": "19.2.3", "react-dom": "^19.2.3", @@ -21,6 +25,7 @@ "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", + "react-native-web": "^0.21.2", "react-native-worklets": "^0.8.3" }, "devDependencies": {