Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dd17cf565 | |||
| 6992e58b9c | |||
| 050c8e5cce | |||
| 6bf66c0afd | |||
| 2c63dcfd75 | |||
| 487f89699c | |||
| ef245d4e61 | |||
| 17a5ab73cb | |||
| ba7d957155 |
@@ -32,6 +32,9 @@ jobs:
|
|||||||
- name: 🏗 Setup Android SDK
|
- name: 🏗 Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: 🏗 Install Android NDK
|
||||||
|
run: yes | sdkmanager "ndk;27.1.12297006" || true
|
||||||
|
|
||||||
- name: 🏗 Setup Expo and EAS
|
- name: 🏗 Setup Expo and EAS
|
||||||
uses: expo/expo-github-action@v8
|
uses: expo/expo-github-action@v8
|
||||||
with:
|
with:
|
||||||
@@ -53,11 +56,14 @@ jobs:
|
|||||||
- name: 📝 Rename build to APK
|
- name: 📝 Rename build to APK
|
||||||
run: mv app-build codexbar-release.apk
|
run: mv app-build codexbar-release.apk
|
||||||
|
|
||||||
|
- name: 🗜 Zip APK
|
||||||
|
run: zip codexbar-release.zip codexbar-release.apk
|
||||||
|
|
||||||
- name: 📤 Upload build artifact
|
- name: 📤 Upload build artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: codexbar-android-preview-build
|
name: codexbar-android-preview-build
|
||||||
path: codexbar-release.apk
|
path: codexbar-release.zip
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@@ -84,7 +90,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tag_name: ${{ env.RELEASE_TAG }}
|
tag_name: ${{ env.RELEASE_TAG }}
|
||||||
name: ${{ env.RELEASE_TAG }}
|
name: ${{ env.RELEASE_TAG }}
|
||||||
files: codexbar-release.apk
|
files: codexbar-release.zip
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "codexbar.mobile",
|
"name": "codexbar-mobile",
|
||||||
"slug": "codexbar-mobile",
|
"slug": "codexbar-mobile",
|
||||||
"scheme": "codexbar",
|
"scheme": "codexbar",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "dark",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-status-bar",
|
"expo-status-bar",
|
||||||
"expo-secure-store",
|
"expo-secure-store",
|
||||||
"expo-document-picker"
|
"expo-document-picker",
|
||||||
|
"expo-notifications"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"router": {},
|
"router": {},
|
||||||
|
|||||||
+6
-20
@@ -1,10 +1,12 @@
|
|||||||
import { Tabs } from "expo-router";
|
import { Tabs } from "expo-router";
|
||||||
import { useColorScheme } from "react-native";
|
import { useColorScheme } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const isDark = colorScheme === "dark";
|
const isDark = colorScheme === "dark";
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -15,8 +17,8 @@ export default function TabLayout() {
|
|||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: isDark ? "#09090b" : "#ffffff",
|
backgroundColor: isDark ? "#09090b" : "#ffffff",
|
||||||
borderTopColor: isDark ? "#27272a" : "#f4f4f5",
|
borderTopColor: isDark ? "#27272a" : "#f4f4f5",
|
||||||
height: 56,
|
height: 56 + insets.bottom,
|
||||||
paddingBottom: 8,
|
paddingBottom: 8 + insets.bottom,
|
||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
},
|
},
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
@@ -34,24 +36,8 @@ export default function TabLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen name="codex" options={{ href: null }} />
|
||||||
name="codex"
|
<Tabs.Screen name="claude" options={{ href: null }} />
|
||||||
options={{
|
|
||||||
title: "Codex",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<MaterialIcons name="auto-awesome" size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="claude"
|
|
||||||
options={{
|
|
||||||
title: "Claude",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<MaterialIcons name="psychology" size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
+20
-9
@@ -17,9 +17,11 @@ import { ErrorMessage } from "@/components/ErrorMessage";
|
|||||||
|
|
||||||
export default function ClaudeTab() {
|
export default function ClaudeTab() {
|
||||||
const {
|
const {
|
||||||
sessionKey,
|
auth,
|
||||||
pendingKey,
|
pendingKey,
|
||||||
setPendingKey,
|
setPendingKey,
|
||||||
|
pendingOrg,
|
||||||
|
setPendingOrg,
|
||||||
usage,
|
usage,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
@@ -77,7 +79,7 @@ export default function ClaudeTab() {
|
|||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={error && (status === "idle" || status === "error") ? error : null}
|
message={error && (status === "idle" || status === "error") ? error : null}
|
||||||
/>
|
/>
|
||||||
{sessionKey ? (
|
{auth ? (
|
||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-row items-center justify-between">
|
||||||
<View className="flex-row items-center gap-x-2.5">
|
<View className="flex-row items-center gap-x-2.5">
|
||||||
<View className="w-2 h-2 rounded-full bg-green-500" />
|
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
@@ -85,7 +87,7 @@ export default function ClaudeTab() {
|
|||||||
Connected
|
Connected
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-mono">
|
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-mono">
|
||||||
{sessionKey.slice(0, 10)}…
|
{auth.sessionKey.slice(0, 10)}…
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -102,11 +104,20 @@ export default function ClaudeTab() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
value={pendingKey}
|
value={pendingKey}
|
||||||
onChangeText={setPendingKey}
|
onChangeText={setPendingKey}
|
||||||
placeholder="sk-ant-..."
|
placeholder="sk-ant-… (required)"
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholderTextColor="#a3a3a3"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
secureTextEntry
|
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"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={pendingOrg}
|
||||||
|
onChangeText={setPendingOrg}
|
||||||
|
placeholder="lastActiveOrg UUID (optional)"
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -127,10 +138,10 @@ export default function ClaudeTab() {
|
|||||||
Where to find it
|
Where to find it
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
|
||||||
Chrome DevTools → Application → Cookies → claude.ai →{" "}
|
Chrome DevTools → Application → Cookies → claude.ai → copy{" "}
|
||||||
<Text className="font-mono text-neutral-600 dark:text-neutral-300">
|
<Text className="font-mono text-neutral-600 dark:text-neutral-300">sessionKey</Text>
|
||||||
sessionKey
|
{" "}and optionally{" "}
|
||||||
</Text>
|
<Text className="font-mono text-neutral-600 dark:text-neutral-300">lastActiveOrg</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -143,7 +154,7 @@ export default function ClaudeTab() {
|
|||||||
Usage
|
Usage
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{status === "idle" && !sessionKey && (
|
{status === "idle" && !auth && (
|
||||||
<View className="items-center py-8">
|
<View className="items-center py-8">
|
||||||
<MaterialIcons name="insert-chart-outlined" size={32} color="#d4d4d4" />
|
<MaterialIcons name="insert-chart-outlined" size={32} color="#d4d4d4" />
|
||||||
<Text className="text-sm text-neutral-400 text-center mt-3">
|
<Text className="text-sm text-neutral-400 text-center mt-3">
|
||||||
|
|||||||
+249
-82
@@ -4,20 +4,81 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
ActivityIndicator,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { router, useFocusEffect } from "expo-router";
|
import { router, useFocusEffect } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
import { useCodexUsage } from "@/hooks/useCodexUsage";
|
import { useCodexUsage } from "@/hooks/useCodexUsage";
|
||||||
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
|
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
|
||||||
import { ServiceStatusCard } from "@/components/ServiceStatusCard";
|
import { onUsageDataLoaded } from "@/lib/notifications";
|
||||||
|
import { ProgressBar } from "@/components/ProgressBar";
|
||||||
|
import { ResetCountdown } from "@/components/ResetCountdown";
|
||||||
import { windowLabel } from "@/lib/timeUtils";
|
import { windowLabel } from "@/lib/timeUtils";
|
||||||
|
|
||||||
|
function UsageRow({
|
||||||
|
label,
|
||||||
|
percent,
|
||||||
|
resetAtSeconds,
|
||||||
|
resetAtISO,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
percent: number;
|
||||||
|
resetAtSeconds?: number;
|
||||||
|
resetAtISO?: string;
|
||||||
|
}) {
|
||||||
|
const color =
|
||||||
|
percent >= 90 ? "#ef4444" : percent >= 70 ? "#f59e0b" : "#10a37f";
|
||||||
|
return (
|
||||||
|
<View className="mb-4">
|
||||||
|
<View className="flex-row justify-between items-baseline mb-1.5">
|
||||||
|
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm font-bold" style={{ color }}>
|
||||||
|
{Math.round(percent)}% used
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ProgressBar percent={percent} />
|
||||||
|
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetupPrompt({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text className="text-sm text-neutral-400 dark:text-neutral-500 mb-3">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.navigate("/(tabs)/settings")}
|
||||||
|
className="flex-row items-center self-start gap-x-1.5 px-3 py-2 rounded-xl border border-neutral-200 dark:border-neutral-700"
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
||||||
|
Set up in Settings
|
||||||
|
</Text>
|
||||||
|
<MaterialIcons name="arrow-forward" size={12} color="#737373" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardTab() {
|
export default function DashboardTab() {
|
||||||
const codex = useCodexUsage();
|
const codex = useCodexUsage();
|
||||||
const claude = useClaudeUsage();
|
const claude = useClaudeUsage();
|
||||||
|
|
||||||
|
// Fire daily digest reschedule + threshold check whenever fresh data arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (codex.status === "success" || claude.status === "success") {
|
||||||
|
void onUsageDataLoaded(
|
||||||
|
claude.status === "success" ? claude.usage : null,
|
||||||
|
codex.status === "success" ? codex.usage : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [codex.status, claude.status]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
codex.reload();
|
codex.reload();
|
||||||
@@ -33,46 +94,16 @@ export default function DashboardTab() {
|
|||||||
claude.refresh();
|
claude.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const codexRows =
|
const connectedCount = [codex.auth, claude.auth].filter(Boolean).length;
|
||||||
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 (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
||||||
edges={["top", "bottom"]}
|
edges={["top"]}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 32 }}
|
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isRefreshing}
|
refreshing={isRefreshing}
|
||||||
@@ -82,14 +113,23 @@ export default function DashboardTab() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="flex-row items-center justify-between py-5">
|
<View className="flex-row items-center justify-between pt-6 pb-5">
|
||||||
<View>
|
<View className="flex-row items-center gap-x-3">
|
||||||
<Text className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
<View className="w-10 h-10 rounded-2xl bg-emerald-500 items-center justify-center">
|
||||||
Codexbar
|
<MaterialIcons name="bar-chart" size={20} color="white" />
|
||||||
</Text>
|
</View>
|
||||||
<Text className="text-xs text-neutral-400 mt-0.5">
|
<View>
|
||||||
AI usage monitor
|
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
||||||
</Text>
|
Codexbar
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-400 mt-0.5">
|
||||||
|
{connectedCount === 0
|
||||||
|
? "No services connected"
|
||||||
|
: connectedCount === 2
|
||||||
|
? "2 services connected"
|
||||||
|
: "1 of 2 services connected"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleRefresh}
|
onPress={handleRefresh}
|
||||||
@@ -100,48 +140,175 @@ export default function DashboardTab() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Codex card */}
|
{/* Codex card */}
|
||||||
<ServiceStatusCard
|
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
|
||||||
title="Codex CLI"
|
<View style={{ height: 3, backgroundColor: "#10a37f" }} />
|
||||||
icon="auto-awesome"
|
<View className="p-4">
|
||||||
accentColor="#10a37f"
|
<View className="flex-row items-center justify-between mb-4">
|
||||||
status={!codex.auth ? "idle" : codex.status}
|
<View className="flex-row items-center gap-x-2.5">
|
||||||
badge={codex.usage?.plan_type}
|
<View
|
||||||
rows={codexRows}
|
className="w-8 h-8 rounded-xl items-center justify-center"
|
||||||
unconfiguredLabel="Import auth.json to see your Codex rate limits."
|
style={{ backgroundColor: "#10a37f18" }}
|
||||||
onConfigurePress={() => router.navigate("/(tabs)/codex")}
|
>
|
||||||
footer={
|
<MaterialIcons name="auto-awesome" size={16} color="#10a37f" />
|
||||||
codex.usage && !codex.usage.credits.unlimited && codex.usage.credits.has_credits
|
|
||||||
? (
|
|
||||||
<View className="flex-row items-center gap-x-2">
|
|
||||||
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
|
|
||||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
||||||
${Number(codex.usage.credits.balance).toFixed(2)} credits remaining
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
: codex.usage?.credits.unlimited
|
Codex CLI
|
||||||
? (
|
</Text>
|
||||||
<View className="flex-row items-center gap-x-2">
|
</View>
|
||||||
<MaterialIcons name="all-inclusive" size={14} color="#10a37f" />
|
<View className="flex-row items-center gap-x-2">
|
||||||
<Text className="text-xs text-green-600 dark:text-green-400">
|
{codex.usage?.plan_type && (
|
||||||
Unlimited credits
|
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||||
</Text>
|
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||||
</View>
|
{codex.usage.plan_type}
|
||||||
)
|
</Text>
|
||||||
: null
|
</View>
|
||||||
}
|
)}
|
||||||
/>
|
{codex.auth && codex.status === "error" && (
|
||||||
|
<View className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
|
)}
|
||||||
|
{codex.status === "success" && !codex.usage?.plan_type && (
|
||||||
|
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!codex.auth && (
|
||||||
|
<SetupPrompt label="Import auth.json to see your Codex rate limits." />
|
||||||
|
)}
|
||||||
|
{codex.auth && codex.status === "loading" && (
|
||||||
|
<ActivityIndicator
|
||||||
|
color="#10a37f"
|
||||||
|
style={{ marginVertical: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{codex.auth && codex.status === "error" && (
|
||||||
|
<View className="flex-row items-center gap-x-2 py-1">
|
||||||
|
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
||||||
|
<Text className="text-sm text-red-500 dark:text-red-400">
|
||||||
|
Failed to fetch usage
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{codex.status === "success" && codex.usage && (
|
||||||
|
<View>
|
||||||
|
<UsageRow
|
||||||
|
label={`${windowLabel(codex.usage.rate_limit.primary_window.limit_window_seconds)} window`}
|
||||||
|
percent={codex.usage.rate_limit.primary_window.used_percent}
|
||||||
|
resetAtSeconds={codex.usage.rate_limit.primary_window.reset_at}
|
||||||
|
/>
|
||||||
|
<UsageRow
|
||||||
|
label={`${windowLabel(codex.usage.rate_limit.secondary_window.limit_window_seconds)} window`}
|
||||||
|
percent={
|
||||||
|
codex.usage.rate_limit.secondary_window.used_percent
|
||||||
|
}
|
||||||
|
resetAtSeconds={
|
||||||
|
codex.usage.rate_limit.secondary_window.reset_at
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!codex.usage.credits.unlimited &&
|
||||||
|
codex.usage.credits.has_credits && (
|
||||||
|
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
||||||
|
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
|
||||||
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
${Number(codex.usage.credits.balance).toFixed(2)}{" "}
|
||||||
|
credits remaining
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{codex.usage.credits.unlimited && (
|
||||||
|
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
||||||
|
<MaterialIcons
|
||||||
|
name="all-inclusive"
|
||||||
|
size={14}
|
||||||
|
color="#10a37f"
|
||||||
|
/>
|
||||||
|
<Text className="text-xs text-green-600 dark:text-green-400">
|
||||||
|
Unlimited credits
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Claude card */}
|
{/* Claude card */}
|
||||||
<ServiceStatusCard
|
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
|
||||||
title="Claude.ai"
|
<View style={{ height: 3, backgroundColor: "#d97706" }} />
|
||||||
icon="psychology"
|
<View className="p-4">
|
||||||
accentColor="#d97706"
|
<View className="flex-row items-center justify-between mb-4">
|
||||||
status={!claude.sessionKey ? "idle" : claude.status}
|
<View className="flex-row items-center gap-x-2.5">
|
||||||
rows={claudeRows}
|
<View
|
||||||
unconfiguredLabel="Add your session key to see Claude usage windows."
|
className="w-8 h-8 rounded-xl items-center justify-center"
|
||||||
onConfigurePress={() => router.navigate("/(tabs)/claude")}
|
style={{ backgroundColor: "#d9770618" }}
|
||||||
/>
|
>
|
||||||
|
<MaterialIcons name="psychology" size={16} color="#d97706" />
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Claude.ai
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{claude.auth && claude.status === "error" && (
|
||||||
|
<View className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
|
)}
|
||||||
|
{claude.status === "success" && (
|
||||||
|
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!claude.auth && (
|
||||||
|
<SetupPrompt label="Add your session key to see Claude usage windows." />
|
||||||
|
)}
|
||||||
|
{claude.auth && claude.status === "loading" && (
|
||||||
|
<ActivityIndicator
|
||||||
|
color="#d97706"
|
||||||
|
style={{ marginVertical: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{claude.auth && claude.status === "error" && (
|
||||||
|
<View className="flex-row items-center gap-x-2 py-1">
|
||||||
|
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
||||||
|
<Text className="text-sm text-red-500 dark:text-red-400">
|
||||||
|
Failed to fetch usage
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{claude.status === "success" && claude.usage && (
|
||||||
|
<View>
|
||||||
|
<UsageRow
|
||||||
|
label="5-hour window"
|
||||||
|
percent={claude.usage.five_hour.utilization}
|
||||||
|
resetAtISO={claude.usage.five_hour.resets_at}
|
||||||
|
/>
|
||||||
|
<UsageRow
|
||||||
|
label="7-day window"
|
||||||
|
percent={claude.usage.seven_day.utilization}
|
||||||
|
resetAtISO={claude.usage.seven_day.resets_at}
|
||||||
|
/>
|
||||||
|
{(claude.usage.seven_day_sonnet ||
|
||||||
|
claude.usage.seven_day_opus) && (
|
||||||
|
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
|
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
|
||||||
|
By Model (7-day)
|
||||||
|
</Text>
|
||||||
|
{claude.usage.seven_day_sonnet && (
|
||||||
|
<UsageRow
|
||||||
|
label="Sonnet"
|
||||||
|
percent={claude.usage.seven_day_sonnet.utilization}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{claude.usage.seven_day_opus && (
|
||||||
|
<UsageRow
|
||||||
|
label="Opus"
|
||||||
|
percent={claude.usage.seven_day_opus.utilization}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
+372
-82
@@ -1,18 +1,47 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { View, Text, TouchableOpacity, Alert, ScrollView } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
Switch,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { router, useFocusEffect } from "expo-router";
|
import { router, useFocusEffect } from "expo-router";
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
loadCodexAuth,
|
loadCodexAuth,
|
||||||
clearCodexAuth,
|
clearCodexAuth,
|
||||||
|
saveCodexAuth,
|
||||||
loadClaudeSessionKey,
|
loadClaudeSessionKey,
|
||||||
clearClaudeSessionKey,
|
clearClaudeSessionKey,
|
||||||
|
saveClaudeSessionKey,
|
||||||
|
saveClaudeLastActiveOrg,
|
||||||
|
clearClaudeLastActiveOrg,
|
||||||
} from "@/lib/storage";
|
} from "@/lib/storage";
|
||||||
|
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
||||||
import { resetOnboarding } from "@/lib/setupState";
|
import { resetOnboarding } from "@/lib/setupState";
|
||||||
|
import { useNotificationSettings } from "@/hooks/useNotificationSettings";
|
||||||
import type { CodexAuth } from "@/types/codex";
|
import type { CodexAuth } from "@/types/codex";
|
||||||
import Constants from "expo-constants";
|
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({
|
function SettingRow({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
@@ -88,14 +117,27 @@ function SectionCard({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Divider() {
|
function Divider() {
|
||||||
return (
|
return <View className="ml-16 mr-0 h-px bg-neutral-100 dark:bg-neutral-800" />;
|
||||||
<View className="ml-16 mr-0 h-px bg-neutral-100 dark:bg-neutral-800" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsTab() {
|
export default function SettingsTab() {
|
||||||
const [codexAuth, setCodexAuth] = useState<CodexAuth | null>(null);
|
const [codexAuth, setCodexAuth] = useState<CodexAuth | null>(null);
|
||||||
const [claudeKey, setClaudeKey] = useState<string | null>(null);
|
const [claudeKey, setClaudeKey] = useState<string | null>(null);
|
||||||
|
const [pendingKey, setPendingKey] = useState("");
|
||||||
|
const [pendingOrg, setPendingOrg] = useState("");
|
||||||
|
const [claudeError, setClaudeError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const notif = useNotificationSettings();
|
||||||
|
const [timeInput, setTimeInput] = useState("");
|
||||||
|
const [thresholdInput, setThresholdInput] = useState("");
|
||||||
|
|
||||||
|
// Sync local text inputs when settings load
|
||||||
|
useEffect(() => {
|
||||||
|
if (notif.loaded) {
|
||||||
|
setTimeInput(formatTime(notif.settings.dailyHour, notif.settings.dailyMinute));
|
||||||
|
setThresholdInput(String(notif.settings.thresholdPct));
|
||||||
|
}
|
||||||
|
}, [notif.loaded]);
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
|
Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
|
||||||
@@ -110,9 +152,40 @@ export default function SettingsTab() {
|
|||||||
reload();
|
reload();
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
useFocusEffect(useCallback(() => {
|
useFocusEffect(
|
||||||
reload();
|
useCallback(() => {
|
||||||
}, [reload]));
|
reload();
|
||||||
|
}, [reload])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportCodex = async () => {
|
||||||
|
try {
|
||||||
|
const auth = await pickAndReadCodexAuth();
|
||||||
|
await saveCodexAuth(auth);
|
||||||
|
setCodexAuth(auth);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||||
|
if (msg !== "PICKER_CANCELLED") {
|
||||||
|
Alert.alert("Import failed", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveClaude = async () => {
|
||||||
|
const trimmed = pendingKey.trim();
|
||||||
|
if (!trimmed.startsWith("sk-ant-")) {
|
||||||
|
setClaudeError("Key must start with sk-ant-");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orgTrimmed = pendingOrg.trim() || undefined;
|
||||||
|
await saveClaudeSessionKey(trimmed);
|
||||||
|
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
|
||||||
|
else await clearClaudeLastActiveOrg();
|
||||||
|
setClaudeKey(trimmed);
|
||||||
|
setPendingKey("");
|
||||||
|
setPendingOrg("");
|
||||||
|
setClaudeError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleClearCodex = () => {
|
const handleClearCodex = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -172,6 +245,26 @@ export default function SettingsTab() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTimeBlur = useCallback(() => {
|
||||||
|
const parsed = parseTimeInput(timeInput);
|
||||||
|
if (parsed) {
|
||||||
|
void notif.update({ dailyHour: parsed.hour, dailyMinute: parsed.minute });
|
||||||
|
} else {
|
||||||
|
// Reset to last valid value
|
||||||
|
setTimeInput(formatTime(notif.settings.dailyHour, notif.settings.dailyMinute));
|
||||||
|
}
|
||||||
|
}, [timeInput, notif]);
|
||||||
|
|
||||||
|
const handleThresholdBlur = useCallback(() => {
|
||||||
|
const n = parseInt(thresholdInput, 10);
|
||||||
|
if (!isNaN(n) && n >= 1 && n <= 99) {
|
||||||
|
void notif.update({ thresholdPct: n });
|
||||||
|
} else {
|
||||||
|
setThresholdInput(String(notif.settings.thresholdPct));
|
||||||
|
}
|
||||||
|
}, [thresholdInput, notif]);
|
||||||
|
|
||||||
|
const canSaveClaude = pendingKey.trim().startsWith("sk-ant-");
|
||||||
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,88 +272,285 @@ export default function SettingsTab() {
|
|||||||
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
||||||
edges={["top", "bottom"]}
|
edges={["top", "bottom"]}
|
||||||
>
|
>
|
||||||
<ScrollView className="flex-1" contentContainerStyle={{ paddingBottom: 48 }}>
|
<KeyboardAvoidingView
|
||||||
{/* Header */}
|
className="flex-1"
|
||||||
<View className="px-4 py-5">
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
<Text className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
>
|
||||||
Settings
|
<ScrollView
|
||||||
</Text>
|
className="flex-1"
|
||||||
</View>
|
contentContainerStyle={{ paddingBottom: 48 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View className="px-4 py-5">
|
||||||
|
<Text className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Codex CLI section */}
|
{/* Codex CLI section */}
|
||||||
<SectionHeader title="Codex CLI" />
|
<SectionHeader title="Codex CLI" />
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
{codexAuth ? (
|
{codexAuth ? (
|
||||||
<>
|
<>
|
||||||
|
<SettingRow
|
||||||
|
icon="check-circle"
|
||||||
|
label="Connected"
|
||||||
|
value={
|
||||||
|
codexAuth.accountId
|
||||||
|
? `${codexAuth.accountId.slice(0, 16)}…`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon="delete-outline"
|
||||||
|
label="Clear credentials"
|
||||||
|
onPress={handleClearCodex}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="check-circle"
|
icon="upload-file"
|
||||||
label="Connected"
|
label="Import auth.json"
|
||||||
value={codexAuth.accountId ? `${codexAuth.accountId.slice(0, 16)}…` : undefined}
|
onPress={handleImportCodex}
|
||||||
disabled
|
|
||||||
/>
|
/>
|
||||||
<Divider />
|
)}
|
||||||
<SettingRow
|
</SectionCard>
|
||||||
icon="delete-outline"
|
|
||||||
label="Clear credentials"
|
{/* Claude.ai section */}
|
||||||
onPress={handleClearCodex}
|
<SectionHeader title="Claude.ai" />
|
||||||
destructive
|
<SectionCard>
|
||||||
/>
|
{claudeKey ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
<SettingRow
|
||||||
|
icon="check-circle"
|
||||||
|
label="Connected"
|
||||||
|
value={`${claudeKey.slice(0, 16)}…`}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon="delete-outline"
|
||||||
|
label="Clear session key"
|
||||||
|
onPress={handleClearClaude}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View className="p-4">
|
||||||
|
{claudeError && (
|
||||||
|
<Text className="text-xs text-red-500 mb-2">
|
||||||
|
{claudeError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<TextInput
|
||||||
|
value={pendingKey}
|
||||||
|
onChangeText={(t) => {
|
||||||
|
setPendingKey(t);
|
||||||
|
setClaudeError(null);
|
||||||
|
}}
|
||||||
|
placeholder="sk-ant-… (required)"
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
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-neutral-50 dark:bg-neutral-800 mb-2"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={pendingOrg}
|
||||||
|
onChangeText={setPendingOrg}
|
||||||
|
placeholder="lastActiveOrg UUID (optional)"
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-neutral-50 dark:bg-neutral-800 mb-3"
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSaveClaude}
|
||||||
|
disabled={!canSaveClaude}
|
||||||
|
className="py-3 px-4 rounded-xl items-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: canSaveClaude ? "#d97706" : "#e5e5e5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="font-semibold text-sm"
|
||||||
|
style={{ color: canSaveClaude ? "white" : "#a3a3a3" }}
|
||||||
|
>
|
||||||
|
Save Session Key
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-xs text-neutral-400 text-center mt-3 leading-relaxed">
|
||||||
|
Chrome DevTools → Application → Cookies → claude.ai →{" "}
|
||||||
|
<Text className="font-mono text-neutral-500">sessionKey</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Notifications section */}
|
||||||
|
<SectionHeader title="Notifications" />
|
||||||
|
<SectionCard>
|
||||||
|
{notif.permissionStatus !== "granted" ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={async () => {
|
||||||
|
const granted = await notif.askPermissions();
|
||||||
|
if (!granted && notif.permissionStatus === "denied") {
|
||||||
|
Alert.alert(
|
||||||
|
"Notifications blocked",
|
||||||
|
"Go to Settings → CodexBar → Notifications to enable them."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-row items-center gap-x-3 py-3.5 px-4"
|
||||||
|
>
|
||||||
|
<View className="w-8 items-center">
|
||||||
|
<MaterialIcons name="notifications-off" size={20} color="#737373" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||||
|
Enable notifications
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-400 mt-0.5">
|
||||||
|
{notif.permissionStatus === "denied"
|
||||||
|
? "Blocked — open system Settings to allow"
|
||||||
|
: "Required to receive alerts"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{notif.permissionStatus !== "denied" && (
|
||||||
|
<View className="px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30">
|
||||||
|
<Text className="text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||||
|
Allow
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Daily digest */}
|
||||||
|
<View className="flex-row items-center gap-x-3 py-3.5 px-4">
|
||||||
|
<View className="w-8 items-center">
|
||||||
|
<MaterialIcons name="alarm" size={20} color="#737373" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||||
|
Daily digest
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-400 mt-0.5">
|
||||||
|
Daily reminder with last-known usage
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={notif.settings.dailyEnabled}
|
||||||
|
onValueChange={(v) => void notif.update({ dailyEnabled: v })}
|
||||||
|
trackColor={{ false: "#e5e5e5", true: "#10a37f" }}
|
||||||
|
thumbColor="white"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{notif.settings.dailyEnabled && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<View className="flex-row items-center gap-x-3 py-3 px-4">
|
||||||
|
<View className="w-8 items-center">
|
||||||
|
<MaterialIcons name="schedule" size={20} color="#737373" />
|
||||||
|
</View>
|
||||||
|
<Text className="flex-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||||
|
Time
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={timeInput}
|
||||||
|
onChangeText={setTimeInput}
|
||||||
|
onBlur={handleTimeBlur}
|
||||||
|
placeholder="09:00"
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
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 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Threshold alert */}
|
||||||
|
<View className="flex-row items-center gap-x-3 py-3.5 px-4">
|
||||||
|
<View className="w-8 items-center">
|
||||||
|
<MaterialIcons name="warning" size={20} color="#737373" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||||
|
Low quota alert
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-400 mt-0.5">
|
||||||
|
Alert when weekly quota runs low
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={notif.settings.thresholdEnabled}
|
||||||
|
onValueChange={(v) => void notif.update({ thresholdEnabled: v })}
|
||||||
|
trackColor={{ false: "#e5e5e5", true: "#d97706" }}
|
||||||
|
thumbColor="white"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{notif.settings.thresholdEnabled && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<View className="flex-row items-center gap-x-3 py-3 px-4">
|
||||||
|
<View className="w-8 items-center">
|
||||||
|
<MaterialIcons name="battery-alert" size={20} color="#737373" />
|
||||||
|
</View>
|
||||||
|
<Text className="flex-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||||
|
Alert below
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center gap-x-1">
|
||||||
|
<TextInput
|
||||||
|
value={thresholdInput}
|
||||||
|
onChangeText={setThresholdInput}
|
||||||
|
onBlur={handleThresholdBlur}
|
||||||
|
keyboardType="numeric"
|
||||||
|
returnKeyType="done"
|
||||||
|
maxLength={2}
|
||||||
|
className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right"
|
||||||
|
style={{ minWidth: 28 }}
|
||||||
|
/>
|
||||||
|
<Text className="text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
|
% remaining
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* App section */}
|
||||||
|
<SectionHeader title="App" />
|
||||||
|
<SectionCard>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="upload-file"
|
icon="info-outline"
|
||||||
label="Import auth.json"
|
label="Version"
|
||||||
onPress={() => router.navigate("/(tabs)/codex")}
|
value={appVersion}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
)}
|
<Divider />
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Claude.ai section */}
|
|
||||||
<SectionHeader title="Claude.ai" />
|
|
||||||
<SectionCard>
|
|
||||||
{claudeKey ? (
|
|
||||||
<>
|
|
||||||
<SettingRow
|
|
||||||
icon="check-circle"
|
|
||||||
label="Connected"
|
|
||||||
value={`${claudeKey.slice(0, 16)}…`}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Divider />
|
|
||||||
<SettingRow
|
|
||||||
icon="delete-outline"
|
|
||||||
label="Clear session key"
|
|
||||||
onPress={handleClearClaude}
|
|
||||||
destructive
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="vpn-key"
|
icon="restart-alt"
|
||||||
label="Enter session key"
|
label="Re-run Setup Wizard"
|
||||||
onPress={() => router.navigate("/(tabs)/claude")}
|
onPress={handleResetSetup}
|
||||||
|
destructive
|
||||||
/>
|
/>
|
||||||
)}
|
</SectionCard>
|
||||||
</SectionCard>
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
{/* App section */}
|
|
||||||
<SectionHeader title="App" />
|
|
||||||
<SectionCard>
|
|
||||||
<SettingRow
|
|
||||||
icon="info-outline"
|
|
||||||
label="Version"
|
|
||||||
value={appVersion}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Divider />
|
|
||||||
<SettingRow
|
|
||||||
icon="restart-alt"
|
|
||||||
label="Re-run Setup Wizard"
|
|
||||||
onPress={handleResetSetup}
|
|
||||||
destructive
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,11 +2,12 @@ import "../global.css";
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import "@/lib/notifications"; // registers setNotificationHandler on app start
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="light" />
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
+65
-29
@@ -11,17 +11,28 @@ import {
|
|||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
import { saveClaudeSessionKey, loadClaudeSessionKey } from "@/lib/storage";
|
import {
|
||||||
|
saveClaudeSessionKey,
|
||||||
|
saveClaudeLastActiveOrg,
|
||||||
|
clearClaudeLastActiveOrg,
|
||||||
|
loadClaudeSessionKey,
|
||||||
|
loadClaudeLastActiveOrg,
|
||||||
|
} from "@/lib/storage";
|
||||||
|
|
||||||
export default function OnboardingClaudeScreen() {
|
export default function OnboardingClaudeScreen() {
|
||||||
const [savedKey, setSavedKey] = useState<string | null>(null);
|
const [savedKey, setSavedKey] = useState<string | null>(null);
|
||||||
|
const [savedOrg, setSavedOrg] = useState<string | null>(null);
|
||||||
const [pendingKey, setPendingKey] = useState("");
|
const [pendingKey, setPendingKey] = useState("");
|
||||||
|
const [pendingOrg, setPendingOrg] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadClaudeSessionKey().then((k) => {
|
Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]).then(
|
||||||
if (k) setSavedKey(k);
|
([k, o]) => {
|
||||||
});
|
if (k) setSavedKey(k);
|
||||||
|
if (o) setSavedOrg(o);
|
||||||
|
}
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const canSave = pendingKey.trim().startsWith("sk-ant-");
|
const canSave = pendingKey.trim().startsWith("sk-ant-");
|
||||||
@@ -32,11 +43,16 @@ export default function OnboardingClaudeScreen() {
|
|||||||
setError("Session key must start with sk-ant-");
|
setError("Session key must start with sk-ant-");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const orgTrimmed = pendingOrg.trim() || undefined;
|
||||||
await saveClaudeSessionKey(trimmed);
|
await saveClaudeSessionKey(trimmed);
|
||||||
|
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
|
||||||
|
else await clearClaudeLastActiveOrg();
|
||||||
setSavedKey(trimmed);
|
setSavedKey(trimmed);
|
||||||
|
setSavedOrg(orgTrimmed ?? null);
|
||||||
setPendingKey("");
|
setPendingKey("");
|
||||||
|
setPendingOrg("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [pendingKey]);
|
}, [pendingKey, pendingOrg]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-neutral-950">
|
<SafeAreaView className="flex-1 bg-neutral-950">
|
||||||
@@ -92,27 +108,38 @@ export default function OnboardingClaudeScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{savedKey ? (
|
{savedKey ? (
|
||||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800 mb-4">
|
<View className="bg-neutral-900 rounded-2xl border border-neutral-800 mb-4 overflow-hidden">
|
||||||
<View
|
<View className="flex-row items-center gap-x-3 p-4">
|
||||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
<View
|
||||||
style={{ backgroundColor: "#d9770622" }}
|
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||||
>
|
style={{ backgroundColor: "#d9770622" }}
|
||||||
<MaterialIcons name="check-circle" size={22} color="#d97706" />
|
>
|
||||||
|
<MaterialIcons name="check-circle" size={22} color="#d97706" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-white font-semibold">Connected</Text>
|
||||||
|
<Text className="text-neutral-500 text-sm font-mono">
|
||||||
|
{savedKey.slice(0, 16)}…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setSavedKey(null);
|
||||||
|
setSavedOrg(null);
|
||||||
|
setPendingKey("");
|
||||||
|
setPendingOrg("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-neutral-400 text-xs">Change</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
{savedOrg && (
|
||||||
<Text className="text-white font-semibold">Connected</Text>
|
<View className="border-t border-neutral-800 px-4 py-3">
|
||||||
<Text className="text-neutral-500 text-sm font-mono">
|
<Text className="text-neutral-500 text-xs font-mono">
|
||||||
{savedKey.slice(0, 16)}…
|
org: {savedOrg.slice(0, 8)}…
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
)}
|
||||||
onPress={() => {
|
|
||||||
setSavedKey(null);
|
|
||||||
setPendingKey("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-neutral-400 text-xs">Change</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View>
|
<View>
|
||||||
@@ -127,13 +154,22 @@ export default function OnboardingClaudeScreen() {
|
|||||||
setPendingKey(t);
|
setPendingKey(t);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="sk-ant-..."
|
placeholder="sk-ant-… (required)"
|
||||||
placeholderTextColor="#525252"
|
placeholderTextColor="#525252"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
|
className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
|
||||||
/>
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={pendingOrg}
|
||||||
|
onChangeText={setPendingOrg}
|
||||||
|
placeholder="lastActiveOrg UUID (optional)"
|
||||||
|
placeholderTextColor="#525252"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
|
||||||
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
@@ -149,11 +185,11 @@ export default function OnboardingClaudeScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View className="bg-neutral-900 rounded-xl p-4 border border-neutral-800">
|
<View className="bg-neutral-900 rounded-xl p-4 border border-neutral-800">
|
||||||
<Text className="text-neutral-400 text-xs font-semibold uppercase tracking-widest mb-2">
|
<Text className="text-neutral-400 text-xs font-semibold uppercase tracking-widest mb-2">
|
||||||
How to find your session key
|
How to find your credentials
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-neutral-500 text-sm leading-relaxed">
|
<Text className="text-neutral-500 text-sm leading-relaxed">
|
||||||
Open Claude.ai in Chrome → DevTools (F12) → Application → Cookies → claude.ai → find{" "}
|
Open Claude.ai in Chrome → DevTools (F12) → Application → Cookies → claude.ai{"\n"}
|
||||||
<Text className="text-neutral-300 font-mono">sessionKey</Text>
|
Copy <Text className="text-neutral-300 font-mono">sessionKey</Text> and optionally <Text className="text-neutral-300 font-mono">lastActiveOrg</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ module.exports = function (api) {
|
|||||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
"nativewind/babel",
|
"nativewind/babel",
|
||||||
],
|
],
|
||||||
|
plugins: ["react-native-reanimated/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+42
-33
@@ -4,15 +4,18 @@ import {
|
|||||||
loadClaudeSessionKey,
|
loadClaudeSessionKey,
|
||||||
saveClaudeSessionKey,
|
saveClaudeSessionKey,
|
||||||
clearClaudeSessionKey,
|
clearClaudeSessionKey,
|
||||||
|
loadClaudeLastActiveOrg,
|
||||||
|
saveClaudeLastActiveOrg,
|
||||||
|
clearClaudeLastActiveOrg,
|
||||||
} from "@/lib/storage";
|
} from "@/lib/storage";
|
||||||
import type { ClaudeUsageResponse } from "@/types/claude";
|
import type { ClaudeAuth, ClaudeUsageResponse } from "@/types/claude";
|
||||||
|
|
||||||
type Status = "idle" | "loading" | "success" | "error";
|
type Status = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
async function doFetch(
|
async function doFetch(
|
||||||
sessionKey: string,
|
auth: ClaudeAuth,
|
||||||
set: {
|
set: {
|
||||||
sessionKey: (v: string | null) => void;
|
auth: (v: ClaudeAuth | null) => void;
|
||||||
usage: (v: ClaudeUsageResponse | null) => void;
|
usage: (v: ClaudeUsageResponse | null) => void;
|
||||||
status: (v: Status) => void;
|
status: (v: Status) => void;
|
||||||
error: (v: string | null) => void;
|
error: (v: string | null) => void;
|
||||||
@@ -21,10 +24,13 @@ async function doFetch(
|
|||||||
set.status("loading");
|
set.status("loading");
|
||||||
set.error(null);
|
set.error(null);
|
||||||
try {
|
try {
|
||||||
const orgs = await fetchClaudeOrgs(sessionKey);
|
let orgUuid = auth.lastActiveOrg;
|
||||||
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
|
if (!orgUuid) {
|
||||||
const orgUuid = orgs[0].uuid;
|
const orgs = await fetchClaudeOrgs(auth);
|
||||||
const data = await fetchClaudeUsage(sessionKey, orgUuid);
|
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
|
||||||
|
orgUuid = orgs[0].uuid;
|
||||||
|
}
|
||||||
|
const data = await fetchClaudeUsage(auth, orgUuid);
|
||||||
set.usage(data);
|
set.usage(data);
|
||||||
set.status("success");
|
set.status("success");
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -33,29 +39,32 @@ async function doFetch(
|
|||||||
set.status("error");
|
set.status("error");
|
||||||
if (msg === "TOKEN_EXPIRED") {
|
if (msg === "TOKEN_EXPIRED") {
|
||||||
await clearClaudeSessionKey();
|
await clearClaudeSessionKey();
|
||||||
set.sessionKey(null);
|
set.auth(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClaudeUsage() {
|
export function useClaudeUsage() {
|
||||||
const [sessionKey, setSessionKey] = useState<string | null>(null);
|
const [auth, setAuth] = useState<ClaudeAuth | null>(null);
|
||||||
const [pendingKey, setPendingKey] = useState("");
|
const [pendingKey, setPendingKey] = useState("");
|
||||||
|
const [pendingOrg, setPendingOrg] = useState("");
|
||||||
const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null);
|
const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null);
|
||||||
const [status, setStatus] = useState<Status>("idle");
|
const [status, setStatus] = useState<Status>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const setters = { sessionKey: setSessionKey, usage: setUsage, status: setStatus, error: setError };
|
const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadClaudeSessionKey().then((stored) => {
|
Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]).then(
|
||||||
if (stored) setSessionKey(stored);
|
([key, org]) => {
|
||||||
});
|
if (key) setAuth({ sessionKey: key, lastActiveOrg: org ?? undefined });
|
||||||
|
}
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionKey) void doFetch(sessionKey, setters);
|
if (auth) void doFetch(auth, setters);
|
||||||
}, [sessionKey]);
|
}, [auth]);
|
||||||
|
|
||||||
const saveKey = useCallback(async () => {
|
const saveKey = useCallback(async () => {
|
||||||
const trimmed = pendingKey.trim();
|
const trimmed = pendingKey.trim();
|
||||||
@@ -63,47 +72,47 @@ export function useClaudeUsage() {
|
|||||||
setError("Session key must start with sk-ant-");
|
setError("Session key must start with sk-ant-");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const orgTrimmed = pendingOrg.trim() || undefined;
|
||||||
await saveClaudeSessionKey(trimmed);
|
await saveClaudeSessionKey(trimmed);
|
||||||
setSessionKey(trimmed);
|
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
|
||||||
|
else await clearClaudeLastActiveOrg();
|
||||||
|
setAuth({ sessionKey: trimmed, lastActiveOrg: orgTrimmed });
|
||||||
setPendingKey("");
|
setPendingKey("");
|
||||||
|
setPendingOrg("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [pendingKey]);
|
}, [pendingKey, pendingOrg]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!sessionKey) return;
|
if (!auth) return;
|
||||||
await doFetch(sessionKey, setters);
|
await doFetch(auth, setters);
|
||||||
}, [sessionKey]);
|
}, [auth]);
|
||||||
|
|
||||||
// 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 reload = useCallback(async () => {
|
||||||
const stored = await loadClaudeSessionKey();
|
const [key, org] = await Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]);
|
||||||
if (!stored) {
|
if (!key) {
|
||||||
setSessionKey(null);
|
setAuth(null);
|
||||||
setUsage(null);
|
setUsage(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If already set to the same string, setState bails out and [sessionKey]
|
// Creating a new object always triggers the [auth] useEffect even if values are identical
|
||||||
// effect won't fire, so call the fetch directly.
|
setAuth({ sessionKey: key, lastActiveOrg: org ?? undefined });
|
||||||
setSessionKey(stored);
|
|
||||||
void doFetch(stored, setters);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearKey = useCallback(async () => {
|
const clearKey = useCallback(async () => {
|
||||||
await clearClaudeSessionKey();
|
await clearClaudeSessionKey();
|
||||||
setSessionKey(null);
|
setAuth(null);
|
||||||
setUsage(null);
|
setUsage(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
setError(null);
|
setError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionKey,
|
auth,
|
||||||
pendingKey,
|
pendingKey,
|
||||||
setPendingKey,
|
setPendingKey,
|
||||||
|
pendingOrg,
|
||||||
|
setPendingOrg,
|
||||||
usage,
|
usage,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
loadNotifSettings,
|
||||||
|
saveNotifSettings,
|
||||||
|
type NotifSettings,
|
||||||
|
} from "@/lib/storage";
|
||||||
|
import {
|
||||||
|
requestPermissions,
|
||||||
|
getPermissionStatus,
|
||||||
|
scheduleDailyDigest,
|
||||||
|
cancelDailyDigest,
|
||||||
|
} from "@/lib/notifications";
|
||||||
|
|
||||||
|
export function useNotificationSettings() {
|
||||||
|
const [settings, setSettings] = useState<NotifSettings>({
|
||||||
|
dailyEnabled: false,
|
||||||
|
dailyHour: 9,
|
||||||
|
dailyMinute: 0,
|
||||||
|
thresholdEnabled: false,
|
||||||
|
thresholdPct: 20,
|
||||||
|
});
|
||||||
|
const [permissionStatus, setPermissionStatus] = useState("undetermined");
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([loadNotifSettings(), getPermissionStatus()]).then(
|
||||||
|
([stored, status]) => {
|
||||||
|
setSettings(stored);
|
||||||
|
setPermissionStatus(status);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const askPermissions = useCallback(async (): Promise<boolean> => {
|
||||||
|
const granted = await requestPermissions();
|
||||||
|
setPermissionStatus(granted ? "granted" : "denied");
|
||||||
|
return granted;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
async (patch: Partial<NotifSettings>) => {
|
||||||
|
const next = { ...settings, ...patch };
|
||||||
|
setSettings(next);
|
||||||
|
await saveNotifSettings(next);
|
||||||
|
|
||||||
|
// Keep scheduled notification in sync when toggling or changing time
|
||||||
|
const dailyChanged =
|
||||||
|
"dailyEnabled" in patch ||
|
||||||
|
"dailyHour" in patch ||
|
||||||
|
"dailyMinute" in patch;
|
||||||
|
if (dailyChanged) {
|
||||||
|
if (next.dailyEnabled) {
|
||||||
|
await scheduleDailyDigest(next.dailyHour, next.dailyMinute, null, null);
|
||||||
|
} else {
|
||||||
|
await cancelDailyDigest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { settings, permissionStatus, loaded, askPermissions, update };
|
||||||
|
}
|
||||||
+24
-15
@@ -1,24 +1,33 @@
|
|||||||
import type { ClaudeOrg, ClaudeUsageResponse } from "@/types/claude";
|
import type { ClaudeAuth, ClaudeOrg, ClaudeUsageResponse } from "@/types/claude";
|
||||||
|
|
||||||
const BASE_URL = "https://claude.ai/api";
|
const BASE_URL = "https://claude.ai/api";
|
||||||
|
|
||||||
function cookieHeader(sessionKey: string): Record<string, string> {
|
const BROWSER_UA =
|
||||||
|
"Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36";
|
||||||
|
|
||||||
|
function buildHeaders(auth: ClaudeAuth): Record<string, string> {
|
||||||
|
const cookies = [`sessionKey=${auth.sessionKey}`];
|
||||||
|
if (auth.lastActiveOrg) cookies.push(`lastActiveOrg=${auth.lastActiveOrg}`);
|
||||||
return {
|
return {
|
||||||
Cookie: `sessionKey=${sessionKey}`,
|
Cookie: cookies.join("; "),
|
||||||
"Accept": "application/json",
|
Accept: "application/json, text/plain, */*",
|
||||||
"Origin": "https://claude.ai",
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"User-Agent": BROWSER_UA,
|
||||||
|
Referer: "https://claude.ai/",
|
||||||
|
Origin: "https://claude.ai",
|
||||||
|
"anthropic-client-platform": "web_claude_to_cc_migration",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchClaudeOrgs(sessionKey: string): Promise<ClaudeOrg[]> {
|
export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise<ClaudeOrg[]> {
|
||||||
const response = await fetch(`${BASE_URL}/organizations`, {
|
const response = await fetch(`${BASE_URL}/organizations`, {
|
||||||
headers: cookieHeader(sessionKey),
|
headers: buildHeaders(auth),
|
||||||
|
credentials: "omit",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
throw new Error("TOKEN_EXPIRED");
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
await response.text().catch(() => null);
|
||||||
|
if (response.status === 401 || response.status === 403) throw new Error("TOKEN_EXPIRED");
|
||||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,17 +35,17 @@ export async function fetchClaudeOrgs(sessionKey: string): Promise<ClaudeOrg[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchClaudeUsage(
|
export async function fetchClaudeUsage(
|
||||||
sessionKey: string,
|
auth: ClaudeAuth,
|
||||||
orgUuid: string
|
orgUuid: string
|
||||||
): Promise<ClaudeUsageResponse> {
|
): Promise<ClaudeUsageResponse> {
|
||||||
const response = await fetch(`${BASE_URL}/organizations/${orgUuid}/usage`, {
|
const response = await fetch(`${BASE_URL}/organizations/${orgUuid}/usage`, {
|
||||||
headers: cookieHeader(sessionKey),
|
headers: buildHeaders(auth),
|
||||||
|
credentials: "omit",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
throw new Error("TOKEN_EXPIRED");
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
await response.text().catch(() => null);
|
||||||
|
if (response.status === 401 || response.status === 403) throw new Error("TOKEN_EXPIRED");
|
||||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageRespon
|
|||||||
throw new Error("TOKEN_EXPIRED");
|
throw new Error("TOKEN_EXPIRED");
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
await response.text().catch(() => null);
|
||||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-8
@@ -1,5 +1,4 @@
|
|||||||
import * as DocumentPicker from "expo-document-picker";
|
import * as DocumentPicker from "expo-document-picker";
|
||||||
import { File } from "expo-file-system/next";
|
|
||||||
import type { CodexAuth } from "@/types/codex";
|
import type { CodexAuth } from "@/types/codex";
|
||||||
|
|
||||||
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
||||||
@@ -8,13 +7,11 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
|||||||
copyToCacheDirectory: true,
|
copyToCacheDirectory: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.canceled) {
|
if (result.canceled) throw new Error("PICKER_CANCELLED");
|
||||||
throw new Error("PICKER_CANCELLED");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { uri } = result.assets[0];
|
const { uri } = result.assets[0];
|
||||||
const file = new File(uri);
|
const response = await fetch(uri);
|
||||||
const text = file.text();
|
const text = await response.text();
|
||||||
|
|
||||||
let parsed: Record<string, unknown>;
|
let parsed: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -23,8 +20,9 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
|||||||
throw new Error("INVALID_JSON");
|
throw new Error("INVALID_JSON");
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = parsed["accessToken"] as string | undefined;
|
const tokens = parsed["tokens"] as Record<string, unknown> | undefined;
|
||||||
const accountId = parsed["accountId"] as string | undefined;
|
const accessToken = (tokens?.["access_token"] ?? parsed["accessToken"]) as string | undefined;
|
||||||
|
const accountId = (tokens?.["account_id"] ?? parsed["accountId"]) as string | undefined;
|
||||||
|
|
||||||
if (!accessToken || typeof accessToken !== "string") {
|
if (!accessToken || typeof accessToken !== "string") {
|
||||||
throw new Error("MISSING_ACCESS_TOKEN");
|
throw new Error("MISSING_ACCESS_TOKEN");
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import type * as NotificationsType from "expo-notifications";
|
||||||
|
import {
|
||||||
|
loadNotifSettings,
|
||||||
|
loadNotifThresholdLastFired,
|
||||||
|
saveNotifThresholdLastFired,
|
||||||
|
} from "@/lib/storage";
|
||||||
|
import type { ClaudeUsageResponse } from "@/types/claude";
|
||||||
|
import type { CodexUsageResponse } from "@/types/codex";
|
||||||
|
|
||||||
|
// expo-notifications push notifications were removed from Expo Go in SDK 53.
|
||||||
|
// Use require() so the initialization error is caught gracefully in Expo Go.
|
||||||
|
let Notifications: typeof NotificationsType | null = null;
|
||||||
|
try {
|
||||||
|
Notifications = require("expo-notifications") as typeof NotificationsType;
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
shouldPlaySound: false,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Running in Expo Go or native module unavailable — notifications disabled.
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestPermissions(): Promise<boolean> {
|
||||||
|
if (!Notifications) return false;
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
return status === "granted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPermissionStatus(): Promise<string> {
|
||||||
|
if (!Notifications) return "undetermined";
|
||||||
|
const { status } = await Notifications.getPermissionsAsync();
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAILY_ID = "codexbar-daily-digest";
|
||||||
|
|
||||||
|
export async function scheduleDailyDigest(
|
||||||
|
hour: number,
|
||||||
|
minute: number,
|
||||||
|
claudeUsage: ClaudeUsageResponse | null,
|
||||||
|
codexUsage: CodexUsageResponse | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (!Notifications) return;
|
||||||
|
try {
|
||||||
|
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (claudeUsage) {
|
||||||
|
parts.push(`Claude 7d: ${Math.round(claudeUsage.seven_day.utilization)}% used`);
|
||||||
|
}
|
||||||
|
if (codexUsage) {
|
||||||
|
parts.push(
|
||||||
|
`Codex: ${Math.round(codexUsage.rate_limit.secondary_window.used_percent)}% used`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
identifier: DAILY_ID,
|
||||||
|
content: {
|
||||||
|
title: "Usage Digest",
|
||||||
|
body:
|
||||||
|
parts.length > 0
|
||||||
|
? parts.join(" · ")
|
||||||
|
: "Open to check your usage limits",
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: Notifications.SchedulableTriggerInputTypes.CALENDAR,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
repeats: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelDailyDigest(): Promise<void> {
|
||||||
|
if (!Notifications) return;
|
||||||
|
try {
|
||||||
|
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThresholdAlert {
|
||||||
|
service: string;
|
||||||
|
window: string;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThresholdAlerts(
|
||||||
|
thresholdPct: number,
|
||||||
|
claudeUsage: ClaudeUsageResponse | null,
|
||||||
|
codexUsage: CodexUsageResponse | null
|
||||||
|
): ThresholdAlert[] {
|
||||||
|
const alerts: ThresholdAlert[] = [];
|
||||||
|
|
||||||
|
if (claudeUsage) {
|
||||||
|
const remaining = 100 - claudeUsage.seven_day.utilization;
|
||||||
|
if (remaining <= thresholdPct) {
|
||||||
|
alerts.push({ service: "Claude", window: "7-day", remaining });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codexUsage) {
|
||||||
|
const remaining = 100 - codexUsage.rate_limit.secondary_window.used_percent;
|
||||||
|
if (remaining <= thresholdPct) {
|
||||||
|
alerts.push({ service: "Codex", window: "weekly", remaining });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call this after every successful usage fetch. Reschedules daily digest and fires threshold alerts. */
|
||||||
|
export async function onUsageDataLoaded(
|
||||||
|
claudeUsage: ClaudeUsageResponse | null,
|
||||||
|
codexUsage: CodexUsageResponse | null
|
||||||
|
): Promise<void> {
|
||||||
|
const settings = await loadNotifSettings();
|
||||||
|
|
||||||
|
if (settings.dailyEnabled) {
|
||||||
|
await scheduleDailyDigest(
|
||||||
|
settings.dailyHour,
|
||||||
|
settings.dailyMinute,
|
||||||
|
claudeUsage,
|
||||||
|
codexUsage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.thresholdEnabled) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const lastFired = await loadNotifThresholdLastFired();
|
||||||
|
if (lastFired !== today) {
|
||||||
|
const alerts = buildThresholdAlerts(
|
||||||
|
settings.thresholdPct,
|
||||||
|
claudeUsage,
|
||||||
|
codexUsage
|
||||||
|
);
|
||||||
|
if (alerts.length > 0 && Notifications) {
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: "Low quota warning",
|
||||||
|
body: alerts
|
||||||
|
.map(
|
||||||
|
(a) =>
|
||||||
|
`${a.service} ${a.window}: ${Math.round(a.remaining)}% remaining`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
await saveNotifThresholdLastFired(today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,59 @@ import * as SecureStore from "expo-secure-store";
|
|||||||
const KEYS = {
|
const KEYS = {
|
||||||
CODEX_AUTH: "codexbar_codex_auth",
|
CODEX_AUTH: "codexbar_codex_auth",
|
||||||
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
|
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
|
||||||
|
CLAUDE_LAST_ACTIVE_ORG: "codexbar_claude_last_active_org",
|
||||||
|
NOTIF_DAILY_ENABLED: "notifDailyEnabled",
|
||||||
|
NOTIF_DAILY_HOUR: "notifDailyHour",
|
||||||
|
NOTIF_DAILY_MINUTE: "notifDailyMinute",
|
||||||
|
NOTIF_THRESHOLD_ENABLED: "notifThresholdEnabled",
|
||||||
|
NOTIF_THRESHOLD_PCT: "notifThresholdPct",
|
||||||
|
NOTIF_THRESHOLD_LAST_FIRED: "notifThresholdLastFired",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export interface NotifSettings {
|
||||||
|
dailyEnabled: boolean;
|
||||||
|
dailyHour: number;
|
||||||
|
dailyMinute: number;
|
||||||
|
thresholdEnabled: boolean;
|
||||||
|
/** Alert when remaining quota falls below this % (e.g. 20 = fire when < 20% left) */
|
||||||
|
thresholdPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadNotifSettings(): Promise<NotifSettings> {
|
||||||
|
const [de, dh, dm, te, tp] = await Promise.all([
|
||||||
|
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_ENABLED),
|
||||||
|
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_HOUR),
|
||||||
|
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_MINUTE),
|
||||||
|
SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_ENABLED),
|
||||||
|
SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_PCT),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
dailyEnabled: de === "true",
|
||||||
|
dailyHour: dh !== null ? parseInt(dh, 10) : 9,
|
||||||
|
dailyMinute: dm !== null ? parseInt(dm, 10) : 0,
|
||||||
|
thresholdEnabled: te === "true",
|
||||||
|
thresholdPct: tp !== null ? parseInt(tp, 10) : 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveNotifSettings(s: NotifSettings): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_ENABLED, String(s.dailyEnabled)),
|
||||||
|
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_HOUR, String(s.dailyHour)),
|
||||||
|
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_MINUTE, String(s.dailyMinute)),
|
||||||
|
SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_ENABLED, String(s.thresholdEnabled)),
|
||||||
|
SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_PCT, String(s.thresholdPct)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadNotifThresholdLastFired(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_LAST_FIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveNotifThresholdLastFired(date: string): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_LAST_FIRED, date);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveCodexAuth(auth: {
|
export async function saveCodexAuth(auth: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -29,10 +80,23 @@ export async function loadClaudeSessionKey(): Promise<string | null> {
|
|||||||
return SecureStore.getItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
return SecureStore.getItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveClaudeLastActiveOrg(orgId: string): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadClaudeLastActiveOrg(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearClaudeLastActiveOrg(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
|
||||||
|
}
|
||||||
|
|
||||||
export async function clearCodexAuth(): Promise<void> {
|
export async function clearCodexAuth(): Promise<void> {
|
||||||
await SecureStore.deleteItemAsync(KEYS.CODEX_AUTH);
|
await SecureStore.deleteItemAsync(KEYS.CODEX_AUTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearClaudeSessionKey(): Promise<void> {
|
export async function clearClaudeSessionKey(): Promise<void> {
|
||||||
await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
||||||
|
await SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-6
@@ -5,11 +5,17 @@ export function formatResetCountdown(resetAtSeconds: number): string {
|
|||||||
if (diffSeconds <= 0) return "resetting now";
|
if (diffSeconds <= 0) return "resetting now";
|
||||||
if (diffSeconds < 60) return "resets in <1m";
|
if (diffSeconds < 60) return "resets in <1m";
|
||||||
|
|
||||||
const hours = Math.floor(diffSeconds / 3600);
|
const totalHours = Math.floor(diffSeconds / 3600);
|
||||||
const minutes = Math.floor((diffSeconds % 3600) / 60);
|
const minutes = Math.floor((diffSeconds % 3600) / 60);
|
||||||
|
|
||||||
if (hours > 0 && minutes > 0) return `resets in ${hours}h ${minutes}m`;
|
if (totalHours >= 24) {
|
||||||
if (hours > 0) return `resets in ${hours}h`;
|
const days = Math.floor(totalHours / 24);
|
||||||
|
const hours = totalHours % 24;
|
||||||
|
if (hours > 0) return `resets in ${days}d ${hours}h`;
|
||||||
|
return `resets in ${days}d`;
|
||||||
|
}
|
||||||
|
if (totalHours > 0 && minutes > 0) return `resets in ${totalHours}h ${minutes}m`;
|
||||||
|
if (totalHours > 0) return `resets in ${totalHours}h`;
|
||||||
return `resets in ${minutes}m`;
|
return `resets in ${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +25,11 @@ export function formatResetCountdownISO(isoString: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function windowLabel(limitWindowSeconds: number): string {
|
export function windowLabel(limitWindowSeconds: number): string {
|
||||||
if (limitWindowSeconds <= 3600) return "1h window";
|
const hours = limitWindowSeconds / 3600;
|
||||||
if (limitWindowSeconds <= 86400) return "24h window";
|
if (hours < 24) return `${Math.round(hours)}h`;
|
||||||
return `${Math.round(limitWindowSeconds / 3600)}h window`;
|
const days = hours / 24;
|
||||||
|
const wholeDays = Math.floor(days);
|
||||||
|
const remainderHours = Math.round(hours - wholeDays * 24);
|
||||||
|
if (remainderHours === 0) return `${wholeDays}d`;
|
||||||
|
return `${wholeDays}d ${remainderHours}h`;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+61
-32
@@ -15,14 +15,18 @@
|
|||||||
"expo-document-picker": "~56.0.4",
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
|
"expo-notifications": "~56.0.18",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
"expo-secure-store": "~56.0.4",
|
"expo-secure-store": "~56.0.4",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"nativewind": "^4.2.6",
|
"nativewind": "^4.2.6",
|
||||||
"react": "^19.2.7",
|
"react": "19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
|
"react-native-reanimated": "4.3.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2"
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-worklets": "^0.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
@@ -572,7 +576,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
|
||||||
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
|
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -1054,7 +1057,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
|
||||||
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
|
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -1070,7 +1072,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
|
||||||
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
|
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -2907,6 +2908,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/badgin": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -3738,6 +3745,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-application": {
|
||||||
|
"version": "56.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz",
|
||||||
|
"integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "56.0.18",
|
"version": "56.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz",
|
||||||
@@ -3854,6 +3870,24 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-notifications": {
|
||||||
|
"version": "56.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.18.tgz",
|
||||||
|
"integrity": "sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/image-utils": "^0.10.1",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"badgin": "^1.1.5",
|
||||||
|
"expo-application": "~56.0.3",
|
||||||
|
"expo-constants": "~56.0.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-router": {
|
"node_modules/expo-router": {
|
||||||
"version": "56.2.11",
|
"version": "56.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.11.tgz",
|
||||||
@@ -6592,9 +6626,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6611,16 +6645,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.7"
|
"react": "^19.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-fast-compare": {
|
"node_modules/react-fast-compare": {
|
||||||
@@ -7013,26 +7046,24 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||||
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
|
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-reanimated": {
|
"node_modules/react-native-reanimated": {
|
||||||
"version": "4.5.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
|
||||||
"integrity": "sha512-+iPfvK34PKKYP/p/4TaBliFkbfvjGDIvXuiiaxvISP5ip7sWegvlacwU/uAV6zNDSSmX0tDyER7PurPMKGDipA==",
|
"integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-native-is-edge-to-edge": "^1.3.1",
|
"react-native-is-edge-to-edge": "^1.3.1",
|
||||||
"semver": "^7.7.3"
|
"semver": "^7.7.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "0.83 - 0.86",
|
"react-native": "0.81 - 0.85",
|
||||||
"react-native-worklets": "0.10.x"
|
"react-native-worklets": "0.8.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-safe-area-context": {
|
"node_modules/react-native-safe-area-context": {
|
||||||
@@ -7060,30 +7091,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-worklets": {
|
"node_modules/react-native-worklets": {
|
||||||
"version": "0.10.0",
|
"version": "0.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
|
||||||
"integrity": "sha512-JhE6IxDf6iabC0qu3+TAKA4v9RlluXmoIngPQX7/QUByf75lfrsHZ6/dQhyjEWnp1EEQiwzz8Cpew140ZcewDw==",
|
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||||
"@babel/plugin-transform-class-properties": "^7.28.6",
|
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||||
"@babel/plugin-transform-classes": "^7.28.6",
|
"@babel/plugin-transform-classes": "^7.28.4",
|
||||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
|
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
|
||||||
"@babel/plugin-transform-optional-chaining": "^7.28.6",
|
"@babel/plugin-transform-optional-chaining": "^7.27.1",
|
||||||
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
||||||
"@babel/plugin-transform-template-literals": "^7.27.1",
|
"@babel/plugin-transform-template-literals": "^7.27.1",
|
||||||
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.27.1",
|
||||||
"@babel/types": "^7.27.1",
|
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"semver": "^7.7.4"
|
"semver": "^7.7.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@babel/core": "*",
|
"@babel/core": "*",
|
||||||
"@react-native/metro-config": "*",
|
"@react-native/metro-config": "*",
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "0.83 - 0.86"
|
"react-native": "0.81 - 0.85"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native/node_modules/commander": {
|
"node_modules/react-native/node_modules/commander": {
|
||||||
|
|||||||
+6
-2
@@ -10,14 +10,18 @@
|
|||||||
"expo-document-picker": "~56.0.4",
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
|
"expo-notifications": "~56.0.18",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
"expo-secure-store": "~56.0.4",
|
"expo-secure-store": "~56.0.4",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"nativewind": "^4.2.6",
|
"nativewind": "^4.2.6",
|
||||||
"react": "^19.2.7",
|
"react": "19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
|
"react-native-reanimated": "4.3.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2"
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-worklets": "^0.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export interface ClaudeAuth {
|
||||||
|
sessionKey: string;
|
||||||
|
lastActiveOrg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClaudeOrg {
|
export interface ClaudeOrg {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user