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": {