From d5b0f9c833b022d0d40a78afed2bb56667d6b042 Mon Sep 17 00:00:00 2001 From: space Date: Wed, 24 Jun 2026 17:52:06 +0200 Subject: [PATCH] feat: production-ready UI with onboarding wizard and dashboard - Onboarding flow (welcome -> Codex setup -> Claude setup -> done) with dark-themed screens and step indicators; completion stored in SecureStore - Smart root redirect checks onboarding state on launch - Dashboard tab shows both services at a glance via ServiceStatusCard; useFocusEffect + reload() keeps it fresh when credentials change in tabs - Settings tab manages credentials and exposes re-run setup wizard - Polished Codex and Claude detail screens with service headers and empty states - 4-tab layout (Home, Codex, Claude, Settings) with dark-mode tab bar - Both hooks gain reload() for cross-tab credential refresh Co-Authored-By: Claude Sonnet 4.6 --- app/(tabs)/_layout.tsx | 38 ++++- app/(tabs)/claude.tsx | 104 ++++++++---- app/(tabs)/codex.tsx | 93 +++++++---- app/(tabs)/index.tsx | 148 +++++++++++++++++ app/(tabs)/settings.tsx | 266 +++++++++++++++++++++++++++++++ app/index.tsx | 17 +- app/onboarding/_layout.tsx | 12 ++ app/onboarding/claude.tsx | 189 ++++++++++++++++++++++ app/onboarding/codex.tsx | 154 ++++++++++++++++++ app/onboarding/done.tsx | 120 ++++++++++++++ app/onboarding/index.tsx | 82 ++++++++++ components/ServiceStatusCard.tsx | 124 ++++++++++++++ hooks/useClaudeUsage.ts | 75 ++++++--- hooks/useCodexUsage.ts | 67 +++++--- lib/setupState.ts | 16 ++ 15 files changed, 1398 insertions(+), 107 deletions(-) create mode 100644 app/(tabs)/index.tsx create mode 100644 app/(tabs)/settings.tsx create mode 100644 app/onboarding/_layout.tsx create mode 100644 app/onboarding/claude.tsx create mode 100644 app/onboarding/codex.tsx create mode 100644 app/onboarding/done.tsx create mode 100644 app/onboarding/index.tsx create mode 100644 components/ServiceStatusCard.tsx create mode 100644 lib/setupState.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 6b7beea..2fbe37a 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,16 +1,39 @@ import { Tabs } from "expo-router"; +import { useColorScheme } from "react-native"; import { MaterialIcons } from "@expo/vector-icons"; export default function TabLayout() { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + return ( + ( + + ), + }} + /> + ( + + ), + }} + /> ); } diff --git a/app/(tabs)/claude.tsx b/app/(tabs)/claude.tsx index 20e83c9..00dfc4d 100644 --- a/app/(tabs)/claude.tsx +++ b/app/(tabs)/claude.tsx @@ -10,8 +10,8 @@ import { Platform, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { MaterialIcons } from "@expo/vector-icons"; import { useClaudeUsage } from "@/hooks/useClaudeUsage"; -import { SectionCard } from "@/components/SectionCard"; import { UsageStat } from "@/components/UsageStat"; import { ErrorMessage } from "@/components/ErrorMessage"; @@ -31,13 +31,17 @@ export default function ClaudeTab() { const canSave = pendingKey.trim().startsWith("sk-ant-"); return ( - + } - contentContainerStyle={{ paddingBottom: 32 }} > - {/* Configure */} - + {/* Header */} + + + + + + + Claude.ai + + + Usage windows + + + + + {/* Configuration card */} + + + Configuration + {sessionKey ? ( - + - - Configured + + Connected + + + {sessionKey.slice(0, 10)}… - + Clear @@ -79,39 +106,53 @@ export default function ClaudeTab() { placeholderTextColor="#a3a3a3" autoCapitalize="none" autoCorrect={false} - className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-2.5 text-sm text-neutral-900 dark:text-white bg-white dark:bg-neutral-800 mb-3" + 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-3" /> Save Session Key - - Chrome DevTools → Application → Cookies → claude.ai → sessionKey + + + Where to find it + + + Chrome DevTools → Application → Cookies → claude.ai →{" "} + + sessionKey + + + + + )} + + + {/* Usage card */} + + + Usage + + + {status === "idle" && !sessionKey && ( + + + + Enter your session key to see usage windows )} - - - {/* Usage */} - - {status === "idle" && !sessionKey && ( - - Enter your Claude session key to see usage - - )} {status === "loading" && ( - + )} {status === "error" && error && } {status === "success" && usage && ( @@ -121,7 +162,6 @@ export default function ClaudeTab() { percent={usage.five_hour.utilization} resetAtISO={usage.five_hour.resets_at} /> - {(usage.seven_day_sonnet || usage.seven_day_opus) && ( - + By Model (7-day) @@ -149,7 +189,7 @@ export default function ClaudeTab() { )} )} - + diff --git a/app/(tabs)/codex.tsx b/app/(tabs)/codex.tsx index fc4f6af..9b6405f 100644 --- a/app/(tabs)/codex.tsx +++ b/app/(tabs)/codex.tsx @@ -7,8 +7,8 @@ import { RefreshControl, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { MaterialIcons } from "@expo/vector-icons"; import { useCodexUsage } from "@/hooks/useCodexUsage"; -import { SectionCard } from "@/components/SectionCard"; import { UsageStat } from "@/components/UsageStat"; import { ErrorMessage } from "@/components/ErrorMessage"; import { windowLabel } from "@/lib/timeUtils"; @@ -18,9 +18,13 @@ export default function CodexTab() { useCodexUsage(); return ( - + } - contentContainerStyle={{ paddingBottom: 32 }} > - {/* Configure */} - + {/* Header */} + + + + + + + Codex CLI + + + Rate limits & credits + + + + + {/* Configuration card */} + + + Configuration + {auth ? ( - + - - Configured + + Connected {auth.accountId && ( - - ({auth.accountId.slice(0, 8)}…) + + {auth.accountId.slice(0, 10)}… )} @@ -50,7 +74,7 @@ export default function CodexTab() { onPress={clearAuth} className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800" > - + Clear @@ -59,8 +83,10 @@ export default function CodexTab() { + Import auth.json @@ -70,25 +96,30 @@ export default function CodexTab() { )} - + + + {/* Usage card */} + + + Usage + - {/* Usage */} - {status === "idle" && !auth && ( - - Import your auth.json to see usage - + + + + Import auth.json to see your rate limits + + )} {status === "loading" && ( - - )} - {status === "error" && error && ( - + )} + {status === "error" && error && } {status === "success" && usage && ( {/* Plan badge */} - + {usage.plan_type} @@ -101,7 +132,6 @@ export default function CodexTab() { percent={usage.rate_limit.primary_window.used_percent} resetAtSeconds={usage.rate_limit.primary_window.reset_at} /> - {/* Credits */} - + Credits {usage.credits.unlimited ? ( - - Unlimited - + + + + Unlimited + + ) : usage.credits.has_credits ? ( ${Number(usage.credits.balance).toFixed(2)} remaining @@ -127,7 +160,7 @@ export default function CodexTab() { )} - + ); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..d293015 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,148 @@ +import { + ScrollView, + View, + Text, + TouchableOpacity, + RefreshControl, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { router, useFocusEffect } from "expo-router"; +import { useCallback } from "react"; +import { MaterialIcons } from "@expo/vector-icons"; +import { useCodexUsage } from "@/hooks/useCodexUsage"; +import { useClaudeUsage } from "@/hooks/useClaudeUsage"; +import { ServiceStatusCard } from "@/components/ServiceStatusCard"; +import { windowLabel } from "@/lib/timeUtils"; + +export default function DashboardTab() { + const codex = useCodexUsage(); + const claude = useClaudeUsage(); + + useFocusEffect( + useCallback(() => { + codex.reload(); + claude.reload(); + }, []) + ); + + const isRefreshing = + codex.status === "loading" || claude.status === "loading"; + + const handleRefresh = () => { + codex.refresh(); + claude.refresh(); + }; + + const codexRows = + codex.usage + ? [ + { + label: `Primary (${windowLabel(codex.usage.rate_limit.primary_window.limit_window_seconds)})`, + percent: codex.usage.rate_limit.primary_window.used_percent, + resetAtSeconds: codex.usage.rate_limit.primary_window.reset_at, + }, + { + label: `Secondary (${windowLabel(codex.usage.rate_limit.secondary_window.limit_window_seconds)})`, + percent: codex.usage.rate_limit.secondary_window.used_percent, + resetAtSeconds: codex.usage.rate_limit.secondary_window.reset_at, + }, + ] + : undefined; + + const claudeRows = + claude.usage + ? [ + { + label: "5-hour window", + percent: claude.usage.five_hour.utilization, + resetAtISO: claude.usage.five_hour.resets_at, + }, + { + label: "7-day window", + percent: claude.usage.seven_day.utilization, + resetAtISO: claude.usage.seven_day.resets_at, + }, + ] + : undefined; + + return ( + + + } + > + {/* Header */} + + + + Codexbar + + + AI usage monitor + + + + + + + + {/* Codex card */} + router.navigate("/(tabs)/codex")} + footer={ + codex.usage && !codex.usage.credits.unlimited && codex.usage.credits.has_credits + ? ( + + + + ${Number(codex.usage.credits.balance).toFixed(2)} credits remaining + + + ) + : codex.usage?.credits.unlimited + ? ( + + + + Unlimited credits + + + ) + : null + } + /> + + {/* Claude card */} + router.navigate("/(tabs)/claude")} + /> + + + ); +} diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx new file mode 100644 index 0000000..479b8d9 --- /dev/null +++ b/app/(tabs)/settings.tsx @@ -0,0 +1,266 @@ +import { useEffect, useState, useCallback } from "react"; +import { View, Text, TouchableOpacity, Alert, ScrollView } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { router, useFocusEffect } from "expo-router"; +import { MaterialIcons } from "@expo/vector-icons"; +import { + loadCodexAuth, + clearCodexAuth, + loadClaudeSessionKey, + clearClaudeSessionKey, +} from "@/lib/storage"; +import { resetOnboarding } from "@/lib/setupState"; +import type { CodexAuth } from "@/types/codex"; +import Constants from "expo-constants"; + +function SettingRow({ + icon, + label, + value, + onPress, + destructive, + disabled, +}: { + icon: React.ComponentProps["name"]; + label: string; + value?: string; + onPress?: () => void; + destructive?: boolean; + disabled?: boolean; +}) { + return ( + + + + + + + {label} + + {value && ( + + {value} + + )} + + {onPress && !disabled && ( + + )} + + ); +} + +function SectionHeader({ title }: { title: string }) { + return ( + + {title} + + ); +} + +function SectionCard({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Divider() { + return ( + + ); +} + +export default function SettingsTab() { + const [codexAuth, setCodexAuth] = useState(null); + const [claudeKey, setClaudeKey] = useState(null); + + const reload = useCallback(() => { + Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then( + ([codex, claude]) => { + setCodexAuth(codex); + setClaudeKey(claude); + } + ); + }, []); + + useEffect(() => { + reload(); + }, [reload]); + + useFocusEffect(useCallback(() => { + reload(); + }, [reload])); + + const handleClearCodex = () => { + Alert.alert( + "Clear Codex credentials", + "This will remove your stored auth.json data. You can re-import it anytime.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear", + style: "destructive", + onPress: async () => { + await clearCodexAuth(); + setCodexAuth(null); + }, + }, + ] + ); + }; + + const handleClearClaude = () => { + Alert.alert( + "Clear Claude session key", + "Your session key will be removed. You can re-enter it anytime.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear", + style: "destructive", + onPress: async () => { + await clearClaudeSessionKey(); + setClaudeKey(null); + }, + }, + ] + ); + }; + + const handleResetSetup = () => { + Alert.alert( + "Re-run Setup Wizard", + "This will clear all credentials and restart the setup flow.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Reset & Re-run", + style: "destructive", + onPress: async () => { + await Promise.all([ + clearCodexAuth(), + clearClaudeSessionKey(), + resetOnboarding(), + ]); + router.replace("/onboarding"); + }, + }, + ] + ); + }; + + const appVersion = Constants.expoConfig?.version ?? "1.0.0"; + + return ( + + + {/* Header */} + + + Settings + + + + {/* Codex CLI section */} + + + {codexAuth ? ( + <> + + + + + ) : ( + router.navigate("/(tabs)/codex")} + /> + )} + + + {/* Claude.ai section */} + + + {claudeKey ? ( + <> + + + + + ) : ( + router.navigate("/(tabs)/claude")} + /> + )} + + + {/* App section */} + + + + + + + + + ); +} diff --git a/app/index.tsx b/app/index.tsx index fac780b..facf87e 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,5 +1,18 @@ -import { Redirect } from "expo-router"; +import { useEffect } from "react"; +import { View, ActivityIndicator } from "react-native"; +import { router } from "expo-router"; +import { hasCompletedOnboarding } from "@/lib/setupState"; export default function Index() { - return ; + useEffect(() => { + hasCompletedOnboarding().then((done) => { + router.replace(done ? "/(tabs)" : "/onboarding"); + }); + }, []); + + return ( + + + + ); } diff --git a/app/onboarding/_layout.tsx b/app/onboarding/_layout.tsx new file mode 100644 index 0000000..e30c6af --- /dev/null +++ b/app/onboarding/_layout.tsx @@ -0,0 +1,12 @@ +import { Stack } from "expo-router"; + +export default function OnboardingLayout() { + return ( + + ); +} diff --git a/app/onboarding/claude.tsx b/app/onboarding/claude.tsx new file mode 100644 index 0000000..e50cc90 --- /dev/null +++ b/app/onboarding/claude.tsx @@ -0,0 +1,189 @@ +import { useState, useCallback, useEffect } from "react"; +import { + View, + Text, + TouchableOpacity, + TextInput, + KeyboardAvoidingView, + Platform, + ScrollView, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { router } from "expo-router"; +import { MaterialIcons } from "@expo/vector-icons"; +import { saveClaudeSessionKey, loadClaudeSessionKey } from "@/lib/storage"; + +export default function OnboardingClaudeScreen() { + const [savedKey, setSavedKey] = useState(null); + const [pendingKey, setPendingKey] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + loadClaudeSessionKey().then((k) => { + if (k) setSavedKey(k); + }); + }, []); + + const canSave = pendingKey.trim().startsWith("sk-ant-"); + + const handleSave = useCallback(async () => { + const trimmed = pendingKey.trim(); + if (!trimmed.startsWith("sk-ant-")) { + setError("Session key must start with sk-ant-"); + return; + } + await saveClaudeSessionKey(trimmed); + setSavedKey(trimmed); + setPendingKey(""); + setError(null); + }, [pendingKey]); + + return ( + + + + + {/* Top: back + step indicator */} + + router.back()} + className="w-9 h-9 rounded-xl bg-neutral-900 items-center justify-center" + > + + + + + + + + + 2 + + + + + + {/* Content */} + + + + + + Connect Claude.ai + + + Enter your session key to monitor your Claude.ai usage windows. + + + {savedKey ? ( + + + + + + Connected + + {savedKey.slice(0, 16)}… + + + { + setSavedKey(null); + setPendingKey(""); + }} + > + Change + + + ) : ( + + {error && ( + + {error} + + )} + { + setPendingKey(t); + setError(null); + }} + placeholder="sk-ant-..." + placeholderTextColor="#525252" + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3" + /> + + + Save Session Key + + + + + How to find your session key + + + Open Claude.ai in Chrome → DevTools (F12) → Application → Cookies → claude.ai → find{" "} + sessionKey + + + + )} + + + {/* Bottom actions */} + + router.push("/onboarding/done")} + className="rounded-2xl py-4 items-center" + style={{ backgroundColor: savedKey ? "#d97706" : "#1a1a1a" }} + > + + Continue + + + router.push("/onboarding/done")} + className="py-3 items-center" + > + Skip for now + + + + + + + ); +} diff --git a/app/onboarding/codex.tsx b/app/onboarding/codex.tsx new file mode 100644 index 0000000..f35648f --- /dev/null +++ b/app/onboarding/codex.tsx @@ -0,0 +1,154 @@ +import { useState, useCallback, useEffect } from "react"; +import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { router } from "expo-router"; +import { MaterialIcons } from "@expo/vector-icons"; +import { pickAndReadCodexAuth } from "@/lib/fileReader"; +import { saveCodexAuth, loadCodexAuth } from "@/lib/storage"; +import type { CodexAuth } from "@/types/codex"; + +export default function OnboardingCodexScreen() { + const [auth, setAuth] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + loadCodexAuth().then((stored) => { + if (stored) setAuth(stored); + }); + }, []); + + const importFile = useCallback(async () => { + setLoading(true); + setError(null); + try { + const parsed = await pickAndReadCodexAuth(); + await saveCodexAuth(parsed); + setAuth(parsed); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR"; + if (msg !== "PICKER_CANCELLED") setError(msg); + } finally { + setLoading(false); + } + }, []); + + return ( + + + {/* Top: back + step indicator */} + + router.back()} + className="w-9 h-9 rounded-xl bg-neutral-900 items-center justify-center" + > + + + + + 1 + + + + 2 + + + + + + {/* Content */} + + + + + + Connect Codex CLI + + + Import your{" "} + + ~/.codex/auth.json + {" "} + file to track your rate limits and credits. + + + {/* Status / action */} + {auth ? ( + + + + + + Connected + {auth.accountId && ( + + {auth.accountId.slice(0, 12)}… + + )} + + + Re-import + + + ) : ( + + {error && ( + + {error} + + )} + + {loading ? ( + + ) : ( + + + + Import auth.json + + + )} + + + Located at ~/.codex/auth.json on your Mac + + + )} + + + {/* Bottom actions */} + + router.push("/onboarding/claude")} + className="rounded-2xl py-4 items-center" + style={{ backgroundColor: auth ? "#10a37f" : "#1a1a1a" }} + > + + Continue + + + router.push("/onboarding/claude")} + className="py-3 items-center" + > + Skip for now + + + + + ); +} diff --git a/app/onboarding/done.tsx b/app/onboarding/done.tsx new file mode 100644 index 0000000..a18d4de --- /dev/null +++ b/app/onboarding/done.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +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 { markOnboardingComplete } from "@/lib/setupState"; +import { loadCodexAuth, loadClaudeSessionKey } from "@/lib/storage"; + +export default function OnboardingDoneScreen() { + const [codexConfigured, setCodexConfigured] = useState(false); + const [claudeConfigured, setClaudeConfigured] = useState(false); + + useEffect(() => { + Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then( + ([codex, claude]) => { + setCodexConfigured(!!codex); + setClaudeConfigured(!!claude); + } + ); + }, []); + + const handleEnterApp = async () => { + await markOnboardingComplete(); + router.replace("/(tabs)"); + }; + + const noneConfigured = !codexConfigured && !claudeConfigured; + + return ( + + + {/* Icon */} + + + + + {/* Title */} + + {noneConfigured ? "Almost there" : "You're all set!"} + + + {noneConfigured + ? "No services configured yet. You can set them up anytime in Settings." + : "Your services are connected and ready to monitor."} + + + {/* Summary cards */} + + + + + + + + Codex CLI + + + {codexConfigured ? "Connected" : "Not configured"} + + + + + + + + + + + Claude.ai + + + {claudeConfigured ? "Connected" : "Not configured"} + + + + + + {/* CTA */} + + Open App + + + + ); +} diff --git a/app/onboarding/index.tsx b/app/onboarding/index.tsx new file mode 100644 index 0000000..51f4321 --- /dev/null +++ b/app/onboarding/index.tsx @@ -0,0 +1,82 @@ +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"; + +export default function WelcomeScreen() { + return ( + + + {/* Logo + title */} + + + + + + + + + + + Codexbar + + + Monitor your Codex CLI and Claude.ai{"\n"}usage limits at a glance. + + + + {/* Feature pills */} + + + + + + + Codex CLI + + Rate limits · Credits · Windows + + + + + + + + + Claude.ai + + 5-hour · 7-day · Model usage + + + + + + {/* CTA */} + + router.push("/onboarding/codex")} + className="rounded-2xl py-4 items-center" + style={{ backgroundColor: "#10a37f" }} + > + Get Started + + + You can configure services at any time in Settings + + + + + ); +} diff --git a/components/ServiceStatusCard.tsx b/components/ServiceStatusCard.tsx new file mode 100644 index 0000000..323d4b9 --- /dev/null +++ b/components/ServiceStatusCard.tsx @@ -0,0 +1,124 @@ +import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native"; +import { MaterialIcons } from "@expo/vector-icons"; +import { ProgressBar } from "./ProgressBar"; +import { ResetCountdown } from "./ResetCountdown"; + +interface UsageRow { + label: string; + percent: number; + resetAtSeconds?: number; + resetAtISO?: string; +} + +interface ServiceStatusCardProps { + title: string; + icon: React.ComponentProps["name"]; + accentColor: string; + status: "idle" | "loading" | "success" | "error"; + badge?: string; + rows?: UsageRow[]; + footer?: React.ReactNode; + unconfiguredLabel?: string; + onConfigurePress?: () => void; +} + +export function ServiceStatusCard({ + title, + icon, + accentColor, + status, + badge, + rows, + footer, + unconfiguredLabel, + onConfigurePress, +}: ServiceStatusCardProps) { + return ( + + {/* Header */} + + + + + + + {title} + + + {badge && ( + + + {badge} + + + )} + {status === "success" && !badge && ( + + )} + {status === "error" && ( + + )} + + + {/* Body */} + {status === "loading" && ( + + )} + + {status === "idle" && ( + + + {unconfiguredLabel ?? "Not configured"} + + {onConfigurePress && ( + + + Configure → + + + )} + + )} + + {status === "error" && ( + + Failed to load usage data + + )} + + {status === "success" && rows && ( + + {rows.map((row, i) => ( + + + + {row.label} + + + {Math.round(row.percent)}% + + + + + + ))} + + )} + + {footer && ( + + {footer} + + )} + + ); +} diff --git a/hooks/useClaudeUsage.ts b/hooks/useClaudeUsage.ts index f5867be..9e93eb2 100644 --- a/hooks/useClaudeUsage.ts +++ b/hooks/useClaudeUsage.ts @@ -9,6 +9,35 @@ import type { ClaudeUsageResponse } from "@/types/claude"; type Status = "idle" | "loading" | "success" | "error"; +async function doFetch( + sessionKey: string, + set: { + sessionKey: (v: string | null) => void; + usage: (v: ClaudeUsageResponse | null) => void; + status: (v: Status) => void; + error: (v: string | null) => void; + } +) { + set.status("loading"); + set.error(null); + try { + const orgs = await fetchClaudeOrgs(sessionKey); + if (!orgs.length) throw new Error("NO_ORGS_FOUND"); + const orgUuid = orgs[0].uuid; + const data = await fetchClaudeUsage(sessionKey, 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.sessionKey(null); + } + } +} + export function useClaudeUsage() { const [sessionKey, setSessionKey] = useState(null); const [pendingKey, setPendingKey] = useState(""); @@ -16,12 +45,18 @@ export function useClaudeUsage() { const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); + const setters = { sessionKey: setSessionKey, usage: setUsage, status: setStatus, error: setError }; + useEffect(() => { loadClaudeSessionKey().then((stored) => { if (stored) setSessionKey(stored); }); }, []); + useEffect(() => { + if (sessionKey) void doFetch(sessionKey, setters); + }, [sessionKey]); + const saveKey = useCallback(async () => { const trimmed = pendingKey.trim(); if (!trimmed.startsWith("sk-ant-")) { @@ -36,29 +71,26 @@ export function useClaudeUsage() { const refresh = useCallback(async () => { if (!sessionKey) return; - setStatus("loading"); - setError(null); - try { - const orgs = await fetchClaudeOrgs(sessionKey); - if (!orgs.length) throw new Error("NO_ORGS_FOUND"); - const orgUuid = orgs[0].uuid; - const data = await fetchClaudeUsage(sessionKey, orgUuid); - setUsage(data); - setStatus("success"); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR"; - setError(msg); - setStatus("error"); - if (msg === "TOKEN_EXPIRED") { - await clearClaudeSessionKey(); - setSessionKey(null); - } - } + await doFetch(sessionKey, setters); }, [sessionKey]); - useEffect(() => { - if (sessionKey) void refresh(); - }, [sessionKey]); + // Re-reads the session key from storage and fetches fresh data. Call from + // useFocusEffect so the dashboard stays current when the key is saved from + // another tab. If the string value is unchanged, React bails out on setState + // so we trigger the fetch directly. + const reload = useCallback(async () => { + const stored = await loadClaudeSessionKey(); + if (!stored) { + setSessionKey(null); + setUsage(null); + setStatus("idle"); + return; + } + // If already set to the same string, setState bails out and [sessionKey] + // effect won't fire, so call the fetch directly. + setSessionKey(stored); + void doFetch(stored, setters); + }, []); const clearKey = useCallback(async () => { await clearClaudeSessionKey(); @@ -77,6 +109,7 @@ export function useClaudeUsage() { error, saveKey, refresh, + reload, clearKey, }; } diff --git a/hooks/useCodexUsage.ts b/hooks/useCodexUsage.ts index 9c5b607..87079a1 100644 --- a/hooks/useCodexUsage.ts +++ b/hooks/useCodexUsage.ts @@ -6,18 +6,50 @@ import type { CodexUsageResponse, CodexAuth } 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); + } + } +} + export function useCodexUsage() { const [auth, setAuth] = useState(null); const [usage, setUsage] = useState(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); + const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError }; + useEffect(() => { loadCodexAuth().then((stored) => { if (stored) setAuth(stored); }); }, []); + useEffect(() => { + if (auth) void doFetch(auth, setters); + }, [auth]); + const importAuthFile = useCallback(async () => { try { const parsed = await pickAndReadCodexAuth(); @@ -32,26 +64,23 @@ export function useCodexUsage() { const refresh = useCallback(async () => { if (!auth) return; - setStatus("loading"); - setError(null); - try { - const data = await fetchCodexUsage(auth); - setUsage(data); - setStatus("success"); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR"; - setError(msg); - setStatus("error"); - if (msg === "TOKEN_EXPIRED") { - await clearCodexAuth(); - setAuth(null); - } - } + await doFetch(auth, setters); }, [auth]); - useEffect(() => { - if (auth) void refresh(); - }, [auth]); + // 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 stored = await loadCodexAuth(); + if (!stored) { + setAuth(null); + setUsage(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); + }, []); const clearAuth = useCallback(async () => { await clearCodexAuth(); @@ -61,5 +90,5 @@ export function useCodexUsage() { setError(null); }, []); - return { auth, usage, status, error, importAuthFile, refresh, clearAuth }; + return { auth, usage, status, error, importAuthFile, refresh, reload, clearAuth }; } diff --git a/lib/setupState.ts b/lib/setupState.ts new file mode 100644 index 0000000..f1e05f4 --- /dev/null +++ b/lib/setupState.ts @@ -0,0 +1,16 @@ +import * as SecureStore from "expo-secure-store"; + +const KEY = "codexbar_onboarding_v1"; + +export async function hasCompletedOnboarding(): Promise { + const val = await SecureStore.getItemAsync(KEY); + return val === "done"; +} + +export async function markOnboardingComplete(): Promise { + await SecureStore.setItemAsync(KEY, "done"); +} + +export async function resetOnboarding(): Promise { + await SecureStore.deleteItemAsync(KEY); +}