@@ -1,5 +0,0 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Build Android APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
type: choice
|
||||
description: EAS build profile to use.
|
||||
default: production
|
||||
options:
|
||||
- preview
|
||||
- production
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
name: Build Android APK
|
||||
type: build
|
||||
environment: ${{ inputs.profile }}
|
||||
params:
|
||||
platform: android
|
||||
profile: ${{ inputs.profile }}
|
||||
message: Android ${{ inputs.profile }} APK from ${{ github.ref_name }}
|
||||
@@ -1,57 +0,0 @@
|
||||
name: Publish OTA updates
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'app/**'
|
||||
- 'components/**'
|
||||
- 'hooks/**'
|
||||
- 'lib/**'
|
||||
- 'types/**'
|
||||
- 'assets/**'
|
||||
- 'App.tsx'
|
||||
- 'index.ts'
|
||||
- 'global.css'
|
||||
- 'tailwind.config.js'
|
||||
- 'babel.config.js'
|
||||
- 'metro.config.js'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
type: choice
|
||||
description: Update channel to publish.
|
||||
default: production
|
||||
options:
|
||||
- preview
|
||||
- production
|
||||
environment:
|
||||
type: choice
|
||||
description: EAS environment to use for the update job.
|
||||
default: production
|
||||
options:
|
||||
- preview
|
||||
- production
|
||||
message:
|
||||
description: Optional update message override.
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
publish_main:
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
name: Publish production update
|
||||
type: update
|
||||
environment: production
|
||||
params:
|
||||
channel: production
|
||||
platform: all
|
||||
|
||||
publish_manual:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
name: Publish selected update
|
||||
type: update
|
||||
environment: ${{ inputs.environment }}
|
||||
params:
|
||||
channel: ${{ inputs.channel }}
|
||||
message: ${{ inputs.message }}
|
||||
platform: all
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto
|
||||
*.gradle text eol=lf
|
||||
*.kt text eol=lf
|
||||
*.kts text eol=lf
|
||||
*.properties text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.md text eol=lf
|
||||
gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -1,29 +1,21 @@
|
||||
name: CodexBar Mobile Build
|
||||
name: android-release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**/*.ts"
|
||||
- "**/*.tsx"
|
||||
- "**/*.js"
|
||||
- "**/*.json"
|
||||
- ".gitea/workflows/**"
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🏗 Setup repo
|
||||
uses: actions/checkout@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🏗 Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: 🏗 Setup Java
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -46,62 +38,48 @@ jobs:
|
||||
- name: Set up Gradle cache
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: 🏗 Setup Expo and EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-version: latest
|
||||
packager: npm
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: 👷 Build app
|
||||
- name: Write release keystore
|
||||
env:
|
||||
CODEXMOBILE_KEYSTORE_BASE64: ${{ secrets.CODEXMOBILE_KEYSTORE_BASE64 }}
|
||||
CODEXMOBILE_KEYSTORE_PASSWORD: ${{ secrets.CODEXMOBILE_KEYSTORE_PASSWORD }}
|
||||
CODEXMOBILE_KEY_ALIAS: ${{ secrets.CODEXMOBILE_KEY_ALIAS }}
|
||||
CODEXMOBILE_KEY_PASSWORD: ${{ secrets.CODEXMOBILE_KEY_PASSWORD }}
|
||||
run: |
|
||||
eas build --local \
|
||||
--non-interactive \
|
||||
--output=./app-build \
|
||||
--platform=android \
|
||||
--profile=preview
|
||||
if [ -z "$CODEXMOBILE_KEYSTORE_BASE64" ] || [ -z "$CODEXMOBILE_KEYSTORE_PASSWORD" ] || [ -z "$CODEXMOBILE_KEY_ALIAS" ] || [ -z "$CODEXMOBILE_KEY_PASSWORD" ]; then
|
||||
echo "Missing release signing secrets" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$CODEXMOBILE_KEYSTORE_BASE64" | base64 -d > "$HOME/codexmobile-release.jks"
|
||||
{
|
||||
echo "CODEXMOBILE_STORE_FILE=$HOME/codexmobile-release.jks"
|
||||
echo "CODEXMOBILE_STORE_PASSWORD=$CODEXMOBILE_KEYSTORE_PASSWORD"
|
||||
echo "CODEXMOBILE_KEY_ALIAS=$CODEXMOBILE_KEY_ALIAS"
|
||||
echo "CODEXMOBILE_KEY_PASSWORD=$CODEXMOBILE_KEY_PASSWORD"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: 📝 Rename build to APK
|
||||
run: mv app-build codexbar-release.apk
|
||||
|
||||
- name: 🗜 Zip APK
|
||||
run: zip codexbar-release.zip codexbar-release.apk
|
||||
|
||||
- name: 📤 Upload build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: codexbar-android-preview-build
|
||||
path: codexbar-release.zip
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-android]
|
||||
steps:
|
||||
- name: 🏗 Setup repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: 📥 Download Android artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: codexbar-android-preview-build
|
||||
|
||||
- name: 🏷 Create tag
|
||||
- name: Build release APK
|
||||
run: |
|
||||
TAG="codexbar-build-$(git rev-parse --short HEAD)"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
chmod +x gradlew
|
||||
./gradlew assembleRelease
|
||||
|
||||
- name: 🚀 Create release
|
||||
uses: softprops/action-gh-release@v1
|
||||
- name: Compute release tag
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
echo "RELEASE_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "RELEASE_TAG=main-${GITHUB_RUN_NUMBER}-${GITHUB_SHA::7}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Publish Gitea release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
env:
|
||||
NODE_OPTIONS: --experimental-fetch
|
||||
with:
|
||||
tag_name: ${{ env.RELEASE_TAG }}
|
||||
name: ${{ env.RELEASE_TAG }}
|
||||
files: codexbar-release.zip
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
files: |
|
||||
app/build/outputs/apk/release/*.apk
|
||||
body: |
|
||||
CodexMobile release built from ${{ github.ref_name }} at ${{ github.sha }}.
|
||||
prerelease: false
|
||||
draft: false
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
keystore.properties
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.keystore
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
*.orig.*
|
||||
.env.ci
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
.DS_Store
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Expo HAS CHANGED
|
||||
|
||||
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
|
||||
@@ -1,20 +0,0 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.tsx to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx expo start # Start Expo dev server (scan QR with Expo Go)
|
||||
npx expo start --android
|
||||
npx expo start --ios
|
||||
npx expo start --web
|
||||
|
||||
eas build --profile preview --platform android # Build APK via EAS
|
||||
eas build --profile production --platform android
|
||||
```
|
||||
|
||||
There is no test suite or lint script configured in package.json.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is an Expo SDK 56 app that monitors AI service usage limits for **Codex CLI** and **Claude.ai**. It fetches usage data from their private REST APIs using credentials the user provides.
|
||||
|
||||
### Navigation (Expo Router v3)
|
||||
|
||||
```
|
||||
app/index.tsx → checks onboarding state, redirects
|
||||
app/onboarding/ → stack: welcome → codex → claude → done
|
||||
app/(tabs)/
|
||||
_layout.tsx → bottom tab navigator (4 tabs)
|
||||
index.tsx → Dashboard: both services side-by-side
|
||||
codex.tsx → Codex CLI detailed view
|
||||
claude.tsx → Claude.ai detailed view (not yet in git)
|
||||
settings.tsx → Settings (not yet in git)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
Each service follows the same pattern:
|
||||
|
||||
1. **Hook** (`hooks/useCodexUsage.ts`, `hooks/useClaudeUsage.ts`) — manages auth + usage state, persists credentials to SecureStore, exposes `refresh()` / `clear()`.
|
||||
2. **API module** (`lib/api/codexApi.ts`, `lib/api/claudeApi.ts`) — thin fetch wrappers with no side effects.
|
||||
3. **Screen** — calls the hook, renders `ServiceStatusCard` or the detailed usage UI.
|
||||
|
||||
### Credential Storage
|
||||
|
||||
All credentials live in `expo-secure-store` (never AsyncStorage). Keys:
|
||||
|
||||
- `codex_auth` — serialized `CodexAuth` object (Bearer token + optional account ID), imported from the user's `auth.json` file via `expo-document-picker`.
|
||||
- `claude_session_key` — cookie value used as `Cookie: sessionKey=…` on claude.ai API calls.
|
||||
|
||||
`TOKEN_EXPIRED` errors automatically clear the stored credential and prompt re-entry.
|
||||
|
||||
### Styling
|
||||
|
||||
NativeWind v4 (Tailwind CSS for React Native). Brand colors:
|
||||
- Codex: `#10a37f` (teal)
|
||||
- Claude: `#d97706` (amber)
|
||||
|
||||
Use Tailwind utility classes on all components. Dark mode is supported via `dark:` variants.
|
||||
|
||||
### Key Types
|
||||
|
||||
- `lib/types/codex.ts` — `CodexAuth`, `CodexUsageResponse`, `UsageWindow`
|
||||
- `lib/types/claude.ts` — `ClaudeOrg`, `ClaudeUsageResponse`, `ClaudeWindowUsage`
|
||||
|
||||
### Path Alias
|
||||
|
||||
`@/` maps to the repo root (configured in `tsconfig.json`). Use `@/components/...`, `@/lib/...`, `@/hooks/...` everywhere.
|
||||
@@ -0,0 +1,20 @@
|
||||
# CodexMobile
|
||||
|
||||
Native Android app for monitoring Codex CLI and Claude.ai usage windows, reset credits, and local quota reminders.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Release builds require signing values from environment variables or `keystore.properties`:
|
||||
|
||||
```properties
|
||||
CODEXMOBILE_STORE_FILE=/path/to/codexmobile-release.jks
|
||||
CODEXMOBILE_STORE_PASSWORD=...
|
||||
CODEXMOBILE_KEY_ALIAS=codexmobile-release
|
||||
CODEXMOBILE_KEY_PASSWORD=...
|
||||
```
|
||||
|
||||
CI secrets are generated into `.env.ci` for copying into Gitea.
|
||||
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Codexbar",
|
||||
"slug": "codexbar-mobile",
|
||||
"description": "Monitor Codex CLI and Claude.ai usage limits, resets, and credits at a glance.",
|
||||
"scheme": "codexbar",
|
||||
"version": "1.0.0",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/9196c5ee-4c6e-4229-9a99-54b50afe38e8"
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"backgroundColor": "#090D11",
|
||||
"primaryColor": "#10A37F",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"bundleIdentifier": "dev.reversed.codexbar",
|
||||
"buildNumber": "1",
|
||||
"supportsTablet": true,
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "dev.reversed.codexbar",
|
||||
"versionCode": 1,
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#090D11",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": true
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"imageWidth": 220,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#090D11",
|
||||
"dark": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"backgroundColor": "#090D11"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-secure-store",
|
||||
{
|
||||
"configureAndroidBackup": true,
|
||||
"faceIDPermission": "Allow Codexbar to access your securely stored credentials."
|
||||
}
|
||||
],
|
||||
"expo-document-picker",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/notification-icon.png",
|
||||
"color": "#10A37F",
|
||||
"defaultChannel": "usage-alerts"
|
||||
}
|
||||
],
|
||||
"expo-font"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "9196c5ee-4c6e-4229-9a99-54b50afe38e8"
|
||||
}
|
||||
},
|
||||
"owner": "spacebanane"
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Tabs } from "expo-router";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: COLORS.codex,
|
||||
tabBarInactiveTintColor: isDark ? "#52525b" : "#a1a1aa",
|
||||
tabBarStyle: {
|
||||
backgroundColor: isDark ? "#09090b" : "#ffffff",
|
||||
borderTopColor: isDark ? "#27272a" : "#f4f4f5",
|
||||
height: 56 + insets.bottom,
|
||||
paddingBottom: 8 + insets.bottom,
|
||||
paddingTop: 6,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "600",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<MaterialIcons name="home" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen name="codex" options={{ href: null }} />
|
||||
<Tabs.Screen name="claude" options={{ href: null }} />
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<MaterialIcons name="settings" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import {
|
||||
ScrollView,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
|
||||
import { UsageStat } from "@/components/UsageStat";
|
||||
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||
import { LastUpdated } from "@/components/LastUpdated";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
function ClaudeTabContent() {
|
||||
const {
|
||||
auth,
|
||||
pendingKey,
|
||||
setPendingKey,
|
||||
pendingOrg,
|
||||
setPendingOrg,
|
||||
usage,
|
||||
lastFetchedAt,
|
||||
status,
|
||||
error,
|
||||
keyValidationError,
|
||||
canSaveKey,
|
||||
saveKey,
|
||||
refresh,
|
||||
clearKey,
|
||||
} = useClaudeUsage();
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
||||
edges={["top", "bottom"]}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1"
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 32 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={status === "loading"}
|
||||
onRefresh={refresh}
|
||||
tintColor={COLORS.claude}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center gap-x-3 py-5">
|
||||
<View
|
||||
className="w-9 h-9 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${COLORS.claude}22` }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={18} color={COLORS.claude} />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
||||
Claude.ai
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400 mt-0.5">
|
||||
Usage windows
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Configuration card */}
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 mb-4">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3">
|
||||
Configuration
|
||||
</Text>
|
||||
<ErrorMessage
|
||||
message={error && (status === "idle" || status === "error") ? error : null}
|
||||
/>
|
||||
{auth ? (
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-x-2.5">
|
||||
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<Text className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Connected
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-mono">
|
||||
{auth.sessionKey.slice(0, 10)}…
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={clearKey}
|
||||
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
|
||||
>
|
||||
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Clear
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<TextInput
|
||||
value={pendingKey}
|
||||
onChangeText={setPendingKey}
|
||||
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-white dark:bg-neutral-800 mb-2"
|
||||
/>
|
||||
{keyValidationError && (
|
||||
<Text selectable className="mb-2 text-xs text-red-500">
|
||||
{keyValidationError}
|
||||
</Text>
|
||||
)}
|
||||
<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"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={saveKey}
|
||||
disabled={!canSaveKey}
|
||||
className="py-3.5 px-4 rounded-xl items-center"
|
||||
style={{
|
||||
backgroundColor: canSaveKey ? COLORS.claude : COLORS.disabled,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-semibold text-sm"
|
||||
style={{ color: canSaveKey ? "white" : COLORS.muted }}
|
||||
>
|
||||
Save Session Key
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className="bg-neutral-50 dark:bg-neutral-800 rounded-xl p-3.5 mt-3 border border-neutral-100 dark:border-neutral-700">
|
||||
<Text className="text-xs font-semibold text-neutral-400 uppercase tracking-widest mb-1.5">
|
||||
Where to find it
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
|
||||
Chrome DevTools → Application → Cookies → claude.ai → copy{" "}
|
||||
<Text className="font-mono text-neutral-600 dark:text-neutral-300">sessionKey</Text>
|
||||
{" "}and optionally{" "}
|
||||
<Text className="font-mono text-neutral-600 dark:text-neutral-300">lastActiveOrg</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Usage card */}
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3">
|
||||
Usage
|
||||
</Text>
|
||||
|
||||
{status === "idle" && !auth && (
|
||||
<View className="items-center py-8">
|
||||
<MaterialIcons name="insert-chart-outlined" size={32} color="#d4d4d4" />
|
||||
<Text className="text-sm text-neutral-400 text-center mt-3">
|
||||
Enter your session key to see usage windows
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === "loading" && !usage && (
|
||||
<ActivityIndicator color={COLORS.claude} style={{ marginVertical: 24 }} />
|
||||
)}
|
||||
{status === "error" && error && <ErrorMessage message={error} />}
|
||||
{usage && (
|
||||
<View>
|
||||
<UsageStat
|
||||
label="5-hour window"
|
||||
percent={usage.five_hour.utilization}
|
||||
resetAtISO={usage.five_hour.resets_at}
|
||||
/>
|
||||
<UsageStat
|
||||
label="7-day window"
|
||||
percent={usage.seven_day.utilization}
|
||||
resetAtISO={usage.seven_day.resets_at}
|
||||
/>
|
||||
|
||||
{(usage.seven_day_sonnet || usage.seven_day_opus) && (
|
||||
<View className="mt-1 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
|
||||
By Model (7-day)
|
||||
</Text>
|
||||
{usage.seven_day_sonnet && (
|
||||
<UsageStat
|
||||
label="Sonnet"
|
||||
percent={usage.seven_day_sonnet.utilization}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_opus && (
|
||||
<UsageStat
|
||||
label="Opus"
|
||||
percent={usage.seven_day_opus.utilization}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||
<LastUpdated timestamp={lastFetchedAt} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClaudeTab() {
|
||||
return (
|
||||
<ScreenErrorBoundary screenName="Claude">
|
||||
<ClaudeTabContent />
|
||||
</ScreenErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import {
|
||||
ScrollView,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useCodexUsage } from "@/hooks/useCodexUsage";
|
||||
import { UsageStat } from "@/components/UsageStat";
|
||||
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||
import { LastUpdated } from "@/components/LastUpdated";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
import { windowLabel } from "@/lib/timeUtils";
|
||||
import type { CodexResetCredit } from "@/types/codex";
|
||||
|
||||
function formatResetStatus(status?: string) {
|
||||
if (!status) return "Unknown";
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function ResetCreditRow({ credit }: { credit: CodexResetCredit }) {
|
||||
return (
|
||||
<View className="mt-2 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-3">
|
||||
<View className="flex-row items-center justify-between mb-1.5">
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
Credit {credit.index}
|
||||
</Text>
|
||||
<View className="px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Text className="text-[11px] font-semibold text-green-700 dark:text-green-300">
|
||||
{formatResetStatus(credit.status)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{credit.timeUntilExpiry && (
|
||||
<Text className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Expires in {credit.timeUntilExpiry}
|
||||
</Text>
|
||||
)}
|
||||
{credit.expiresAtLocal && (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
Expires {credit.expiresAtLocal}
|
||||
</Text>
|
||||
)}
|
||||
{credit.grantedAtLocal && (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
Granted {credit.grantedAtLocal}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CodexTabContent() {
|
||||
const {
|
||||
auth,
|
||||
usage,
|
||||
lastFetchedAt,
|
||||
status,
|
||||
error,
|
||||
importAuthFile,
|
||||
refresh,
|
||||
clearAuth,
|
||||
} =
|
||||
useCodexUsage();
|
||||
const resetCoupons = usage?.resetCoupons;
|
||||
const availableResetCredits =
|
||||
resetCoupons?.credits?.filter((credit) => credit.status === "available") ?? [];
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
||||
edges={["top", "bottom"]}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 32 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={status === "loading"}
|
||||
onRefresh={refresh}
|
||||
tintColor={COLORS.codex}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center gap-x-3 py-5">
|
||||
<View
|
||||
className="w-9 h-9 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={18} color={COLORS.codex} />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
||||
Codex CLI
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400 mt-0.5">
|
||||
Rate limits & credits
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Configuration card */}
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 mb-4">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3">
|
||||
Configuration
|
||||
</Text>
|
||||
<ErrorMessage message={error && status === "idle" ? error : null} />
|
||||
{auth ? (
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-x-2.5">
|
||||
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<Text className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Connected
|
||||
</Text>
|
||||
{auth.accountId && (
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-mono">
|
||||
{auth.accountId.slice(0, 10)}…
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={clearAuth}
|
||||
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
|
||||
>
|
||||
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Clear
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={importAuthFile}
|
||||
className="flex-row items-center justify-center gap-x-2 py-3.5 px-4 rounded-xl"
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<MaterialIcons name="upload-file" size={16} color="white" />
|
||||
<Text className="text-white font-semibold text-sm">
|
||||
Import auth.json
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-xs text-neutral-400 text-center mt-2">
|
||||
Located at ~/.codex/auth.json
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Usage card */}
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3">
|
||||
Usage
|
||||
</Text>
|
||||
|
||||
{status === "idle" && !auth && (
|
||||
<View className="items-center py-8">
|
||||
<MaterialIcons name="insert-chart-outlined" size={32} color="#d4d4d4" />
|
||||
<Text className="text-sm text-neutral-400 text-center mt-3">
|
||||
Import auth.json to see your rate limits
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === "loading" && !usage && (
|
||||
<ActivityIndicator color={COLORS.codex} style={{ marginVertical: 24 }} />
|
||||
)}
|
||||
{status === "error" && error && <ErrorMessage message={error} />}
|
||||
{usage && (
|
||||
<View>
|
||||
{/* Plan badge */}
|
||||
<View className="flex-row items-center mb-5">
|
||||
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
|
||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||
{usage.planType ?? "codex"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{usage.primary && (
|
||||
<UsageStat
|
||||
label={`Primary (${windowLabel(usage.primary.windowSeconds)})`}
|
||||
percent={usage.primary.usedPercent}
|
||||
resetAtSeconds={usage.primary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{usage.secondary && (
|
||||
<UsageStat
|
||||
label={`Secondary (${windowLabel(usage.secondary.windowSeconds)})`}
|
||||
percent={usage.secondary.usedPercent}
|
||||
resetAtSeconds={usage.secondary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Credits */}
|
||||
<View className="mt-1 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-2">
|
||||
Credits
|
||||
</Text>
|
||||
{usage.credits.unlimited ? (
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<MaterialIcons name="all-inclusive" size={16} color={COLORS.codex} />
|
||||
<Text className="text-sm font-semibold text-green-600 dark:text-green-400">
|
||||
Unlimited
|
||||
</Text>
|
||||
</View>
|
||||
) : usage.credits.hasCredits ? (
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
${Number(usage.credits.balance).toFixed(2)} remaining
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="text-sm text-neutral-400">No credits</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{resetCoupons && (
|
||||
<View className="mt-3 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-2">
|
||||
Reset Credits
|
||||
</Text>
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{resetCoupons.availableCount ?? 0} available
|
||||
</Text>
|
||||
{typeof resetCoupons.totalEarnedCount === "number" && (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{resetCoupons.totalEarnedCount} earned total
|
||||
</Text>
|
||||
)}
|
||||
{resetCoupons.nextExpiringCredit?.expiresAtLocal && (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
Next expires {resetCoupons.nextExpiringCredit.expiresAtLocal}
|
||||
</Text>
|
||||
)}
|
||||
{resetCoupons.nextExpiringCredit?.timeUntilExpiry && (
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-0.5">
|
||||
In {resetCoupons.nextExpiringCredit.timeUntilExpiry}
|
||||
</Text>
|
||||
)}
|
||||
{availableResetCredits.length > 0 && (
|
||||
<View className="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-1">
|
||||
Available Now
|
||||
</Text>
|
||||
{availableResetCredits.map((credit) => (
|
||||
<ResetCreditRow key={`${credit.index}-${credit.expiresAt ?? "none"}`} credit={credit} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{resetCoupons.source !== "live_api" && (
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
|
||||
Source: {resetCoupons.sourceDescription}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||
<LastUpdated timestamp={lastFetchedAt} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodexTab() {
|
||||
return (
|
||||
<ScreenErrorBoundary screenName="Codex">
|
||||
<CodexTabContent />
|
||||
</ScreenErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import {
|
||||
ScrollView,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useCodexUsage } from "@/hooks/useCodexUsage";
|
||||
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
|
||||
import { onUsageDataLoaded } from "@/lib/notifications";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { ResetCountdown } from "@/components/ResetCountdown";
|
||||
import { LastUpdated } from "@/components/LastUpdated";
|
||||
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import { COLORS, getUsageColor } from "@/lib/constants";
|
||||
import { windowLabel } from "@/lib/timeUtils";
|
||||
|
||||
function UsageRow({
|
||||
label,
|
||||
percent,
|
||||
resetAtSeconds,
|
||||
resetAtISO,
|
||||
}: {
|
||||
label: string;
|
||||
percent: number;
|
||||
resetAtSeconds?: number;
|
||||
resetAtISO?: string;
|
||||
}) {
|
||||
const color = getUsageColor(percent);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardTabContent() {
|
||||
const codex = useCodexUsage();
|
||||
const claude = useClaudeUsage();
|
||||
|
||||
// Fire daily digest reschedule + threshold check whenever fresh data arrives
|
||||
useEffect(() => {
|
||||
if (codex.usage || claude.usage) {
|
||||
void onUsageDataLoaded(
|
||||
claude.usage,
|
||||
codex.usage
|
||||
);
|
||||
}
|
||||
}, [codex.lastFetchedAt, claude.lastFetchedAt]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void codex.reloadCredentials();
|
||||
void claude.reloadCredentials();
|
||||
}, [codex.reloadCredentials, claude.reloadCredentials])
|
||||
);
|
||||
|
||||
const isRefreshing =
|
||||
codex.status === "loading" || claude.status === "loading";
|
||||
|
||||
const handleRefresh = () => {
|
||||
void codex.refresh();
|
||||
void claude.refresh();
|
||||
};
|
||||
|
||||
const connectedCount = [codex.auth, claude.auth].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
||||
edges={["top"]}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={COLORS.codex}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between pt-6 pb-5">
|
||||
<View className="flex-row items-center gap-x-3">
|
||||
<View className="w-10 h-10 rounded-2xl bg-emerald-500 items-center justify-center">
|
||||
<MaterialIcons name="bar-chart" size={20} color="white" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
||||
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>
|
||||
<TouchableOpacity
|
||||
onPress={handleRefresh}
|
||||
className="w-9 h-9 rounded-xl bg-neutral-100 dark:bg-neutral-800 items-center justify-center"
|
||||
>
|
||||
<MaterialIcons name="refresh" size={18} color={COLORS.codex} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Codex card */}
|
||||
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
|
||||
<View style={{ height: 3, backgroundColor: COLORS.codex }} />
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center justify-between mb-4">
|
||||
<View className="flex-row items-center gap-x-2.5">
|
||||
<View
|
||||
className="w-8 h-8 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${COLORS.codex}18` }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={16} color={COLORS.codex} />
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
Codex CLI
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
{codex.usage?.planType && (
|
||||
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||
{codex.usage.planType}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{codex.auth && codex.status === "error" && (
|
||||
<View className="w-2 h-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
{codex.status === "success" && !codex.usage?.planType && (
|
||||
<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" && !codex.usage && (
|
||||
<ActivityIndicator
|
||||
color={COLORS.codex}
|
||||
style={{ marginVertical: 16 }}
|
||||
/>
|
||||
)}
|
||||
{codex.auth && codex.status === "error" && (
|
||||
<ErrorMessage message={codex.error} />
|
||||
)}
|
||||
{codex.usage && (
|
||||
<View>
|
||||
{codex.usage.primary && (
|
||||
<UsageRow
|
||||
label={`${windowLabel(codex.usage.primary.windowSeconds)} window`}
|
||||
percent={codex.usage.primary.usedPercent}
|
||||
resetAtSeconds={codex.usage.primary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{codex.usage.secondary && (
|
||||
<UsageRow
|
||||
label={`${windowLabel(codex.usage.secondary.windowSeconds)} window`}
|
||||
percent={codex.usage.secondary.usedPercent}
|
||||
resetAtSeconds={codex.usage.secondary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{!codex.usage.credits.unlimited &&
|
||||
codex.usage.credits.hasCredits && (
|
||||
<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={COLORS.codex}
|
||||
/>
|
||||
<Text className="text-xs text-green-600 dark:text-green-400">
|
||||
Unlimited credits
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||
{!!codex.usage.resetCoupons && (
|
||||
<View className="mb-3">
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<MaterialIcons name="restart-alt" size={14} color={COLORS.codex} />
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{codex.usage.resetCoupons.availableCount ?? 0} reset credits
|
||||
available
|
||||
</Text>
|
||||
</View>
|
||||
{!!codex.usage.resetCoupons.nextExpiringCredit?.timeUntilExpiry && (
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
Next expires in {codex.usage.resetCoupons.nextExpiringCredit.timeUntilExpiry}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<LastUpdated timestamp={codex.lastFetchedAt} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Claude card */}
|
||||
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
|
||||
<View style={{ height: 3, backgroundColor: COLORS.claude }} />
|
||||
<View className="p-4">
|
||||
<View className="flex-row items-center justify-between mb-4">
|
||||
<View className="flex-row items-center gap-x-2.5">
|
||||
<View
|
||||
className="w-8 h-8 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${COLORS.claude}18` }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={16} color={COLORS.claude} />
|
||||
</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" && !claude.usage && (
|
||||
<ActivityIndicator
|
||||
color={COLORS.claude}
|
||||
style={{ marginVertical: 16 }}
|
||||
/>
|
||||
)}
|
||||
{claude.auth && claude.status === "error" && (
|
||||
<ErrorMessage message={claude.error} />
|
||||
)}
|
||||
{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 className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||
<LastUpdated timestamp={claude.lastFetchedAt} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardTab() {
|
||||
return (
|
||||
<ScreenErrorBoundary screenName="Dashboard">
|
||||
<DashboardTabContent />
|
||||
</ScreenErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
Switch,
|
||||
KeyboardAvoidingView,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import {
|
||||
loadCodexAuth,
|
||||
clearCodexAuth,
|
||||
saveCodexAuth,
|
||||
loadClaudeSessionKey,
|
||||
clearClaudeSessionKey,
|
||||
saveClaudeSessionKey,
|
||||
saveClaudeLastActiveOrg,
|
||||
clearClaudeLastActiveOrg,
|
||||
} from "@/lib/storage";
|
||||
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
||||
import { resetOnboarding } from "@/lib/setupState";
|
||||
import { useNotificationSettings } from "@/hooks/useNotificationSettings";
|
||||
import {
|
||||
isClaudeSessionKeyValid,
|
||||
validateClaudeSessionKey,
|
||||
} from "@/lib/claudeCredentials";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
import { formatTime, parseTimeInput } from "@/lib/timeUtils";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import type { CodexAuth } from "@/types/codex";
|
||||
import Constants from "expo-constants";
|
||||
|
||||
function humanizeImportError(code: string): string {
|
||||
if (code === "DOCUMENT_NOT_READABLE") {
|
||||
return "The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.";
|
||||
}
|
||||
return code;
|
||||
}
|
||||
function SettingRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
destructive,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof MaterialIcons>["name"];
|
||||
label: string;
|
||||
value?: string;
|
||||
onPress?: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
className="flex-row items-center gap-x-3 py-3.5 px-4"
|
||||
>
|
||||
<View className="w-8 items-center">
|
||||
<MaterialIcons
|
||||
name={icon}
|
||||
size={20}
|
||||
color={destructive ? "#ef4444" : disabled ? "#a3a3a3" : "#737373"}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-sm font-medium ${
|
||||
destructive
|
||||
? "text-red-500"
|
||||
: disabled
|
||||
? "text-neutral-400 dark:text-neutral-600"
|
||||
: "text-neutral-800 dark:text-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{value && (
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-0.5 font-mono">
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{onPress && !disabled && (
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={18}
|
||||
color={destructive ? "#ef4444" : "#d4d4d4"}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 px-4 pt-5 pb-1.5">
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 mx-4 overflow-hidden">
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <View className="ml-16 mr-0 h-px bg-neutral-100 dark:bg-neutral-800" />;
|
||||
}
|
||||
|
||||
function SettingsTabContent() {
|
||||
const [codexAuth, setCodexAuth] = useState<CodexAuth | 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 [timeError, setTimeError] = useState<string | null>(null);
|
||||
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(() => {
|
||||
Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
|
||||
([codex, claude]) => {
|
||||
setCodexAuth(codex);
|
||||
setClaudeKey(claude);
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
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", humanizeImportError(msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveClaude = async () => {
|
||||
const trimmed = pendingKey.trim();
|
||||
const validationError = validateClaudeSessionKey(trimmed);
|
||||
if (validationError) {
|
||||
setClaudeError(validationError);
|
||||
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 = () => {
|
||||
Alert.alert(
|
||||
"Clear Codex credentials",
|
||||
"This will remove your stored auth.json data. You can re-import it anytime.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Clear",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await clearCodexAuth();
|
||||
setCodexAuth(null);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearClaude = () => {
|
||||
Alert.alert(
|
||||
"Clear Claude session key",
|
||||
"Your session key will be removed. You can re-enter it anytime.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Clear",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await clearClaudeSessionKey();
|
||||
setClaudeKey(null);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleResetSetup = () => {
|
||||
Alert.alert(
|
||||
"Re-run Setup Wizard",
|
||||
"This will clear all credentials and restart the setup flow.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Reset & Re-run",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await Promise.all([
|
||||
clearCodexAuth(),
|
||||
clearClaudeSessionKey(),
|
||||
resetOnboarding(),
|
||||
]);
|
||||
router.replace("/onboarding");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleTimeBlur = useCallback(() => {
|
||||
const parsed = parseTimeInput(timeInput);
|
||||
if (parsed) {
|
||||
void notif.update({ dailyHour: parsed.hour, dailyMinute: parsed.minute });
|
||||
setTimeInput(formatTime(parsed.hour, parsed.minute));
|
||||
setTimeError(null);
|
||||
} else {
|
||||
setTimeError("Use HH:MM format, from 00:00 to 23:59.");
|
||||
}
|
||||
}, [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 = isClaudeSessionKeyValid(pendingKey);
|
||||
const inlineClaudeError =
|
||||
claudeError ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null);
|
||||
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
||||
edges={["top", "bottom"]}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1"
|
||||
behavior={process.env.EXPO_OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
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 */}
|
||||
<SectionHeader title="Codex CLI" />
|
||||
<SectionCard>
|
||||
{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
|
||||
icon="upload-file"
|
||||
label="Import auth.json"
|
||||
onPress={handleImportCodex}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View className="p-4">
|
||||
{inlineClaudeError && (
|
||||
<Text selectable className="text-xs text-red-500 mb-2">
|
||||
{inlineClaudeError}
|
||||
</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
|
||||
? COLORS.claude
|
||||
: COLORS.disabled,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-semibold text-sm"
|
||||
style={{ color: canSaveClaude ? "white" : COLORS.muted }}
|
||||
>
|
||||
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: COLORS.disabled, true: COLORS.codex }}
|
||||
thumbColor="white"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{notif.settings.dailyEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<View className="px-4 py-3">
|
||||
<View className="flex-row items-center gap-x-3">
|
||||
<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={(value) => {
|
||||
setTimeInput(value);
|
||||
setTimeError(null);
|
||||
}}
|
||||
onBlur={handleTimeBlur}
|
||||
placeholder="09:00"
|
||||
placeholderTextColor={COLORS.muted}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
returnKeyType="done"
|
||||
maxLength={5}
|
||||
className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right"
|
||||
style={{ minWidth: 52 }}
|
||||
/>
|
||||
</View>
|
||||
{timeError && (
|
||||
<Text selectable className="ml-11 mt-1.5 text-xs text-red-500">
|
||||
{timeError}
|
||||
</Text>
|
||||
)}
|
||||
</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: COLORS.disabled, true: COLORS.claude }}
|
||||
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
|
||||
icon="info-outline"
|
||||
label="Version"
|
||||
value={appVersion}
|
||||
disabled
|
||||
/>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon="restart-alt"
|
||||
label="Re-run Setup Wizard"
|
||||
onPress={handleResetSetup}
|
||||
destructive
|
||||
/>
|
||||
</SectionCard>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsTab() {
|
||||
return (
|
||||
<ScreenErrorBoundary screenName="Settings">
|
||||
<SettingsTabContent />
|
||||
</ScreenErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import "../global.css";
|
||||
import { Stack } from "expo-router";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import "@/lib/notifications"; // registers setNotificationHandler on app start
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
def getVersionCode = {
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
try {
|
||||
exec {
|
||||
commandLine 'git', 'rev-list', '--count', 'HEAD'
|
||||
standardOutput = stdout
|
||||
errorOutput = new ByteArrayOutputStream()
|
||||
ignoreExitValue = true
|
||||
}
|
||||
def count = stdout.toString().trim()
|
||||
return count.isInteger() ? count.toInteger() : 1
|
||||
} catch (ignored) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
def signingPropertiesFile = rootProject.file('keystore.properties')
|
||||
def signingProperties = new Properties()
|
||||
if (signingPropertiesFile.exists()) {
|
||||
signingPropertiesFile.withInputStream(signingProperties.&load)
|
||||
}
|
||||
|
||||
def readSigningValue = { String... keys ->
|
||||
keys.collect { key -> System.getenv(key) ?: signingProperties.getProperty(key) }
|
||||
.find { value -> value != null && !value.trim().isEmpty() }
|
||||
}
|
||||
|
||||
def releaseKeystorePath = readSigningValue('CODEXMOBILE_STORE_FILE', 'CODEXMOBILE_KEYSTORE_FILE')
|
||||
def releaseKeystorePassword = readSigningValue('CODEXMOBILE_STORE_PASSWORD', 'CODEXMOBILE_KEYSTORE_PASSWORD')
|
||||
def releaseKeyAlias = readSigningValue('CODEXMOBILE_KEY_ALIAS')
|
||||
def releaseKeyPassword = readSigningValue('CODEXMOBILE_KEY_PASSWORD')
|
||||
def hasReleaseSigning = [releaseKeystorePath, releaseKeystorePassword, releaseKeyAlias, releaseKeyPassword].every { it }
|
||||
def releaseSigningHelp = '''Release signing is not configured.
|
||||
|
||||
Provide these values either as environment variables or in keystore.properties:
|
||||
- CODEXMOBILE_STORE_FILE or CODEXMOBILE_KEYSTORE_FILE
|
||||
- CODEXMOBILE_STORE_PASSWORD or CODEXMOBILE_KEYSTORE_PASSWORD
|
||||
- CODEXMOBILE_KEY_ALIAS
|
||||
- CODEXMOBILE_KEY_PASSWORD
|
||||
'''
|
||||
|
||||
android {
|
||||
namespace 'dev.reversed.codexbarmobile'
|
||||
compileSdk 34
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseSigning) {
|
||||
release {
|
||||
storeFile rootProject.file(releaseKeystorePath)
|
||||
storePassword releaseKeystorePassword
|
||||
keyAlias releaseKeyAlias
|
||||
keyPassword releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'dev.reversed.codexbarmobile'
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode getVersionCode()
|
||||
versionName '1.0.0'
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
versionNameSuffix '-debug'
|
||||
}
|
||||
release {
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.14'
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gradle.taskGraph.whenReady { graph ->
|
||||
def needsSignedRelease = graph.allTasks.any { task ->
|
||||
task.project == project &&
|
||||
task.name.toLowerCase().contains('release') &&
|
||||
!task.name.toLowerCase().contains('lint') &&
|
||||
!task.name.toLowerCase().contains('unittest') &&
|
||||
!task.name.toLowerCase().contains('signingreport')
|
||||
}
|
||||
|
||||
if (needsSignedRelease && !hasReleaseSigning) {
|
||||
throw new GradleException(releaseSigningHelp)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4'
|
||||
implementation 'androidx.activity:activity-compose:1.9.2'
|
||||
implementation platform('androidx.compose:compose-bom:2024.06.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
implementation 'com.google.errorprone:error_prone_annotations:2.28.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { View, ActivityIndicator } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { hasCompletedOnboarding } from "@/lib/setupState";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function Index() {
|
||||
useEffect(() => {
|
||||
hasCompletedOnboarding().then((done) => {
|
||||
router.replace(done ? "/(tabs)" : "/onboarding");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-neutral-950 items-center justify-center">
|
||||
<ActivityIndicator color={COLORS.codex} size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function OnboardingLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "slide_from_right",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { router } from "expo-router";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import {
|
||||
saveClaudeSessionKey,
|
||||
saveClaudeLastActiveOrg,
|
||||
clearClaudeLastActiveOrg,
|
||||
loadClaudeSessionKey,
|
||||
loadClaudeLastActiveOrg,
|
||||
} from "@/lib/storage";
|
||||
import {
|
||||
isClaudeSessionKeyValid,
|
||||
validateClaudeSessionKey,
|
||||
} from "@/lib/claudeCredentials";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function OnboardingClaudeScreen() {
|
||||
const [savedKey, setSavedKey] = useState<string | null>(null);
|
||||
const [savedOrg, setSavedOrg] = useState<string | null>(null);
|
||||
const [pendingKey, setPendingKey] = useState("");
|
||||
const [pendingOrg, setPendingOrg] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]).then(
|
||||
([k, o]) => {
|
||||
if (k) setSavedKey(k);
|
||||
if (o) setSavedOrg(o);
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
const canSave = isClaudeSessionKeyValid(pendingKey);
|
||||
const inlineError =
|
||||
error ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = pendingKey.trim();
|
||||
const validationError = validateClaudeSessionKey(trimmed);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
const orgTrimmed = pendingOrg.trim() || undefined;
|
||||
await saveClaudeSessionKey(trimmed);
|
||||
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
|
||||
else await clearClaudeLastActiveOrg();
|
||||
setSavedKey(trimmed);
|
||||
setSavedOrg(orgTrimmed ?? null);
|
||||
setPendingKey("");
|
||||
setPendingOrg("");
|
||||
setError(null);
|
||||
}, [pendingKey, pendingOrg]);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-neutral-950">
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1"
|
||||
behavior={process.env.EXPO_OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="flex-1 px-8 justify-between py-8">
|
||||
{/* Top: back + step indicator */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="w-9 h-9 rounded-xl bg-neutral-900 items-center justify-center"
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={18} color="#a3a3a3" />
|
||||
</TouchableOpacity>
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<View
|
||||
className="w-6 h-6 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<MaterialIcons name="check" size={14} color="white" />
|
||||
</View>
|
||||
<View className="w-12 h-0.5" style={{ backgroundColor: COLORS.claude }} />
|
||||
<View
|
||||
className="w-6 h-6 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: COLORS.claude }}
|
||||
>
|
||||
<Text className="text-white text-xs font-bold">2</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="w-9" />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View>
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center mb-6"
|
||||
style={{ backgroundColor: `${COLORS.claude}22` }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={32} color={COLORS.claude} />
|
||||
</View>
|
||||
<Text className="text-3xl font-bold text-white mb-3">
|
||||
Connect Claude.ai
|
||||
</Text>
|
||||
<Text className="text-neutral-400 text-base leading-relaxed mb-8">
|
||||
Enter your session key to monitor your Claude.ai usage windows.
|
||||
</Text>
|
||||
|
||||
{savedKey ? (
|
||||
<View className="bg-neutral-900 rounded-2xl border border-neutral-800 mb-4 overflow-hidden">
|
||||
<View className="flex-row items-center gap-x-3 p-4">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${COLORS.claude}22` }}
|
||||
>
|
||||
<MaterialIcons name="check-circle" size={22} color={COLORS.claude} />
|
||||
</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>
|
||||
{savedOrg && (
|
||||
<View className="border-t border-neutral-800 px-4 py-3">
|
||||
<Text className="text-neutral-500 text-xs font-mono">
|
||||
org: {savedOrg.slice(0, 8)}…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{inlineError && (
|
||||
<View className="bg-red-950 border border-red-900 rounded-xl px-4 py-3 mb-4">
|
||||
<Text selectable className="text-red-400 text-sm">{inlineError}</Text>
|
||||
</View>
|
||||
)}
|
||||
<TextInput
|
||||
value={pendingKey}
|
||||
onChangeText={(t) => {
|
||||
setPendingKey(t);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="sk-ant-… (required)"
|
||||
placeholderTextColor="#525252"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
|
||||
/>
|
||||
<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
|
||||
onPress={handleSave}
|
||||
disabled={!canSave}
|
||||
className="rounded-2xl py-4 items-center mb-4"
|
||||
style={{
|
||||
backgroundColor: canSave ? COLORS.claude : "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-bold text-base"
|
||||
style={{ color: canSave ? "white" : "#525252" }}
|
||||
>
|
||||
Save Session Key
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<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">
|
||||
How to find your credentials
|
||||
</Text>
|
||||
<Text className="text-neutral-500 text-sm leading-relaxed">
|
||||
Open Claude.ai in Chrome → DevTools (F12) → Application → Cookies → claude.ai{"\n"}
|
||||
Copy <Text className="text-neutral-300 font-mono">sessionKey</Text> and optionally <Text className="text-neutral-300 font-mono">lastActiveOrg</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<View className="gap-y-3">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/onboarding/done")}
|
||||
className="rounded-2xl py-4 items-center"
|
||||
style={{
|
||||
backgroundColor: savedKey ? COLORS.claude : "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-bold text-base"
|
||||
style={{ color: savedKey ? "white" : "#a3a3a3" }}
|
||||
>
|
||||
Continue
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/onboarding/done")}
|
||||
className="py-3 items-center"
|
||||
>
|
||||
<Text className="text-neutral-600 text-sm">Skip for now</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { router } from "expo-router";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
||||
import { saveCodexAuth, loadCodexAuth } from "@/lib/storage";
|
||||
import type { CodexAuth } from "@/types/codex";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
function humanizeImportError(code: string): string {
|
||||
if (code === "DOCUMENT_NOT_READABLE") {
|
||||
return "The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.";
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export default function OnboardingCodexScreen() {
|
||||
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadCodexAuth().then((stored) => {
|
||||
if (stored) setAuth(stored);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const importFile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const parsed = await pickAndReadCodexAuth();
|
||||
await saveCodexAuth(parsed);
|
||||
setAuth(parsed);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
if (msg !== "PICKER_CANCELLED") setError(humanizeImportError(msg));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-neutral-950">
|
||||
<View className="flex-1 px-8 justify-between py-8">
|
||||
{/* Top: back + step indicator */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="w-9 h-9 rounded-xl bg-neutral-900 items-center justify-center"
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={18} color="#a3a3a3" />
|
||||
</TouchableOpacity>
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<View className="w-6 h-6 rounded-full items-center justify-center" style={{ backgroundColor: COLORS.codex }}>
|
||||
<Text className="text-white text-xs font-bold">1</Text>
|
||||
</View>
|
||||
<View className="w-12 h-0.5 bg-neutral-800" />
|
||||
<View className="w-6 h-6 rounded-full bg-neutral-800 items-center justify-center">
|
||||
<Text className="text-neutral-500 text-xs font-bold">2</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="w-9" />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View>
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center mb-6"
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={32} color={COLORS.codex} />
|
||||
</View>
|
||||
<Text className="text-3xl font-bold text-white mb-3">
|
||||
Connect Codex CLI
|
||||
</Text>
|
||||
<Text className="text-neutral-400 text-base leading-relaxed mb-8">
|
||||
Import your{" "}
|
||||
<Text className="text-neutral-200 font-mono text-sm">
|
||||
~/.codex/auth.json
|
||||
</Text>{" "}
|
||||
file to track your rate limits and credits.
|
||||
</Text>
|
||||
|
||||
{/* Status / action */}
|
||||
{auth ? (
|
||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800 mb-4">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons name="check-circle" size={22} color={COLORS.codex} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold">Imported</Text>
|
||||
{auth.accountId && (
|
||||
<Text className="text-neutral-500 text-sm font-mono">
|
||||
{auth.accountId.slice(0, 12)}…
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity onPress={importFile}>
|
||||
<Text className="text-neutral-400 text-xs">Re-import</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{error && (
|
||||
<View className="bg-red-950 border border-red-900 rounded-xl px-4 py-3 mb-4">
|
||||
<Text className="text-red-400 text-sm">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={importFile}
|
||||
disabled={loading}
|
||||
className="rounded-2xl py-4 items-center mb-3"
|
||||
style={{ backgroundColor: loading ? "#1a1a1a" : COLORS.codex }}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={COLORS.codex} />
|
||||
) : (
|
||||
<View className="flex-row items-center gap-x-2">
|
||||
<MaterialIcons name="upload-file" size={18} color="white" />
|
||||
<Text className="text-white font-bold text-base">
|
||||
Import auth.json
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<Text className="text-neutral-600 text-xs text-center">
|
||||
Located at ~/.codex/auth.json on your Mac
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<View className="gap-y-3">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/onboarding/claude")}
|
||||
className="rounded-2xl py-4 items-center"
|
||||
style={{ backgroundColor: auth ? COLORS.codex : "#1a1a1a" }}
|
||||
>
|
||||
<Text
|
||||
className="font-bold text-base"
|
||||
style={{ color: auth ? "white" : "#a3a3a3" }}
|
||||
>
|
||||
Continue
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/onboarding/claude")}
|
||||
className="py-3 items-center"
|
||||
>
|
||||
<Text className="text-neutral-600 text-sm">Skip for now</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, TouchableOpacity } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { router } from "expo-router";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { markOnboardingComplete } from "@/lib/setupState";
|
||||
import { loadCodexAuth, loadClaudeSessionKey } from "@/lib/storage";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function OnboardingDoneScreen() {
|
||||
const [codexConfigured, setCodexConfigured] = useState(false);
|
||||
const [claudeConfigured, setClaudeConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
|
||||
([codex, claude]) => {
|
||||
setCodexConfigured(!!codex);
|
||||
setClaudeConfigured(!!claude);
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleEnterApp = async () => {
|
||||
await markOnboardingComplete();
|
||||
router.replace("/(tabs)");
|
||||
};
|
||||
|
||||
const noneConfigured = !codexConfigured && !claudeConfigured;
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-neutral-950">
|
||||
<View className="flex-1 px-8 items-center justify-center">
|
||||
{/* Icon */}
|
||||
<View
|
||||
className="w-20 h-20 rounded-3xl items-center justify-center mb-8"
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={noneConfigured ? "info-outline" : "check-circle"}
|
||||
size={40}
|
||||
color={noneConfigured ? COLORS.muted : COLORS.codex}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="text-4xl font-bold text-white mb-3 text-center">
|
||||
{noneConfigured ? "Almost there" : "You're all set!"}
|
||||
</Text>
|
||||
<Text className="text-neutral-400 text-base text-center leading-relaxed mb-10">
|
||||
{noneConfigured
|
||||
? "No services configured yet. You can set them up anytime in Settings."
|
||||
: "Your services are connected and ready to monitor."}
|
||||
</Text>
|
||||
|
||||
{/* Summary cards */}
|
||||
<View className="w-full gap-y-3 mb-10">
|
||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: codexConfigured
|
||||
? `${COLORS.codex}22`
|
||||
: "#27272a",
|
||||
}}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={codexConfigured ? "check-circle" : "radio-button-unchecked"}
|
||||
size={22}
|
||||
color={codexConfigured ? COLORS.codex : "#525252"}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="font-semibold"
|
||||
style={{ color: codexConfigured ? "white" : "#525252" }}
|
||||
>
|
||||
Codex CLI
|
||||
</Text>
|
||||
<Text className="text-neutral-600 text-sm">
|
||||
{codexConfigured ? "Auth imported" : "Not configured"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: claudeConfigured
|
||||
? `${COLORS.claude}22`
|
||||
: "#27272a",
|
||||
}}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={claudeConfigured ? "check-circle" : "radio-button-unchecked"}
|
||||
size={22}
|
||||
color={claudeConfigured ? COLORS.claude : "#525252"}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="font-semibold"
|
||||
style={{ color: claudeConfigured ? "white" : "#525252" }}
|
||||
>
|
||||
Claude.ai
|
||||
</Text>
|
||||
<Text className="text-neutral-600 text-sm">
|
||||
{claudeConfigured ? "Session saved" : "Not configured"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* CTA */}
|
||||
<TouchableOpacity
|
||||
onPress={handleEnterApp}
|
||||
className="w-full rounded-2xl py-4 items-center"
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<Text className="text-white font-bold text-base">Open App</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { View, Text, TouchableOpacity } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { router } from "expo-router";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-neutral-950">
|
||||
<View className="flex-1 px-8 justify-between py-12">
|
||||
{/* Logo + title */}
|
||||
<View className="items-center mt-8">
|
||||
<View className="flex-row items-center justify-center mb-8">
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center"
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={28} color="white" />
|
||||
</View>
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center -ml-5"
|
||||
style={{ backgroundColor: COLORS.claude }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={28} color="white" />
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-5xl font-bold text-white mb-4 tracking-tight">
|
||||
Codexbar
|
||||
</Text>
|
||||
<Text className="text-neutral-400 text-base text-center leading-relaxed">
|
||||
Monitor your Codex CLI and Claude.ai{"\n"}usage limits at a glance.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Feature pills */}
|
||||
<View className="gap-y-3">
|
||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${COLORS.codex}22` }}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={20} color={COLORS.codex} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold mb-0.5">Codex CLI</Text>
|
||||
<Text className="text-neutral-500 text-sm">
|
||||
Rate limits · Credits · Windows
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${COLORS.claude}22` }}
|
||||
>
|
||||
<MaterialIcons name="psychology" size={20} color={COLORS.claude} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold mb-0.5">Claude.ai</Text>
|
||||
<Text className="text-neutral-500 text-sm">
|
||||
5-hour · 7-day · Model usage
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* CTA */}
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/onboarding/codex")}
|
||||
className="rounded-2xl py-4 items-center"
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<Text className="text-white font-bold text-base">Get Started</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-neutral-600 text-xs text-center mt-4">
|
||||
You can configure services at any time in Settings
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".CodexMobileApp"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="CodexMobile"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.CodexMobile">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.CodexMobile">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver android:name=".DailyDigestReceiver" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,282 @@
|
||||
package dev.reversed.codexbarmobile
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class AppStorage(context: Context) {
|
||||
private val prefs: SharedPreferences
|
||||
|
||||
init {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"codexmobile_secure",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
fun hasCompletedOnboarding(): Boolean = prefs.getBoolean(KEY_ONBOARDING_DONE, false)
|
||||
|
||||
fun markOnboardingComplete() {
|
||||
prefs.edit().putBoolean(KEY_ONBOARDING_DONE, true).apply()
|
||||
}
|
||||
|
||||
fun resetOnboarding() {
|
||||
prefs.edit().remove(KEY_ONBOARDING_DONE).apply()
|
||||
}
|
||||
|
||||
fun saveCodexAuth(auth: CodexAuth) {
|
||||
prefs.edit().putString(KEY_CODEX_AUTH, JSONObject().apply {
|
||||
put("accessToken", auth.accessToken)
|
||||
putNullable("accountId", auth.accountId)
|
||||
}.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadCodexAuth(): CodexAuth? = prefs.getString(KEY_CODEX_AUTH, null)?.let { raw ->
|
||||
runCatching {
|
||||
val json = JSONObject(raw)
|
||||
CodexAuth(
|
||||
accessToken = json.getString("accessToken"),
|
||||
accountId = json.optStringOrNull("accountId"),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun clearCodexAuth() {
|
||||
prefs.edit().remove(KEY_CODEX_AUTH).remove(KEY_CODEX_USAGE_CACHE).apply()
|
||||
}
|
||||
|
||||
fun saveClaudeAuth(auth: ClaudeAuth) {
|
||||
prefs.edit().putString(KEY_CLAUDE_AUTH, JSONObject().apply {
|
||||
put("sessionKey", auth.sessionKey)
|
||||
putNullable("lastActiveOrg", auth.lastActiveOrg)
|
||||
}.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadClaudeAuth(): ClaudeAuth? = prefs.getString(KEY_CLAUDE_AUTH, null)?.let { raw ->
|
||||
runCatching {
|
||||
val json = JSONObject(raw)
|
||||
ClaudeAuth(
|
||||
sessionKey = json.getString("sessionKey"),
|
||||
lastActiveOrg = json.optStringOrNull("lastActiveOrg"),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun clearClaudeAuth() {
|
||||
prefs.edit().remove(KEY_CLAUDE_AUTH).remove(KEY_CLAUDE_USAGE_CACHE).apply()
|
||||
}
|
||||
|
||||
fun saveCodexUsageCache(cache: UsageCache<CodexUsage>) {
|
||||
prefs.edit().putString(KEY_CODEX_USAGE_CACHE, JSONObject().apply {
|
||||
put("lastFetchedAt", cache.lastFetchedAt)
|
||||
put("data", cache.data.toJson())
|
||||
}.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadCodexUsageCache(): UsageCache<CodexUsage>? = prefs.getString(KEY_CODEX_USAGE_CACHE, null)?.let { raw ->
|
||||
runCatching {
|
||||
val json = JSONObject(raw)
|
||||
UsageCache(
|
||||
data = json.getJSONObject("data").toCodexUsage(),
|
||||
lastFetchedAt = json.getLong("lastFetchedAt"),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun saveClaudeUsageCache(cache: UsageCache<ClaudeUsage>) {
|
||||
prefs.edit().putString(KEY_CLAUDE_USAGE_CACHE, JSONObject().apply {
|
||||
put("lastFetchedAt", cache.lastFetchedAt)
|
||||
put("data", cache.data.toJson())
|
||||
}.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadClaudeUsageCache(): UsageCache<ClaudeUsage>? = prefs.getString(KEY_CLAUDE_USAGE_CACHE, null)?.let { raw ->
|
||||
runCatching {
|
||||
val json = JSONObject(raw)
|
||||
UsageCache(
|
||||
data = json.getJSONObject("data").toClaudeUsage(),
|
||||
lastFetchedAt = json.getLong("lastFetchedAt"),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun loadNotificationSettings(): NotificationSettings = NotificationSettings(
|
||||
dailyEnabled = prefs.getBoolean(KEY_DAILY_ENABLED, false),
|
||||
dailyHour = prefs.getInt(KEY_DAILY_HOUR, 9),
|
||||
dailyMinute = prefs.getInt(KEY_DAILY_MINUTE, 0),
|
||||
thresholdEnabled = prefs.getBoolean(KEY_THRESHOLD_ENABLED, false),
|
||||
thresholdPct = prefs.getInt(KEY_THRESHOLD_PCT, 20),
|
||||
)
|
||||
|
||||
fun saveNotificationSettings(settings: NotificationSettings) {
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_DAILY_ENABLED, settings.dailyEnabled)
|
||||
.putInt(KEY_DAILY_HOUR, settings.dailyHour)
|
||||
.putInt(KEY_DAILY_MINUTE, settings.dailyMinute)
|
||||
.putBoolean(KEY_THRESHOLD_ENABLED, settings.thresholdEnabled)
|
||||
.putInt(KEY_THRESHOLD_PCT, settings.thresholdPct)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadThresholdLastFired(): String? = prefs.getString(KEY_THRESHOLD_LAST_FIRED, null)
|
||||
|
||||
fun saveThresholdLastFired(date: String) {
|
||||
prefs.edit().putString(KEY_THRESHOLD_LAST_FIRED, date).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_ONBOARDING_DONE = "codexmobile_onboarding_v1"
|
||||
private const val KEY_CODEX_AUTH = "codexmobile_codex_auth"
|
||||
private const val KEY_CLAUDE_AUTH = "codexmobile_claude_auth"
|
||||
private const val KEY_CODEX_USAGE_CACHE = "codexmobile_codex_usage_cache"
|
||||
private const val KEY_CLAUDE_USAGE_CACHE = "codexmobile_claude_usage_cache"
|
||||
private const val KEY_DAILY_ENABLED = "codexmobile_daily_enabled"
|
||||
private const val KEY_DAILY_HOUR = "codexmobile_daily_hour"
|
||||
private const val KEY_DAILY_MINUTE = "codexmobile_daily_minute"
|
||||
private const val KEY_THRESHOLD_ENABLED = "codexmobile_threshold_enabled"
|
||||
private const val KEY_THRESHOLD_PCT = "codexmobile_threshold_pct"
|
||||
private const val KEY_THRESHOLD_LAST_FIRED = "codexmobile_threshold_last_fired"
|
||||
|
||||
fun parseCodexAuthJson(text: String): CodexAuth {
|
||||
val json = JSONObject(text)
|
||||
val tokens = json.optJSONObject("tokens")
|
||||
val accessToken = tokens?.optStringOrNull("access_token")
|
||||
?: json.optStringOrNull("accessToken")
|
||||
?: throw IllegalArgumentException("MISSING_ACCESS_TOKEN")
|
||||
val accountId = tokens?.optStringOrNull("account_id")
|
||||
?: json.optStringOrNull("accountId")
|
||||
?: json.optStringOrNull("account_id")
|
||||
return CodexAuth(accessToken = accessToken, accountId = accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun JSONObject.optStringOrNull(name: String): String? = if (has(name) && !isNull(name)) {
|
||||
optString(name).takeIf { it.isNotBlank() }
|
||||
} else null
|
||||
|
||||
fun JSONObject.optDoubleOrNull(name: String): Double? = if (has(name) && !isNull(name)) optDouble(name) else null
|
||||
|
||||
fun JSONObject.optLongOrNull(name: String): Long? = if (has(name) && !isNull(name)) optLong(name) else null
|
||||
|
||||
fun JSONObject.optIntOrNull(name: String): Int? = if (has(name) && !isNull(name)) optInt(name) else null
|
||||
|
||||
fun JSONObject.putNullable(name: String, value: Any?) {
|
||||
if (value == null) put(name, JSONObject.NULL) else put(name, value)
|
||||
}
|
||||
|
||||
fun UsageWindow.toJson(): JSONObject = JSONObject().apply {
|
||||
put("usedPercent", usedPercent)
|
||||
putNullable("resetsAt", resetsAt)
|
||||
putNullable("resetsAtIso", resetsAtIso)
|
||||
putNullable("windowSeconds", windowSeconds)
|
||||
}
|
||||
|
||||
fun JSONObject.toUsageWindow(): UsageWindow = UsageWindow(
|
||||
usedPercent = getDouble("usedPercent"),
|
||||
resetsAt = optLongOrNull("resetsAt"),
|
||||
resetsAtIso = optStringOrNull("resetsAtIso"),
|
||||
windowSeconds = optLongOrNull("windowSeconds"),
|
||||
)
|
||||
|
||||
fun CodexUsage.toJson(): JSONObject = JSONObject().apply {
|
||||
putNullable("planType", planType)
|
||||
putNullable("rateLimitReachedType", rateLimitReachedType)
|
||||
putNullable("primary", primary?.toJson())
|
||||
putNullable("secondary", secondary?.toJson())
|
||||
put("credits", JSONObject().apply {
|
||||
put("hasCredits", credits.hasCredits)
|
||||
put("unlimited", credits.unlimited)
|
||||
put("balance", credits.balance)
|
||||
})
|
||||
putNullable("resetCoupons", resetCoupons?.toJson())
|
||||
}
|
||||
|
||||
fun JSONObject.toCodexUsage(): CodexUsage {
|
||||
val creditsJson = getJSONObject("credits")
|
||||
return CodexUsage(
|
||||
planType = optStringOrNull("planType"),
|
||||
rateLimitReachedType = optStringOrNull("rateLimitReachedType"),
|
||||
primary = optJSONObject("primary")?.toUsageWindow(),
|
||||
secondary = optJSONObject("secondary")?.toUsageWindow(),
|
||||
credits = CodexCredits(
|
||||
hasCredits = creditsJson.optBoolean("hasCredits"),
|
||||
unlimited = creditsJson.optBoolean("unlimited"),
|
||||
balance = creditsJson.optDouble("balance", 0.0),
|
||||
),
|
||||
resetCoupons = optJSONObject("resetCoupons")?.toResetCoupons(),
|
||||
)
|
||||
}
|
||||
|
||||
fun ResetCoupons.toJson(): JSONObject = JSONObject().apply {
|
||||
put("source", source)
|
||||
put("sourceDescription", sourceDescription)
|
||||
putNullable("availableCount", availableCount)
|
||||
putNullable("totalEarnedCount", totalEarnedCount)
|
||||
put("credits", JSONArray().also { arr -> credits.forEach { arr.put(it.toJson()) } })
|
||||
putNullable("nextExpiringCredit", nextExpiringCredit?.toJson())
|
||||
putNullable("error", error)
|
||||
}
|
||||
|
||||
fun ResetCredit.toJson(): JSONObject = JSONObject().apply {
|
||||
put("index", index)
|
||||
putNullable("status", status)
|
||||
putNullable("grantedAt", grantedAt)
|
||||
putNullable("expiresAt", expiresAt)
|
||||
putNullable("timeUntilExpiry", timeUntilExpiry)
|
||||
}
|
||||
|
||||
fun JSONObject.toResetCoupons(): ResetCoupons {
|
||||
val arr = optJSONArray("credits") ?: JSONArray()
|
||||
val credits = (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.toResetCredit() }
|
||||
return ResetCoupons(
|
||||
source = optString("source", "unavailable"),
|
||||
sourceDescription = optString("sourceDescription", "Reset-credit endpoint unavailable"),
|
||||
availableCount = optIntOrNull("availableCount"),
|
||||
totalEarnedCount = optIntOrNull("totalEarnedCount"),
|
||||
credits = credits,
|
||||
nextExpiringCredit = optJSONObject("nextExpiringCredit")?.toResetCredit(),
|
||||
error = optStringOrNull("error"),
|
||||
)
|
||||
}
|
||||
|
||||
fun JSONObject.toResetCredit(): ResetCredit = ResetCredit(
|
||||
index = optInt("index"),
|
||||
status = optStringOrNull("status"),
|
||||
grantedAt = optStringOrNull("grantedAt"),
|
||||
expiresAt = optStringOrNull("expiresAt"),
|
||||
timeUntilExpiry = optStringOrNull("timeUntilExpiry"),
|
||||
)
|
||||
|
||||
fun ClaudeUsage.toJson(): JSONObject = JSONObject().apply {
|
||||
put("fiveHour", fiveHour.toJson())
|
||||
put("sevenDay", sevenDay.toJson())
|
||||
putNullable("sevenDaySonnet", sevenDaySonnet?.toJson())
|
||||
putNullable("sevenDayOpus", sevenDayOpus?.toJson())
|
||||
}
|
||||
|
||||
fun ClaudeWindowUsage.toJson(): JSONObject = JSONObject().apply {
|
||||
put("utilization", utilization)
|
||||
putNullable("resetsAt", resetsAt)
|
||||
}
|
||||
|
||||
fun JSONObject.toClaudeUsage(): ClaudeUsage = ClaudeUsage(
|
||||
fiveHour = getJSONObject("fiveHour").toClaudeWindowUsage(),
|
||||
sevenDay = getJSONObject("sevenDay").toClaudeWindowUsage(),
|
||||
sevenDaySonnet = optJSONObject("sevenDaySonnet")?.toClaudeWindowUsage(),
|
||||
sevenDayOpus = optJSONObject("sevenDayOpus")?.toClaudeWindowUsage(),
|
||||
)
|
||||
|
||||
fun JSONObject.toClaudeWindowUsage(): ClaudeWindowUsage = ClaudeWindowUsage(
|
||||
utilization = getDouble("utilization"),
|
||||
resetsAt = optStringOrNull("resetsAt"),
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
package dev.reversed.codexbarmobile
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AppViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val storage = AppStorage(application)
|
||||
private val api = UsageApi()
|
||||
|
||||
var screen by androidx.compose.runtime.mutableStateOf(AppScreen.Splash)
|
||||
private set
|
||||
var tab by androidx.compose.runtime.mutableStateOf(MainTab.Dashboard)
|
||||
private set
|
||||
|
||||
var codexAuth by androidx.compose.runtime.mutableStateOf<CodexAuth?>(null)
|
||||
private set
|
||||
var codexUsage by androidx.compose.runtime.mutableStateOf<CodexUsage?>(null)
|
||||
private set
|
||||
var codexLastFetchedAt by androidx.compose.runtime.mutableStateOf<Long?>(null)
|
||||
private set
|
||||
var codexStatus by androidx.compose.runtime.mutableStateOf(LoadStatus.Idle)
|
||||
private set
|
||||
var codexError by androidx.compose.runtime.mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var claudeAuth by androidx.compose.runtime.mutableStateOf<ClaudeAuth?>(null)
|
||||
private set
|
||||
var claudeUsage by androidx.compose.runtime.mutableStateOf<ClaudeUsage?>(null)
|
||||
private set
|
||||
var claudeLastFetchedAt by androidx.compose.runtime.mutableStateOf<Long?>(null)
|
||||
private set
|
||||
var claudeStatus by androidx.compose.runtime.mutableStateOf(LoadStatus.Idle)
|
||||
private set
|
||||
var claudeError by androidx.compose.runtime.mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var notificationSettings by androidx.compose.runtime.mutableStateOf(NotificationSettings())
|
||||
private set
|
||||
|
||||
init {
|
||||
loadInitialState()
|
||||
}
|
||||
|
||||
fun selectTab(next: MainTab) {
|
||||
tab = next
|
||||
}
|
||||
|
||||
fun goTo(next: AppScreen) {
|
||||
screen = next
|
||||
}
|
||||
|
||||
fun openMain() {
|
||||
storage.markOnboardingComplete()
|
||||
screen = AppScreen.Main
|
||||
}
|
||||
|
||||
fun resetSetup() {
|
||||
storage.clearCodexAuth()
|
||||
storage.clearClaudeAuth()
|
||||
storage.resetOnboarding()
|
||||
codexAuth = null
|
||||
codexUsage = null
|
||||
codexLastFetchedAt = null
|
||||
codexStatus = LoadStatus.Idle
|
||||
codexError = null
|
||||
claudeAuth = null
|
||||
claudeUsage = null
|
||||
claudeLastFetchedAt = null
|
||||
claudeStatus = LoadStatus.Idle
|
||||
claudeError = null
|
||||
screen = AppScreen.Welcome
|
||||
tab = MainTab.Dashboard
|
||||
}
|
||||
|
||||
fun importCodexAuthText(text: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { AppStorage.parseCodexAuthJson(text) }
|
||||
.onSuccess { auth ->
|
||||
storage.saveCodexAuth(auth)
|
||||
codexAuth = auth
|
||||
codexError = null
|
||||
refreshCodex()
|
||||
}
|
||||
.onFailure { codexError = humanizeImportError(it.message ?: "INVALID_JSON") }
|
||||
}
|
||||
}
|
||||
|
||||
fun saveClaude(sessionKey: String, lastActiveOrg: String?) {
|
||||
val trimmed = sessionKey.trim()
|
||||
val validationError = validateClaudeSessionKey(trimmed)
|
||||
if (validationError != null) {
|
||||
claudeError = validationError
|
||||
return
|
||||
}
|
||||
val auth = ClaudeAuth(trimmed, lastActiveOrg?.trim()?.takeIf { it.isNotBlank() })
|
||||
storage.saveClaudeAuth(auth)
|
||||
claudeAuth = auth
|
||||
claudeError = null
|
||||
refreshClaude()
|
||||
}
|
||||
|
||||
fun clearCodex() {
|
||||
storage.clearCodexAuth()
|
||||
codexAuth = null
|
||||
codexUsage = null
|
||||
codexLastFetchedAt = null
|
||||
codexStatus = LoadStatus.Idle
|
||||
codexError = null
|
||||
}
|
||||
|
||||
fun clearClaude() {
|
||||
storage.clearClaudeAuth()
|
||||
claudeAuth = null
|
||||
claudeUsage = null
|
||||
claudeLastFetchedAt = null
|
||||
claudeStatus = LoadStatus.Idle
|
||||
claudeError = null
|
||||
}
|
||||
|
||||
fun refreshAll() {
|
||||
refreshCodex()
|
||||
refreshClaude()
|
||||
}
|
||||
|
||||
fun refreshCodex() {
|
||||
val auth = codexAuth ?: return
|
||||
viewModelScope.launch {
|
||||
codexStatus = LoadStatus.Loading
|
||||
codexError = null
|
||||
runCatchingWithRetry { api.fetchCodexUsage(auth) }
|
||||
.onSuccess { usage ->
|
||||
val fetchedAt = System.currentTimeMillis()
|
||||
codexUsage = usage
|
||||
codexLastFetchedAt = fetchedAt
|
||||
codexStatus = LoadStatus.Success
|
||||
storage.saveCodexUsageCache(UsageCache(usage, fetchedAt))
|
||||
syncNotifications()
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error.message == "TOKEN_EXPIRED") clearCodex()
|
||||
codexStatus = LoadStatus.Error
|
||||
codexError = humanizeError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshClaude() {
|
||||
val auth = claudeAuth ?: return
|
||||
viewModelScope.launch {
|
||||
claudeStatus = LoadStatus.Loading
|
||||
claudeError = null
|
||||
runCatchingWithRetry { api.fetchClaudeUsage(auth) }
|
||||
.onSuccess { usage ->
|
||||
val fetchedAt = System.currentTimeMillis()
|
||||
claudeUsage = usage
|
||||
claudeLastFetchedAt = fetchedAt
|
||||
claudeStatus = LoadStatus.Success
|
||||
storage.saveClaudeUsageCache(UsageCache(usage, fetchedAt))
|
||||
syncNotifications()
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error.message == "TOKEN_EXPIRED") clearClaude()
|
||||
claudeStatus = LoadStatus.Error
|
||||
claudeError = humanizeError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotificationSettings(settings: NotificationSettings) {
|
||||
notificationSettings = settings
|
||||
storage.saveNotificationSettings(settings)
|
||||
syncNotifications()
|
||||
}
|
||||
|
||||
private fun loadInitialState() {
|
||||
codexAuth = storage.loadCodexAuth()
|
||||
claudeAuth = storage.loadClaudeAuth()
|
||||
notificationSettings = storage.loadNotificationSettings()
|
||||
storage.loadCodexUsageCache()?.let {
|
||||
codexUsage = it.data
|
||||
codexLastFetchedAt = it.lastFetchedAt
|
||||
codexStatus = LoadStatus.Success
|
||||
}
|
||||
storage.loadClaudeUsageCache()?.let {
|
||||
claudeUsage = it.data
|
||||
claudeLastFetchedAt = it.lastFetchedAt
|
||||
claudeStatus = LoadStatus.Success
|
||||
}
|
||||
screen = if (storage.hasCompletedOnboarding()) AppScreen.Main else AppScreen.Welcome
|
||||
refreshAll()
|
||||
}
|
||||
|
||||
private fun syncNotifications() {
|
||||
val context = getApplication<Application>()
|
||||
if (notificationSettings.dailyEnabled) {
|
||||
NotificationScheduler.scheduleDailyDigest(
|
||||
context = context,
|
||||
hour = notificationSettings.dailyHour,
|
||||
minute = notificationSettings.dailyMinute,
|
||||
body = buildDailyDigestBody(claudeUsage, codexUsage),
|
||||
)
|
||||
} else {
|
||||
NotificationScheduler.cancelDailyDigest(context)
|
||||
}
|
||||
maybeFireThresholdAlert(context, storage, notificationSettings, claudeUsage, codexUsage)
|
||||
}
|
||||
|
||||
private suspend fun <T> runCatchingWithRetry(block: suspend () -> T): Result<T> {
|
||||
return runCatching { block() }.recoverCatching { first ->
|
||||
if (!shouldRetry(first)) throw first
|
||||
delay(RETRY_DELAY_MS)
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRetry(error: Throwable): Boolean = when (error.message) {
|
||||
"TOKEN_EXPIRED", "NO_ORGS_FOUND", "INVALID_CODEX_RESPONSE", "INVALID_CLAUDE_RESPONSE" -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun humanizeImportError(code: String): String = when (code) {
|
||||
"MISSING_ACCESS_TOKEN" -> "That auth.json does not contain a Codex access token."
|
||||
"INVALID_JSON" -> "That file is not valid JSON."
|
||||
else -> "The selected file could not be read. Try another copy of auth.json."
|
||||
}
|
||||
|
||||
private fun humanizeError(error: Throwable): String = when (val message = error.message) {
|
||||
"TOKEN_EXPIRED" -> "Credentials expired. Reconnect this service."
|
||||
"NO_ORGS_FOUND" -> "No Claude organizations were found for this session."
|
||||
null -> "Something went wrong. Pull to retry."
|
||||
else -> if (message.startsWith("HTTP_ERROR_")) "Service request failed (${message.removePrefix("HTTP_ERROR_")}). Pull to retry." else message
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RETRY_DELAY_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
private val claudeSessionPattern = Regex("^sk-ant-[A-Za-z0-9_-]+$")
|
||||
|
||||
fun validateClaudeSessionKey(value: String): String? {
|
||||
val key = value.trim()
|
||||
return when {
|
||||
!key.startsWith("sk-ant-") -> "Session key must start with sk-ant-."
|
||||
key.length <= 40 -> "Session key looks too short. Paste the complete cookie value."
|
||||
!claudeSessionPattern.matches(key) -> "Session key contains unexpected characters."
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun isClaudeSessionKeyValid(value: String): Boolean = validateClaudeSessionKey(value) == null
|
||||
@@ -0,0 +1,855 @@
|
||||
package dev.reversed.codexbarmobile
|
||||
|
||||
import android.Manifest
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Alarm
|
||||
import androidx.compose.material.icons.rounded.AutoAwesome
|
||||
import androidx.compose.material.icons.rounded.BarChart
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Psychology
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.RestartAlt
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.UploadFile
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
CodexMobileTheme {
|
||||
val vm: AppViewModel = viewModel()
|
||||
CodexMobileApp(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodexMobileApp(vm: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val pickCodexAuth = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
|
||||
if (text != null) vm.importCodexAuthText(text)
|
||||
}
|
||||
}
|
||||
val requestNotifications = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
|
||||
val pickAuth = { pickCodexAuth.launch(arrayOf("application/json", "text/*", "*/*")) }
|
||||
val askNotifications = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
when (vm.screen) {
|
||||
AppScreen.Splash -> SplashScreen()
|
||||
AppScreen.Welcome -> WelcomeScreen(vm)
|
||||
AppScreen.OnboardingCodex -> OnboardingCodexScreen(vm, pickAuth)
|
||||
AppScreen.OnboardingClaude -> OnboardingClaudeScreen(vm)
|
||||
AppScreen.OnboardingDone -> OnboardingDoneScreen(vm)
|
||||
AppScreen.Main -> MainScreen(vm, pickAuth, askNotifications)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SplashScreen() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = CodexGreen)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeScreen(vm: AppViewModel) {
|
||||
OnboardingShell {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
AppMark(size = 72)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Text("CodexMobile", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black)
|
||||
Text(
|
||||
"Monitor Codex CLI and Claude.ai usage limits without a web stack.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
FeaturePill(Icons.Rounded.AutoAwesome, "Codex CLI", "Rate limits, credits, reset coupons", CodexGreen)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
FeaturePill(Icons.Rounded.Psychology, "Claude.ai", "5-hour, 7-day, and model windows", ClaudeAmber)
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton("Get started", CodexGreen) { vm.goTo(AppScreen.OnboardingCodex) }
|
||||
Text(
|
||||
"You can configure services later in Settings.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingCodexScreen(vm: AppViewModel, pickAuth: () -> Unit) {
|
||||
OnboardingShell {
|
||||
StepHeader(1, 2)
|
||||
Spacer(Modifier.height(36.dp))
|
||||
ServiceIcon(Icons.Rounded.AutoAwesome, CodexGreen)
|
||||
Text("Connect Codex CLI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Import your ~/.codex/auth.json file to track rate limits, credits, and reset coupons.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
vm.codexError?.let { ErrorCard(it) }
|
||||
if (vm.codexAuth != null) {
|
||||
ConnectedCard("Imported", vm.codexAuth?.accountId?.take(16), CodexGreen)
|
||||
TextButton(onClick = pickAuth) { Text("Re-import auth.json") }
|
||||
} else {
|
||||
PrimaryButton("Import auth.json", CodexGreen, icon = Icons.Rounded.UploadFile, onClick = pickAuth)
|
||||
Text("File picker accepts the Codex auth JSON export.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton("Continue", if (vm.codexAuth != null) CodexGreen else MutedButton) { vm.goTo(AppScreen.OnboardingClaude) }
|
||||
TextButton(onClick = { vm.goTo(AppScreen.OnboardingClaude) }) { Text("Skip for now") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingClaudeScreen(vm: AppViewModel) {
|
||||
var key by remember { mutableStateOf("") }
|
||||
var org by remember { mutableStateOf("") }
|
||||
val inlineError = if (key.isBlank()) null else validateClaudeSessionKey(key)
|
||||
OnboardingShell {
|
||||
StepHeader(2, 2)
|
||||
Spacer(Modifier.height(36.dp))
|
||||
ServiceIcon(Icons.Rounded.Psychology, ClaudeAmber)
|
||||
Text("Connect Claude.ai", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Paste your sessionKey cookie and optionally lastActiveOrg to monitor Claude usage windows.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
if (vm.claudeAuth != null) {
|
||||
ConnectedCard("Connected", vm.claudeAuth?.sessionKey?.take(16), ClaudeAmber)
|
||||
TextButton(onClick = { vm.clearClaude() }) { Text("Change session key") }
|
||||
} else {
|
||||
(vm.claudeError ?: inlineError)?.let { ErrorCard(it) }
|
||||
OutlinedTextField(
|
||||
value = key,
|
||||
onValueChange = { key = it },
|
||||
label = { Text("sessionKey") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = org,
|
||||
onValueChange = { org = it },
|
||||
label = { Text("lastActiveOrg (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PrimaryButton("Save session key", if (isClaudeSessionKeyValid(key)) ClaudeAmber else MutedButton) {
|
||||
vm.saveClaude(key, org)
|
||||
}
|
||||
HelpCard("Find it in Chrome DevTools > Application > Cookies > claude.ai > sessionKey.")
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton("Continue", if (vm.claudeAuth != null) ClaudeAmber else MutedButton) { vm.goTo(AppScreen.OnboardingDone) }
|
||||
TextButton(onClick = { vm.goTo(AppScreen.OnboardingDone) }) { Text("Skip for now") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingDoneScreen(vm: AppViewModel) {
|
||||
OnboardingShell(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
ServiceIcon(if (vm.codexAuth != null || vm.claudeAuth != null) Icons.Rounded.CheckCircle else Icons.Rounded.Info, CodexGreen, 84)
|
||||
Text(
|
||||
if (vm.codexAuth == null && vm.claudeAuth == null) "Almost there" else "You're all set",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
Text(
|
||||
if (vm.codexAuth == null && vm.claudeAuth == null) "No services are configured yet. Settings can finish setup anytime." else "Your connected services are ready to monitor.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
SummaryRow("Codex CLI", if (vm.codexAuth != null) "Auth imported" else "Not configured", vm.codexAuth != null, CodexGreen)
|
||||
SummaryRow("Claude.ai", if (vm.claudeAuth != null) "Session saved" else "Not configured", vm.claudeAuth != null, ClaudeAmber)
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton("Open app", CodexGreen) { vm.openMain() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(vm: AppViewModel, pickAuth: () -> Unit, askNotifications: () -> Unit) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
MainTab.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = vm.tab == item,
|
||||
onClick = { vm.selectTab(item) },
|
||||
icon = { Icon(item.icon, null) },
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.padding(padding)) {
|
||||
when (vm.tab) {
|
||||
MainTab.Dashboard -> DashboardScreen(vm)
|
||||
MainTab.Codex -> CodexScreen(vm, pickAuth)
|
||||
MainTab.Claude -> ClaudeScreen(vm)
|
||||
MainTab.Settings -> SettingsScreen(vm, pickAuth, askNotifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardScreen(vm: AppViewModel) {
|
||||
ScreenColumn {
|
||||
Header("CodexMobile", connectedSummary(vm), Icons.Rounded.BarChart, CodexGreen) { vm.refreshAll() }
|
||||
ServiceCard("Codex CLI", Icons.Rounded.AutoAwesome, CodexGreen, vm.codexStatus, vm.codexError, vm.codexAuth != null) {
|
||||
vm.codexUsage?.let { CodexUsageContent(it, vm.codexLastFetchedAt, compact = true) }
|
||||
?: EmptyState("Import auth.json in Settings to see Codex rate limits.") { vm.selectTab(MainTab.Settings) }
|
||||
}
|
||||
ServiceCard("Claude.ai", Icons.Rounded.Psychology, ClaudeAmber, vm.claudeStatus, vm.claudeError, vm.claudeAuth != null) {
|
||||
vm.claudeUsage?.let { ClaudeUsageContent(it, vm.claudeLastFetchedAt) }
|
||||
?: EmptyState("Add a session key in Settings to see Claude usage.") { vm.selectTab(MainTab.Settings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodexScreen(vm: AppViewModel, pickAuth: () -> Unit) {
|
||||
ScreenColumn {
|
||||
Header("Codex CLI", "Rate limits and reset credits", Icons.Rounded.AutoAwesome, CodexGreen) { vm.refreshCodex() }
|
||||
ConfigCard(
|
||||
connected = vm.codexAuth != null,
|
||||
label = vm.codexAuth?.accountId?.take(16),
|
||||
color = CodexGreen,
|
||||
connectLabel = "Import auth.json",
|
||||
onConnect = pickAuth,
|
||||
onClear = vm::clearCodex,
|
||||
)
|
||||
vm.codexError?.let { ErrorCard(it) }
|
||||
UsageSurface(vm.codexStatus) {
|
||||
vm.codexUsage?.let { CodexUsageContent(it, vm.codexLastFetchedAt, compact = false) }
|
||||
?: EmptyState("Import Codex auth.json to load usage windows.") { pickAuth() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClaudeScreen(vm: AppViewModel) {
|
||||
var key by remember { mutableStateOf("") }
|
||||
var org by remember { mutableStateOf("") }
|
||||
ScreenColumn {
|
||||
Header("Claude.ai", "Usage windows", Icons.Rounded.Psychology, ClaudeAmber) { vm.refreshClaude() }
|
||||
if (vm.claudeAuth != null) {
|
||||
ConfigCard(
|
||||
connected = true,
|
||||
label = vm.claudeAuth?.sessionKey?.take(16),
|
||||
color = ClaudeAmber,
|
||||
connectLabel = "Save session key",
|
||||
onConnect = {},
|
||||
onClear = vm::clearClaude,
|
||||
)
|
||||
} else {
|
||||
ClaudeCredentialCard(key, org, vm.claudeError, onKey = { key = it }, onOrg = { org = it }) { vm.saveClaude(key, org) }
|
||||
}
|
||||
UsageSurface(vm.claudeStatus) {
|
||||
vm.claudeUsage?.let { ClaudeUsageContent(it, vm.claudeLastFetchedAt) }
|
||||
?: EmptyState("Enter a Claude session key to load usage windows.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(vm: AppViewModel, pickAuth: () -> Unit, askNotifications: () -> Unit) {
|
||||
var key by remember { mutableStateOf("") }
|
||||
var org by remember { mutableStateOf("") }
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
ScreenColumn {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Black)
|
||||
SectionTitle("Codex CLI")
|
||||
ConfigCard(vm.codexAuth != null, vm.codexAuth?.accountId?.take(16), CodexGreen, "Import auth.json", pickAuth, vm::clearCodex)
|
||||
SectionTitle("Claude.ai")
|
||||
if (vm.claudeAuth != null) {
|
||||
ConfigCard(true, vm.claudeAuth?.sessionKey?.take(16), ClaudeAmber, "Save session key", {}, vm::clearClaude)
|
||||
} else {
|
||||
ClaudeCredentialCard(key, org, vm.claudeError, onKey = { key = it }, onOrg = { org = it }) { vm.saveClaude(key, org) }
|
||||
}
|
||||
SectionTitle("Notifications")
|
||||
SettingsCard {
|
||||
val hasPermission = NotificationScheduler.hasNotificationPermission(context)
|
||||
if (!hasPermission) {
|
||||
RowItem(Icons.Rounded.Notifications, "Enable notifications", "Required for digests and quota alerts") {
|
||||
OutlinedButton(onClick = askNotifications) { Text("Allow") }
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
SwitchRow("Daily digest", "Last-known usage at a chosen time", vm.notificationSettings.dailyEnabled) {
|
||||
vm.updateNotificationSettings(vm.notificationSettings.copy(dailyEnabled = it))
|
||||
}
|
||||
if (vm.notificationSettings.dailyEnabled) {
|
||||
TimeSetting(vm.notificationSettings) { hour, minute ->
|
||||
vm.updateNotificationSettings(vm.notificationSettings.copy(dailyHour = hour, dailyMinute = minute))
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
SwitchRow("Low quota alert", "Warn when weekly quota runs low", vm.notificationSettings.thresholdEnabled) {
|
||||
vm.updateNotificationSettings(vm.notificationSettings.copy(thresholdEnabled = it))
|
||||
}
|
||||
if (vm.notificationSettings.thresholdEnabled) {
|
||||
ThresholdSetting(vm.notificationSettings) {
|
||||
vm.updateNotificationSettings(vm.notificationSettings.copy(thresholdPct = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTitle("App")
|
||||
SettingsCard {
|
||||
RowItem(Icons.Rounded.Info, "Version", "1.0.0") {}
|
||||
HorizontalDivider()
|
||||
RowItem(Icons.Rounded.RestartAlt, "Re-run setup wizard", "Clears credentials and onboarding", danger = true) {
|
||||
TextButton(onClick = { showResetDialog = true }) { Text("Reset") }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = { Text("Reset setup?") },
|
||||
text = { Text("This clears stored credentials and restarts onboarding.") },
|
||||
confirmButton = { TextButton(onClick = { showResetDialog = false; vm.resetSetup() }) { Text("Reset") } },
|
||||
dismissButton = { TextButton(onClick = { showResetDialog = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodexUsageContent(usage: CodexUsage, lastFetchedAt: Long?, compact: Boolean) {
|
||||
usage.planType?.let { AssistChip(onClick = {}, label = { Text(it.replaceFirstChar(Char::uppercase)) }) }
|
||||
usage.primary?.let { UsageRow("${windowLabel(it.windowSeconds)} window", it, CodexGreen) }
|
||||
usage.secondary?.let { UsageRow("${windowLabel(it.windowSeconds)} window", it, CodexGreen) }
|
||||
CreditCard(usage)
|
||||
if (!compact) ResetCreditsCard(usage.resetCoupons)
|
||||
LastUpdated(lastFetchedAt)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClaudeUsageContent(usage: ClaudeUsage, lastFetchedAt: Long?) {
|
||||
UsageRow("5-hour window", UsageWindow(usage.fiveHour.utilization, resetsAtIso = usage.fiveHour.resetsAt), ClaudeAmber)
|
||||
UsageRow("7-day window", UsageWindow(usage.sevenDay.utilization, resetsAtIso = usage.sevenDay.resetsAt), ClaudeAmber)
|
||||
if (usage.sevenDaySonnet != null || usage.sevenDayOpus != null) {
|
||||
CardBlock {
|
||||
Text("By model (7-day)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
usage.sevenDaySonnet?.let { UsageRow("Sonnet", UsageWindow(it.utilization), ClaudeAmber) }
|
||||
usage.sevenDayOpus?.let { UsageRow("Opus", UsageWindow(it.utilization), ClaudeAmber) }
|
||||
}
|
||||
}
|
||||
LastUpdated(lastFetchedAt)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageRow(label: String, window: UsageWindow, accent: Color) {
|
||||
val percent = window.usedPercent.roundToInt().coerceIn(0, 100)
|
||||
Column(Modifier.padding(vertical = 10.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("$percent% used", style = MaterialTheme.typography.titleSmall, color = usageColor(percent, accent), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { percent / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp),
|
||||
color = usageColor(percent, accent),
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
resetText(window)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreditCard(usage: CodexUsage) {
|
||||
CardBlock {
|
||||
Text("Credits", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val text = when {
|
||||
usage.credits.unlimited -> "Unlimited credits"
|
||||
usage.credits.hasCredits -> "\$${"%.2f".format(usage.credits.balance)} remaining"
|
||||
else -> "No credits"
|
||||
}
|
||||
Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
usage.resetCoupons?.let {
|
||||
Text("${it.availableCount ?: 0} reset credits available", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
it.nextExpiringCredit?.timeUntilExpiry?.let { expiry -> Text("Next expires in $expiry", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetCreditsCard(resetCoupons: ResetCoupons?) {
|
||||
if (resetCoupons == null) return
|
||||
CardBlock {
|
||||
Text("Reset credits", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${resetCoupons.availableCount ?: 0} available", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
resetCoupons.totalEarnedCount?.let { Text("$it earned total", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
resetCoupons.credits.filter { it.status == "available" }.forEach { credit ->
|
||||
Text("Credit ${credit.index}: expires ${credit.timeUntilExpiry ?: credit.expiresAt ?: "unknown"}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (resetCoupons.source != "live_api") Text("Source: ${resetCoupons.sourceDescription}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceCard(title: String, icon: ImageVector, color: Color, status: LoadStatus, error: String?, connected: Boolean, content: @Composable () -> Unit) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(28.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ServiceIcon(icon, color, 40)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
StatusDot(status, connected)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (status == LoadStatus.Loading && !connected) CircularProgressIndicator(color = color)
|
||||
if (status == LoadStatus.Error && error != null) ErrorCard(error)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageSurface(status: LoadStatus, content: @Composable () -> Unit) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(28.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp)) {
|
||||
if (status == LoadStatus.Loading) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Refreshing usage…", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfigCard(connected: Boolean, label: String?, color: Color, connectLabel: String, onConnect: () -> Unit, onClear: () -> Unit) {
|
||||
SettingsCard {
|
||||
if (connected) {
|
||||
RowItem(Icons.Rounded.CheckCircle, "Connected", label ?: "Credentials saved") {
|
||||
TextButton(onClick = onClear) { Text("Clear", color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
} else {
|
||||
RowItem(Icons.Rounded.UploadFile, connectLabel, "Credentials stay encrypted on this device") {
|
||||
Button(onClick = onConnect, colors = ButtonDefaults.buttonColors(containerColor = color)) { Text("Connect") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClaudeCredentialCard(key: String, org: String, error: String?, onKey: (String) -> Unit, onOrg: (String) -> Unit, onSave: () -> Unit) {
|
||||
SettingsCard {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
(error ?: if (key.isBlank()) null else validateClaudeSessionKey(key))?.let { ErrorCard(it) }
|
||||
OutlinedTextField(key, onKey, Modifier.fillMaxWidth(), label = { Text("sessionKey") }, singleLine = true, visualTransformation = PasswordVisualTransformation())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(org, onOrg, Modifier.fillMaxWidth(), label = { Text("lastActiveOrg (optional)") }, singleLine = true)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PrimaryButton("Save session key", if (isClaudeSessionKeyValid(key)) ClaudeAmber else MutedButton, onClick = onSave)
|
||||
HelpCard("Chrome DevTools > Application > Cookies > claude.ai > sessionKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenColumn(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingShell(horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp),
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(title: String, subtitle: String, icon: ImageVector, color: Color, onRefresh: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ServiceIcon(icon, color, 48)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onRefresh) { Icon(Icons.Rounded.Refresh, "Refresh") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryButton(text: String, color: Color, icon: ImageVector? = null, onClick: () -> Unit) {
|
||||
Button(onClick = onClick, colors = ButtonDefaults.buttonColors(containerColor = color), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth().height(54.dp)) {
|
||||
if (icon != null) { Icon(icon, null); Spacer(Modifier.width(8.dp)) }
|
||||
Text(text, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceIcon(icon: ImageVector, color: Color, size: Int = 56) {
|
||||
Surface(shape = RoundedCornerShape((size / 3).dp), color = color.copy(alpha = 0.16f), modifier = Modifier.size(size.dp)) {
|
||||
Box(contentAlignment = Alignment.Center) { Icon(icon, null, tint = color, modifier = Modifier.size((size / 2).dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppMark(size: Int) {
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
ServiceIcon(Icons.Rounded.AutoAwesome, CodexGreen, size)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
ServiceIcon(Icons.Rounded.Psychology, ClaudeAmber, size)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepHeader(current: Int, total: Int) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
(1..total).forEach { step ->
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (step <= current) CodexGreen else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(28.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
step.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (step <= current) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (step < total) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
HorizontalDivider(Modifier.width(42.dp), color = if (step < current) CodexGreen else MaterialTheme.colorScheme.surfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeaturePill(icon: ImageVector, title: String, subtitle: String, color: Color) {
|
||||
SettingsCard {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
ServiceIcon(icon, color, 42)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(title, fontWeight = FontWeight.Bold)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectedCard(title: String, label: String?, color: Color) {
|
||||
SettingsCard {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.CheckCircle, null, tint = color)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(title, fontWeight = FontWeight.Bold)
|
||||
label?.let { Text("$it…", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(title: String, subtitle: String, ok: Boolean, color: Color) {
|
||||
SettingsCard {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(shape = CircleShape, color = if (ok) color else MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(10.dp)) {}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column { Text(title, fontWeight = FontWeight.Bold); Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(text: String) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpCard(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardBlock(content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)), shape = RoundedCornerShape(20.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
Column(Modifier.padding(14.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(text: String, onAction: (() -> Unit)? = null) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (onAction != null) TextButton(onClick = onAction) { Text("Open Settings") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LastUpdated(timestamp: Long?) {
|
||||
Text("Updated ${timestamp?.let(::formatTimestamp) ?: "never"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(title.uppercase(), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowItem(icon: ImageVector, title: String, subtitle: String, danger: Boolean = false, action: @Composable () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontWeight = FontWeight.SemiBold, color = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(title: String, subtitle: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||
RowItem(Icons.Rounded.Alarm, title, subtitle) { Switch(checked = checked, onCheckedChange = onChecked) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeSetting(settings: NotificationSettings, onChange: (Int, Int) -> Unit) {
|
||||
var value by remember(settings.dailyHour, settings.dailyMinute) { mutableStateOf("%02d:%02d".format(settings.dailyHour, settings.dailyMinute)) }
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it.take(5)
|
||||
parseTime(value)?.let { parsed -> onChange(parsed.first, parsed.second) }
|
||||
},
|
||||
label = { Text("Digest time") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
supportingText = { Text("Use 24-hour HH:MM format.") },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThresholdSetting(settings: NotificationSettings, onChange: (Int) -> Unit) {
|
||||
var value by remember(settings.thresholdPct) { mutableStateOf(settings.thresholdPct.toString()) }
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { next -> value = next.filter(Char::isDigit).take(2); value.toIntOrNull()?.takeIf { it in 1..99 }?.let(onChange) },
|
||||
label = { Text("Alert below % remaining") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusDot(status: LoadStatus, connected: Boolean) {
|
||||
val color = when {
|
||||
status == LoadStatus.Error -> MaterialTheme.colorScheme.error
|
||||
connected -> CodexGreen
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
}
|
||||
Surface(shape = CircleShape, color = color, modifier = Modifier.size(10.dp)) {}
|
||||
}
|
||||
|
||||
private fun connectedSummary(vm: AppViewModel): String = when (listOf(vm.codexAuth, vm.claudeAuth).count { it != null }) {
|
||||
0 -> "No services connected"
|
||||
1 -> "1 of 2 services connected"
|
||||
else -> "2 services connected"
|
||||
}
|
||||
|
||||
private val MainTab.icon: ImageVector get() = when (this) {
|
||||
MainTab.Dashboard -> Icons.Rounded.Home
|
||||
MainTab.Codex -> Icons.Rounded.AutoAwesome
|
||||
MainTab.Claude -> Icons.Rounded.Psychology
|
||||
MainTab.Settings -> Icons.Rounded.Settings
|
||||
}
|
||||
|
||||
private fun usageColor(percent: Int, fallback: Color): Color = when {
|
||||
percent >= 85 -> DangerRed
|
||||
percent >= 60 -> WarningAmber
|
||||
else -> fallback
|
||||
}
|
||||
|
||||
private fun resetText(window: UsageWindow): String? = when {
|
||||
window.resetsAt != null -> "Resets ${formatTimestamp(window.resetsAt * 1000L)}"
|
||||
window.resetsAtIso != null -> runCatching { "Resets ${formatTimestamp(Instant.parse(window.resetsAtIso).toEpochMilli())}" }.getOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun windowLabel(seconds: Long?): String = when {
|
||||
seconds == null || seconds <= 0L -> "Usage"
|
||||
seconds < 3_600L -> "${seconds / 60L}m"
|
||||
seconds < 86_400L -> "${seconds / 3_600L}h"
|
||||
else -> "${seconds / 86_400L}d"
|
||||
}
|
||||
|
||||
private fun formatTimestamp(value: Long): String = DateTimeFormatter.ofPattern("MMM d, HH:mm")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochMilli(value))
|
||||
|
||||
private fun parseTime(value: String): Pair<Int, Int>? {
|
||||
val parts = value.split(":")
|
||||
if (parts.size != 2) return null
|
||||
val hour = parts[0].toIntOrNull() ?: return null
|
||||
val minute = parts[1].toIntOrNull() ?: return null
|
||||
return if (hour in 0..23 && minute in 0..59) hour to minute else null
|
||||
}
|
||||
|
||||
private val CodexGreen = Color(0xFF10A37F)
|
||||
private val ClaudeAmber = Color(0xFFD97706)
|
||||
private val WarningAmber = Color(0xFFF59E0B)
|
||||
private val DangerRed = Color(0xFFEF4444)
|
||||
private val MutedButton = Color(0xFF52525B)
|
||||
|
||||
@Composable
|
||||
private fun CodexMobileTheme(content: @Composable () -> Unit) {
|
||||
val dark = darkColorScheme(
|
||||
primary = CodexGreen,
|
||||
secondary = ClaudeAmber,
|
||||
background = Color(0xFF090D11),
|
||||
surface = Color(0xFF10161B),
|
||||
surfaceVariant = Color(0xFF1A2229),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color(0xFFE7ECEF),
|
||||
onSurface = Color(0xFFE7ECEF),
|
||||
onSurfaceVariant = Color(0xFF9CA3AF),
|
||||
)
|
||||
val light = lightColorScheme(
|
||||
primary = CodexGreen,
|
||||
secondary = ClaudeAmber,
|
||||
background = Color(0xFFF6F8F7),
|
||||
surface = Color.White,
|
||||
surfaceVariant = Color(0xFFECEFED),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
)
|
||||
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) dark else light, content = {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, content = content)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dev.reversed.codexbarmobile
|
||||
|
||||
data class CodexAuth(
|
||||
val accessToken: String,
|
||||
val accountId: String? = null,
|
||||
)
|
||||
|
||||
data class UsageWindow(
|
||||
val usedPercent: Double,
|
||||
val resetsAt: Long? = null,
|
||||
val resetsAtIso: String? = null,
|
||||
val windowSeconds: Long? = null,
|
||||
)
|
||||
|
||||
data class CodexCredits(
|
||||
val hasCredits: Boolean,
|
||||
val unlimited: Boolean,
|
||||
val balance: Double,
|
||||
)
|
||||
|
||||
data class ResetCredit(
|
||||
val index: Int,
|
||||
val status: String?,
|
||||
val grantedAt: String?,
|
||||
val expiresAt: String?,
|
||||
val timeUntilExpiry: String?,
|
||||
)
|
||||
|
||||
data class ResetCoupons(
|
||||
val source: String,
|
||||
val sourceDescription: String,
|
||||
val availableCount: Int?,
|
||||
val totalEarnedCount: Int?,
|
||||
val credits: List<ResetCredit>,
|
||||
val nextExpiringCredit: ResetCredit?,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
data class CodexUsage(
|
||||
val planType: String?,
|
||||
val rateLimitReachedType: String?,
|
||||
val primary: UsageWindow?,
|
||||
val secondary: UsageWindow?,
|
||||
val credits: CodexCredits,
|
||||
val resetCoupons: ResetCoupons?,
|
||||
)
|
||||
|
||||
data class ClaudeAuth(
|
||||
val sessionKey: String,
|
||||
val lastActiveOrg: String? = null,
|
||||
)
|
||||
|
||||
data class ClaudeOrg(
|
||||
val uuid: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
data class ClaudeWindowUsage(
|
||||
val utilization: Double,
|
||||
val resetsAt: String? = null,
|
||||
)
|
||||
|
||||
data class ClaudeUsage(
|
||||
val fiveHour: ClaudeWindowUsage,
|
||||
val sevenDay: ClaudeWindowUsage,
|
||||
val sevenDaySonnet: ClaudeWindowUsage? = null,
|
||||
val sevenDayOpus: ClaudeWindowUsage? = null,
|
||||
)
|
||||
|
||||
data class UsageCache<T>(
|
||||
val data: T,
|
||||
val lastFetchedAt: Long,
|
||||
)
|
||||
|
||||
data class NotificationSettings(
|
||||
val dailyEnabled: Boolean = false,
|
||||
val dailyHour: Int = 9,
|
||||
val dailyMinute: Int = 0,
|
||||
val thresholdEnabled: Boolean = false,
|
||||
val thresholdPct: Int = 20,
|
||||
)
|
||||
|
||||
enum class LoadStatus {
|
||||
Idle,
|
||||
Loading,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
enum class AppScreen {
|
||||
Splash,
|
||||
Welcome,
|
||||
OnboardingCodex,
|
||||
OnboardingClaude,
|
||||
OnboardingDone,
|
||||
Main,
|
||||
}
|
||||
|
||||
enum class MainTab(val label: String) {
|
||||
Dashboard("Home"),
|
||||
Codex("Codex"),
|
||||
Claude("Claude"),
|
||||
Settings("Settings"),
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package dev.reversed.codexbarmobile
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlarmManager
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.time.LocalDate
|
||||
import java.util.Calendar
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CodexMobileApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NotificationScheduler.ensureChannel(this)
|
||||
}
|
||||
}
|
||||
|
||||
object NotificationScheduler {
|
||||
private const val CHANNEL_ID = "usage-alerts"
|
||||
private const val CHANNEL_NAME = "Usage alerts"
|
||||
private const val DAILY_REQUEST_CODE = 1001
|
||||
private const val EXTRA_BODY = "body"
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = "CodexMobile usage digests and quota warnings"
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasNotificationPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun scheduleDailyDigest(context: Context, hour: Int, minute: Int, body: String) {
|
||||
if (!hasNotificationPermission(context)) return
|
||||
ensureChannel(context)
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, DailyDigestReceiver::class.java).putExtra(EXTRA_BODY, body)
|
||||
val pending = PendingIntent.getBroadcast(
|
||||
context,
|
||||
DAILY_REQUEST_CODE,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val firstRun = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, hour)
|
||||
set(Calendar.MINUTE, minute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
if (timeInMillis <= System.currentTimeMillis()) add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
alarmManager.setInexactRepeating(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
firstRun.timeInMillis,
|
||||
AlarmManager.INTERVAL_DAY,
|
||||
pending,
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelDailyDigest(context: Context) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val pending = PendingIntent.getBroadcast(
|
||||
context,
|
||||
DAILY_REQUEST_CODE,
|
||||
Intent(context, DailyDigestReceiver::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
alarmManager.cancel(pending)
|
||||
pending.cancel()
|
||||
}
|
||||
|
||||
fun showQuotaAlert(context: Context, body: String) {
|
||||
if (!hasNotificationPermission(context)) return
|
||||
showNotification(
|
||||
context = context,
|
||||
id = 2001,
|
||||
title = "Low quota warning",
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
|
||||
fun showDailyDigest(context: Context, body: String) {
|
||||
showNotification(
|
||||
context = context,
|
||||
id = 1002,
|
||||
title = "Usage digest",
|
||||
body = body.ifBlank { "Open CodexMobile to check your usage limits." },
|
||||
)
|
||||
}
|
||||
|
||||
private fun showNotification(context: Context, id: Int, title: String, body: String) {
|
||||
if (!hasNotificationPermission(context)) return
|
||||
ensureChannel(context)
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setContentIntent(openIntent)
|
||||
.setAutoCancel(true)
|
||||
.setColor(0xFF10A37F.toInt())
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).notify(id, notification)
|
||||
}
|
||||
}
|
||||
|
||||
class DailyDigestReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
NotificationScheduler.showDailyDigest(context, intent.getStringExtra("body").orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun maybeFireThresholdAlert(
|
||||
context: Context,
|
||||
storage: AppStorage,
|
||||
settings: NotificationSettings,
|
||||
claudeUsage: ClaudeUsage?,
|
||||
codexUsage: CodexUsage?,
|
||||
) {
|
||||
if (!settings.thresholdEnabled) return
|
||||
val today = LocalDate.now().toString()
|
||||
if (storage.loadThresholdLastFired() == today) return
|
||||
|
||||
val alerts = buildList {
|
||||
claudeUsage?.let {
|
||||
val remaining = 100.0 - it.sevenDay.utilization
|
||||
if (remaining <= settings.thresholdPct) add("Claude 7-day: ${remaining.roundPercent()}% remaining")
|
||||
}
|
||||
codexUsage?.let {
|
||||
val remaining = 100.0 - (it.secondary?.usedPercent ?: 0.0)
|
||||
if (remaining <= settings.thresholdPct) add("Codex weekly: ${remaining.roundPercent()}% remaining")
|
||||
}
|
||||
}
|
||||
if (alerts.isNotEmpty()) {
|
||||
NotificationScheduler.showQuotaAlert(context, alerts.joinToString("\n"))
|
||||
storage.saveThresholdLastFired(today)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDailyDigestBody(claudeUsage: ClaudeUsage?, codexUsage: CodexUsage?): String {
|
||||
val parts = buildList {
|
||||
claudeUsage?.let { add("Claude 7d: ${it.sevenDay.utilization.roundPercent()}% used") }
|
||||
codexUsage?.secondary?.let { add("Codex weekly: ${it.usedPercent.roundPercent()}% used") }
|
||||
}
|
||||
return if (parts.isEmpty()) "Open CodexMobile to check your usage limits." else parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
fun Double.roundPercent(): Int = roundToInt().coerceIn(0, 100)
|
||||
@@ -0,0 +1,196 @@
|
||||
package dev.reversed.codexbarmobile
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UsageApi {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(25, TimeUnit.SECONDS)
|
||||
.readTimeout(25, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
suspend fun fetchCodexUsage(auth: CodexAuth): CodexUsage = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url(CODEX_USAGE_URL)
|
||||
.applyCodexHeaders(auth)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val payload = client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) throw IllegalStateException("TOKEN_EXPIRED")
|
||||
if (!response.isSuccessful) throw IllegalStateException("HTTP_ERROR_${response.code}")
|
||||
JSONObject(response.body?.string().orEmpty())
|
||||
}
|
||||
|
||||
val resetCoupons = fetchResetCoupons(auth, payload)
|
||||
CodexUsage(
|
||||
planType = payload.optStringOrNull("plan_type"),
|
||||
rateLimitReachedType = payload.optStringOrNull("rate_limit_reached_type"),
|
||||
primary = payload.optJSONObject("rate_limit")?.optJSONObject("primary_window")?.toCodexWindow(),
|
||||
secondary = payload.optJSONObject("rate_limit")?.optJSONObject("secondary_window")?.toCodexWindow(),
|
||||
credits = payload.optJSONObject("credits").let { credits ->
|
||||
CodexCredits(
|
||||
hasCredits = credits?.optBoolean("has_credits") ?: false,
|
||||
unlimited = credits?.optBoolean("unlimited") ?: false,
|
||||
balance = credits?.optDouble("balance", 0.0) ?: 0.0,
|
||||
)
|
||||
},
|
||||
resetCoupons = resetCoupons,
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchResetCoupons(auth: CodexAuth, usagePayload: JSONObject): ResetCoupons {
|
||||
val request = Request.Builder()
|
||||
.url(CODEX_RESET_CREDITS_URL)
|
||||
.applyCodexHeaders(auth)
|
||||
.header("OpenAI-Beta", "codex-1")
|
||||
.header("originator", "Codex Desktop")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("HTTP_ERROR_${response.code}")
|
||||
val payload = JSONObject(response.body?.string().orEmpty())
|
||||
val rawCredits = payload.optJSONArray("credits") ?: JSONArray()
|
||||
val credits = (0 until rawCredits.length())
|
||||
.mapNotNull { index -> rawCredits.optJSONObject(index)?.toResetCredit(index + 1) }
|
||||
.sortedBy { credit -> credit.expiresAt?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: Instant.MAX }
|
||||
val available = credits.filter { it.status == "available" }
|
||||
ResetCoupons(
|
||||
source = "live_api",
|
||||
sourceDescription = "Live Codex reset-credit endpoint",
|
||||
availableCount = payload.optIntOrNull("available_count")
|
||||
?: usagePayload.optJSONObject("rate_limit_reset_credits")?.optIntOrNull("available_count"),
|
||||
totalEarnedCount = payload.optIntOrNull("total_earned_count"),
|
||||
credits = credits,
|
||||
nextExpiringCredit = available.firstOrNull() ?: credits.firstOrNull(),
|
||||
)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
ResetCoupons(
|
||||
source = "unavailable",
|
||||
sourceDescription = "Reset-credit endpoint unavailable",
|
||||
availableCount = usagePayload.optJSONObject("rate_limit_reset_credits")?.optIntOrNull("available_count"),
|
||||
totalEarnedCount = null,
|
||||
credits = emptyList(),
|
||||
nextExpiringCredit = null,
|
||||
error = error.message ?: "UNKNOWN_ERROR",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchClaudeUsage(auth: ClaudeAuth): ClaudeUsage = withContext(Dispatchers.IO) {
|
||||
val orgId = auth.lastActiveOrg?.takeIf { it.isNotBlank() } ?: fetchClaudeOrgs(auth).firstOrNull()?.uuid
|
||||
?: throw IllegalStateException("NO_ORGS_FOUND")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$CLAUDE_BASE_URL/organizations/$orgId/usage")
|
||||
.applyClaudeHeaders(auth)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) throw IllegalStateException("TOKEN_EXPIRED")
|
||||
if (!response.isSuccessful) throw IllegalStateException("HTTP_ERROR_${response.code}")
|
||||
JSONObject(response.body?.string().orEmpty()).toClaudeUsageFromApi()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchClaudeOrgs(auth: ClaudeAuth): List<ClaudeOrg> {
|
||||
val request = Request.Builder()
|
||||
.url("$CLAUDE_BASE_URL/organizations")
|
||||
.applyClaudeHeaders(auth)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) throw IllegalStateException("TOKEN_EXPIRED")
|
||||
if (!response.isSuccessful) throw IllegalStateException("HTTP_ERROR_${response.code}")
|
||||
val arr = JSONArray(response.body?.string().orEmpty())
|
||||
(0 until arr.length()).mapNotNull { index ->
|
||||
arr.optJSONObject(index)?.let { ClaudeOrg(it.getString("uuid"), it.optString("name")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Request.Builder.applyCodexHeaders(auth: CodexAuth): Request.Builder = apply {
|
||||
header("Authorization", "Bearer ${auth.accessToken}")
|
||||
header("Content-Type", "application/json")
|
||||
auth.accountId?.takeIf { it.isNotBlank() }?.let { header("ChatGPT-Account-Id", it) }
|
||||
}
|
||||
|
||||
private fun Request.Builder.applyClaudeHeaders(auth: ClaudeAuth): Request.Builder = apply {
|
||||
val cookies = buildList {
|
||||
add("sessionKey=${auth.sessionKey}")
|
||||
auth.lastActiveOrg?.takeIf { it.isNotBlank() }?.let { add("lastActiveOrg=$it") }
|
||||
}
|
||||
header("Cookie", cookies.joinToString("; "))
|
||||
header("Accept", "application/json, text/plain, */*")
|
||||
header("Accept-Language", "en-US,en;q=0.9")
|
||||
header("User-Agent", BROWSER_USER_AGENT)
|
||||
header("Referer", "https://claude.ai/")
|
||||
header("Origin", "https://claude.ai")
|
||||
header("anthropic-client-platform", "web_claude_to_cc_migration")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
|
||||
private const val CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
|
||||
private const val CLAUDE_BASE_URL = "https://claude.ai/api"
|
||||
private const val BROWSER_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.toCodexWindow(): UsageWindow {
|
||||
val seconds = optLong("limit_window_seconds", 0L)
|
||||
return UsageWindow(
|
||||
usedPercent = optDouble("used_percent", 0.0),
|
||||
resetsAt = optLong("reset_at", 0L).takeIf { it > 0L },
|
||||
windowSeconds = seconds.takeIf { it > 0L },
|
||||
)
|
||||
}
|
||||
|
||||
private fun JSONObject.toResetCredit(index: Int): ResetCredit = ResetCredit(
|
||||
index = index,
|
||||
status = optStringOrNull("status"),
|
||||
grantedAt = optStringOrNull("granted_at"),
|
||||
expiresAt = optStringOrNull("expires_at"),
|
||||
timeUntilExpiry = formatDurationUntil(optStringOrNull("expires_at")),
|
||||
)
|
||||
|
||||
private fun JSONObject.toClaudeUsageFromApi(): ClaudeUsage = ClaudeUsage(
|
||||
fiveHour = getJSONObject("five_hour").toClaudeWindowFromApi(),
|
||||
sevenDay = getJSONObject("seven_day").toClaudeWindowFromApi(),
|
||||
sevenDaySonnet = optJSONObject("seven_day_sonnet")?.toClaudeWindowFromApi(),
|
||||
sevenDayOpus = optJSONObject("seven_day_opus")?.toClaudeWindowFromApi(),
|
||||
)
|
||||
|
||||
private fun JSONObject.toClaudeWindowFromApi(): ClaudeWindowUsage = ClaudeWindowUsage(
|
||||
utilization = optDouble("utilization", 0.0),
|
||||
resetsAt = optStringOrNull("resets_at"),
|
||||
)
|
||||
|
||||
fun formatDurationUntil(isoString: String?): String? {
|
||||
if (isoString.isNullOrBlank()) return null
|
||||
val target = runCatching { Instant.parse(isoString).toEpochMilli() }.getOrNull() ?: return null
|
||||
var remaining = ((target - System.currentTimeMillis()).coerceAtLeast(0L) / 1000L)
|
||||
val days = remaining / 86_400L
|
||||
remaining -= days * 86_400L
|
||||
val hours = remaining / 3_600L
|
||||
remaining -= hours * 3_600L
|
||||
val minutes = remaining / 60L
|
||||
remaining -= minutes * 60L
|
||||
return buildList {
|
||||
if (days > 0) add("${days}d")
|
||||
if (hours > 0) add("${hours}h")
|
||||
if (minutes > 0) add("${minutes}m")
|
||||
if (remaining > 0 || isEmpty()) add("${remaining}s")
|
||||
}.joinToString(" ")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:fillColor="#10A37F" android:pathData="M23,22h40a12,12 0,0 1,12 12v40a12,12 0,0 1,-12 12h-40a12,12 0,0 1,-12 -12v-40a12,12 0,0 1,12 -12z" />
|
||||
<path android:fillColor="#D97706" android:pathData="M48,22h37a12,12 0,0 1,12 12v40a12,12 0,0 1,-12 12h-37a12,12 0,0 1,-12 -12v-40a12,12 0,0 1,12 -12z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M55,31l5.8,15.2L76,52l-15.2,5.8L55,73l-5.8,-15.2L34,52l15.2,-5.8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M12,2l2.4,6.4L21,11l-6.6,2.6L12,20l-2.4,-6.4L3,11l6.6,-2.6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="codexmobile_seed">#10A37F</color>
|
||||
<color name="ic_launcher_background">#071311</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">CodexMobile</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.CodexMobile" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:navigationBarColor">#090D11</item>
|
||||
<item name="android:statusBarColor">#090D11</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup disableIfNoEncryptionCapabilities="true" />
|
||||
<device-transfer />
|
||||
</data-extraction-rules>
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1006 KiB |
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 985 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 985 KiB |
@@ -1,10 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
plugins: ["react-native-reanimated/plugin"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '8.5.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
|
||||
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
|
||||
INVALID_JSON: "The selected file is not valid JSON.",
|
||||
DOCUMENT_NOT_READABLE:
|
||||
"The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.",
|
||||
NO_ORGS_FOUND: "No Claude organizations found for this session key.",
|
||||
INVALID_CODEX_RESPONSE:
|
||||
"Codex returned an unexpected response. The app was kept safe from invalid data.",
|
||||
INVALID_CLAUDE_RESPONSE:
|
||||
"Claude returned an unexpected usage response. The app was kept safe from invalid data.",
|
||||
INVALID_CLAUDE_ORGS_RESPONSE:
|
||||
"Claude returned an unexpected organizations response.",
|
||||
UNKNOWN_ERROR: "An unknown error occurred. Try refreshing.",
|
||||
};
|
||||
|
||||
function humanize(code: string): string {
|
||||
if (code.startsWith("HTTP_ERROR_")) {
|
||||
const status = code.replace("HTTP_ERROR_", "");
|
||||
if (status === "429") return "Rate limited by the server. Try again shortly.";
|
||||
if (status === "500" || status === "502" || status === "503")
|
||||
return `Server error (${status}). The service may be down.`;
|
||||
return `Unexpected server response (HTTP ${status}).`;
|
||||
}
|
||||
if (
|
||||
code === "Network request failed" ||
|
||||
code.includes("fetch") ||
|
||||
code.includes("network")
|
||||
) {
|
||||
return "Network error. Check your internet connection and try again.";
|
||||
}
|
||||
return ERROR_MESSAGES[code] ?? `Unexpected error: ${code}`;
|
||||
}
|
||||
|
||||
interface ErrorMessageProps {
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export function ErrorMessage({ message }: ErrorMessageProps) {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<View className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl px-3 py-2.5 mb-3">
|
||||
<Text className="text-sm text-red-700 dark:text-red-300">{humanize(message)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants";
|
||||
import { formatLastUpdated } from "@/lib/timeUtils";
|
||||
|
||||
export function LastUpdated({ timestamp }: { timestamp: number | null }) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Date.now()), COUNTDOWN_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!timestamp) return null;
|
||||
|
||||
return (
|
||||
<Text selectable className="text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{formatLastUpdated(timestamp, now)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { View } from "react-native";
|
||||
import { PROGRESS_THRESHOLDS } from "@/lib/constants";
|
||||
|
||||
interface ProgressBarProps {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
function getBarColor(percent: number): string {
|
||||
if (percent >= PROGRESS_THRESHOLDS.danger) return "bg-red-500";
|
||||
if (percent >= PROGRESS_THRESHOLDS.warning) return "bg-yellow-400";
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
export function ProgressBar({ percent }: ProgressBarProps) {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
return (
|
||||
<View className="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-700">
|
||||
<View
|
||||
className={`h-2.5 rounded-full ${getBarColor(clamped)}`}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants";
|
||||
import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils";
|
||||
|
||||
interface ResetCountdownProps {
|
||||
resetAtSeconds?: number;
|
||||
resetAtISO?: string;
|
||||
}
|
||||
|
||||
export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) {
|
||||
const latest = useRef({ resetAtSeconds, resetAtISO });
|
||||
latest.current = { resetAtSeconds, resetAtISO };
|
||||
|
||||
const getLabel = () => {
|
||||
if (latest.current.resetAtSeconds !== undefined) {
|
||||
return formatResetCountdown(latest.current.resetAtSeconds);
|
||||
}
|
||||
if (latest.current.resetAtISO) return formatResetCountdownISO(latest.current.resetAtISO);
|
||||
return "";
|
||||
};
|
||||
|
||||
const [label, setLabel] = useState(getLabel);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setLabel(getLabel());
|
||||
update();
|
||||
const interval = setInterval(update, COUNTDOWN_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLabel(getLabel());
|
||||
}, [resetAtSeconds, resetAtISO]);
|
||||
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">{label}</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
screenName: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ScreenErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-8">
|
||||
<Text selectable className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||
{this.props.screenName} hit a snag
|
||||
</Text>
|
||||
<Text
|
||||
selectable
|
||||
className="mt-2 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
This screen could not be rendered. The rest of Codexbar is still available.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => this.setState({ error: null })}
|
||||
className="mt-5 rounded-xl px-4 py-3"
|
||||
style={{ backgroundColor: COLORS.codex }}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-white">Try again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
interface SectionCardProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
accentClass?: string;
|
||||
}
|
||||
|
||||
export function SectionCard({ title, children, accentClass }: SectionCardProps) {
|
||||
return (
|
||||
<View
|
||||
className={`rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-4 mb-4 ${accentClass ?? ""}`}
|
||||
>
|
||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{title}
|
||||
</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { ResetCountdown } from "./ResetCountdown";
|
||||
|
||||
interface UsageRow {
|
||||
label: string;
|
||||
percent: number;
|
||||
resetAtSeconds?: number;
|
||||
resetAtISO?: string;
|
||||
}
|
||||
|
||||
interface ServiceStatusCardProps {
|
||||
title: string;
|
||||
icon: React.ComponentProps<typeof MaterialIcons>["name"];
|
||||
accentColor: string;
|
||||
status: "idle" | "loading" | "success" | "error";
|
||||
badge?: string;
|
||||
rows?: UsageRow[];
|
||||
footer?: React.ReactNode;
|
||||
unconfiguredLabel?: string;
|
||||
onConfigurePress?: () => void;
|
||||
}
|
||||
|
||||
export function ServiceStatusCard({
|
||||
title,
|
||||
icon,
|
||||
accentColor,
|
||||
status,
|
||||
badge,
|
||||
rows,
|
||||
footer,
|
||||
unconfiguredLabel,
|
||||
onConfigurePress,
|
||||
}: ServiceStatusCardProps) {
|
||||
return (
|
||||
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 p-4 mb-4">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="flex-row items-center gap-x-2.5">
|
||||
<View
|
||||
className="w-8 h-8 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: `${accentColor}20` }}
|
||||
>
|
||||
<MaterialIcons name={icon} size={16} color={accentColor} />
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-neutral-800 dark:text-white">
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
{badge && (
|
||||
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
|
||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||
{badge}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === "success" && !badge && (
|
||||
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
{status === "error" && (
|
||||
<View className="w-2 h-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{status === "loading" && (
|
||||
<ActivityIndicator color={accentColor} style={{ marginVertical: 12 }} />
|
||||
)}
|
||||
|
||||
{status === "idle" && (
|
||||
<View className="py-2 items-start">
|
||||
<Text className="text-sm text-neutral-400 dark:text-neutral-500 mb-3">
|
||||
{unconfiguredLabel ?? "Not configured"}
|
||||
</Text>
|
||||
{onConfigurePress && (
|
||||
<TouchableOpacity
|
||||
onPress={onConfigurePress}
|
||||
className="px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700"
|
||||
>
|
||||
<Text className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
||||
Configure →
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<Text className="text-sm text-red-500 dark:text-red-400 py-2">
|
||||
Failed to load usage data
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{status === "success" && rows && (
|
||||
<View>
|
||||
{rows.map((row, i) => (
|
||||
<View key={i} className={`gap-y-1 ${i < rows.length - 1 ? "mb-3" : ""}`}>
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text className="text-xs font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
{Math.round(row.percent)}%
|
||||
</Text>
|
||||
</View>
|
||||
<ProgressBar percent={row.percent} />
|
||||
<ResetCountdown
|
||||
resetAtSeconds={row.resetAtSeconds}
|
||||
resetAtISO={row.resetAtISO}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{footer && (
|
||||
<View className="mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-800">
|
||||
{footer}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { ResetCountdown } from "./ResetCountdown";
|
||||
|
||||
interface UsageStatProps {
|
||||
label: string;
|
||||
percent: number;
|
||||
resetAtSeconds?: number;
|
||||
resetAtISO?: string;
|
||||
}
|
||||
|
||||
export function UsageStat({ label, percent, resetAtSeconds, resetAtISO }: UsageStatProps) {
|
||||
return (
|
||||
<View className="gap-y-1.5 mb-4">
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{label}
|
||||
</Text>
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{Math.round(percent)}%
|
||||
</Text>
|
||||
</View>
|
||||
<ProgressBar percent={percent} />
|
||||
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"preview": {
|
||||
"channel": "preview",
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "production",
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copyright 2015-2021 the original authors.
|
||||
# Licensed under the Apache License, Version 2.0.
|
||||
|
||||
APP_HOME=${0%/*}
|
||||
[ "$APP_HOME" = "$0" ] && APP_HOME=.
|
||||
APP_HOME=$(cd "$APP_HOME" >/dev/null && pwd -P) || exit
|
||||
APP_BASE_NAME=${0##*/}
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
if [ -n "$JAVA_HOME" ]; then
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
else
|
||||
JAVACMD=java
|
||||
fi
|
||||
|
||||
if ! command -v "$JAVACMD" >/dev/null 2>&1 && [ ! -x "$JAVACMD" ]; then
|
||||
echo "ERROR: Java is not available. Set JAVA_HOME to a JDK 17 installation." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "-Xmx64m" "-Xms64m" "-Dorg.gradle.appname=$APP_BASE_NAME" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
@@ -0,0 +1,23 @@
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
echo ERROR: JAVA_HOME is not set and no java command could be found. 1>&2
|
||||
exit /b 1
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
exit /b 1
|
||||
:execute
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchClaudeOrgs,
|
||||
fetchClaudeUsage,
|
||||
parseClaudeUsageResponse,
|
||||
} from "@/lib/api/claudeApi";
|
||||
import {
|
||||
isClaudeSessionKeyValid,
|
||||
validateClaudeSessionKey,
|
||||
} from "@/lib/claudeCredentials";
|
||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||
import {
|
||||
clearClaudeLastActiveOrg,
|
||||
clearClaudeSessionKey,
|
||||
clearClaudeUsageCache,
|
||||
loadClaudeLastActiveOrg,
|
||||
loadClaudeSessionKey,
|
||||
loadClaudeUsageCache,
|
||||
saveClaudeLastActiveOrg,
|
||||
saveClaudeSessionKey,
|
||||
saveClaudeUsageCache,
|
||||
} from "@/lib/storage";
|
||||
import type { ClaudeAuth, ClaudeUsageResponse } from "@/types/claude";
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
function shouldRetry(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return ![
|
||||
"TOKEN_EXPIRED",
|
||||
"NO_ORGS_FOUND",
|
||||
"INVALID_CLAUDE_RESPONSE",
|
||||
"INVALID_CLAUDE_ORGS_RESPONSE",
|
||||
].includes(message);
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function requestClaudeUsage(auth: ClaudeAuth): Promise<ClaudeUsageResponse> {
|
||||
let orgUuid = auth.lastActiveOrg?.trim();
|
||||
if (!orgUuid) {
|
||||
const orgs = await fetchClaudeOrgs(auth);
|
||||
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
|
||||
orgUuid = orgs[0].uuid;
|
||||
await saveClaudeLastActiveOrg(orgUuid);
|
||||
}
|
||||
return fetchClaudeUsage(auth, orgUuid);
|
||||
}
|
||||
|
||||
export function useClaudeUsage() {
|
||||
const [auth, setAuth] = useState<ClaudeAuth | null>(null);
|
||||
const [pendingKey, setPendingKey] = useState("");
|
||||
const [pendingOrg, setPendingOrg] = useState("");
|
||||
const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null);
|
||||
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const inFlight = useRef<{
|
||||
key: string;
|
||||
promise: Promise<void>;
|
||||
} | null>(null);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUsage = useCallback((credentials: ClaudeAuth): Promise<void> => {
|
||||
const sanitized = {
|
||||
sessionKey: credentials.sessionKey.trim(),
|
||||
lastActiveOrg: credentials.lastActiveOrg?.trim() || undefined,
|
||||
};
|
||||
const requestKey = `${sanitized.lastActiveOrg ?? ""}:${sanitized.sessionKey}`;
|
||||
if (inFlight.current?.key === requestKey) return inFlight.current.promise;
|
||||
const version = ++requestVersion.current;
|
||||
|
||||
const promise = (async () => {
|
||||
if (mounted.current) {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
let data: ClaudeUsageResponse;
|
||||
try {
|
||||
data = await requestClaudeUsage(sanitized);
|
||||
} catch (firstError) {
|
||||
if (!shouldRetry(firstError)) throw firstError;
|
||||
await wait(RETRY_DELAY_MS);
|
||||
if (version !== requestVersion.current) return;
|
||||
data = await requestClaudeUsage(sanitized);
|
||||
}
|
||||
|
||||
if (version !== requestVersion.current) return;
|
||||
const fetchedAt = Date.now();
|
||||
await saveClaudeUsageCache({ data, lastFetchedAt: fetchedAt });
|
||||
if (mounted.current && version === requestVersion.current) {
|
||||
setUsage(data);
|
||||
setLastFetchedAt(fetchedAt);
|
||||
setStatus("success");
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (version !== requestVersion.current) return;
|
||||
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
|
||||
if (message === "TOKEN_EXPIRED") {
|
||||
await clearClaudeSessionKey();
|
||||
if (mounted.current) {
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
}
|
||||
}
|
||||
if (mounted.current) {
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
if (inFlight.current?.promise === promise) inFlight.current = null;
|
||||
});
|
||||
|
||||
inFlight.current = { key: requestKey, promise };
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void Promise.all([
|
||||
loadClaudeSessionKey(),
|
||||
loadClaudeLastActiveOrg(),
|
||||
loadClaudeUsageCache(),
|
||||
]).then(async ([key, org, cache]) => {
|
||||
if (!active) return;
|
||||
|
||||
const storedAuth = key
|
||||
? { sessionKey: key.trim(), lastActiveOrg: org?.trim() || undefined }
|
||||
: null;
|
||||
|
||||
if (storedAuth && cache) {
|
||||
try {
|
||||
if (!Number.isFinite(cache.lastFetchedAt)) {
|
||||
throw new Error("INVALID_CACHE_TIMESTAMP");
|
||||
}
|
||||
const cachedUsage = parseClaudeUsageResponse(cache.data);
|
||||
setUsage(cachedUsage);
|
||||
setLastFetchedAt(cache.lastFetchedAt);
|
||||
setStatus("success");
|
||||
} catch {
|
||||
await clearClaudeUsageCache();
|
||||
}
|
||||
}
|
||||
|
||||
setAuth(storedAuth);
|
||||
if (storedAuth) await fetchUsage(storedAuth);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fetchUsage]);
|
||||
|
||||
const saveKey = useCallback(async () => {
|
||||
const trimmedKey = pendingKey.trim();
|
||||
const validationError = validateClaudeSessionKey(trimmedKey);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedOrg = pendingOrg.trim() || undefined;
|
||||
await saveClaudeSessionKey(trimmedKey);
|
||||
if (trimmedOrg) await saveClaudeLastActiveOrg(trimmedOrg);
|
||||
else await clearClaudeLastActiveOrg();
|
||||
|
||||
const nextAuth = { sessionKey: trimmedKey, lastActiveOrg: trimmedOrg };
|
||||
setAuth(nextAuth);
|
||||
setPendingKey("");
|
||||
setPendingOrg("");
|
||||
setError(null);
|
||||
await fetchUsage(nextAuth);
|
||||
}, [fetchUsage, pendingKey, pendingOrg]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (auth) await fetchUsage(auth);
|
||||
}, [auth, fetchUsage]);
|
||||
|
||||
const reloadCredentials = useCallback(async () => {
|
||||
const [key, org] = await Promise.all([
|
||||
loadClaudeSessionKey(),
|
||||
loadClaudeLastActiveOrg(),
|
||||
]);
|
||||
if (!key) {
|
||||
requestVersion.current += 1;
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = {
|
||||
sessionKey: key.trim(),
|
||||
lastActiveOrg: org?.trim() || undefined,
|
||||
};
|
||||
setAuth(stored);
|
||||
await fetchUsage(stored);
|
||||
}, [fetchUsage]);
|
||||
|
||||
const clearKey = useCallback(async () => {
|
||||
requestVersion.current += 1;
|
||||
await clearClaudeSessionKey();
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
auth,
|
||||
pendingKey,
|
||||
setPendingKey,
|
||||
pendingOrg,
|
||||
setPendingOrg,
|
||||
usage,
|
||||
lastFetchedAt,
|
||||
status,
|
||||
error,
|
||||
keyValidationError: pendingKey
|
||||
? validateClaudeSessionKey(pendingKey)
|
||||
: null,
|
||||
canSaveKey: isClaudeSessionKeyValid(pendingKey),
|
||||
saveKey,
|
||||
refresh,
|
||||
reloadCredentials,
|
||||
clearKey,
|
||||
};
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchCodexUsage,
|
||||
parseCodexUsageResponse,
|
||||
} from "@/lib/api/codexApi";
|
||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
||||
import {
|
||||
clearCodexAuth,
|
||||
clearCodexUsageCache,
|
||||
loadCodexAuth,
|
||||
loadCodexUsageCache,
|
||||
saveCodexAuth,
|
||||
saveCodexUsageCache,
|
||||
} from "@/lib/storage";
|
||||
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
function shouldRetry(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return message !== "TOKEN_EXPIRED" && message !== "INVALID_CODEX_RESPONSE";
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function useCodexUsage() {
|
||||
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
||||
const [usage, setUsage] = useState<CodexUsageResponse | null>(null);
|
||||
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const inFlight = useRef<{
|
||||
key: string;
|
||||
promise: Promise<void>;
|
||||
} | null>(null);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUsage = useCallback((credentials: CodexAuth): Promise<void> => {
|
||||
const requestKey = `${credentials.accountId ?? ""}:${credentials.accessToken}`;
|
||||
if (inFlight.current?.key === requestKey) return inFlight.current.promise;
|
||||
const version = ++requestVersion.current;
|
||||
|
||||
const promise = (async () => {
|
||||
if (mounted.current) {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
let data: CodexUsageResponse;
|
||||
try {
|
||||
data = await fetchCodexUsage(credentials);
|
||||
} catch (firstError) {
|
||||
if (!shouldRetry(firstError)) throw firstError;
|
||||
await wait(RETRY_DELAY_MS);
|
||||
if (version !== requestVersion.current) return;
|
||||
data = await fetchCodexUsage(credentials);
|
||||
}
|
||||
|
||||
if (version !== requestVersion.current) return;
|
||||
const fetchedAt = Date.now();
|
||||
await saveCodexUsageCache({ data, lastFetchedAt: fetchedAt });
|
||||
if (mounted.current && version === requestVersion.current) {
|
||||
setUsage(data);
|
||||
setLastFetchedAt(fetchedAt);
|
||||
setStatus("success");
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (version !== requestVersion.current) return;
|
||||
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
|
||||
if (mounted.current) {
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
if (inFlight.current?.promise === promise) inFlight.current = null;
|
||||
});
|
||||
|
||||
inFlight.current = { key: requestKey, promise };
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void Promise.all([loadCodexAuth(), loadCodexUsageCache()]).then(
|
||||
async ([storedAuth, cache]) => {
|
||||
if (!active) return;
|
||||
|
||||
if (storedAuth && cache) {
|
||||
try {
|
||||
if (!Number.isFinite(cache.lastFetchedAt)) {
|
||||
throw new Error("INVALID_CACHE_TIMESTAMP");
|
||||
}
|
||||
const cachedUsage = parseCodexUsageResponse(cache.data);
|
||||
setUsage(cachedUsage);
|
||||
setLastFetchedAt(cache.lastFetchedAt);
|
||||
setStatus("success");
|
||||
} catch {
|
||||
await clearCodexUsageCache();
|
||||
}
|
||||
}
|
||||
|
||||
setAuth(storedAuth);
|
||||
if (storedAuth) await fetchUsage(storedAuth);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fetchUsage]);
|
||||
|
||||
const importAuthFile = useCallback(async () => {
|
||||
try {
|
||||
const parsed = await pickAndReadCodexAuth();
|
||||
await saveCodexAuth(parsed);
|
||||
setAuth(parsed);
|
||||
setError(null);
|
||||
await fetchUsage(parsed);
|
||||
} catch (caught: unknown) {
|
||||
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
|
||||
if (message !== "PICKER_CANCELLED") setError(message);
|
||||
}
|
||||
}, [fetchUsage]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (auth) await fetchUsage(auth);
|
||||
}, [auth, fetchUsage]);
|
||||
|
||||
const reloadCredentials = useCallback(async () => {
|
||||
const stored = await loadCodexAuth();
|
||||
if (!stored) {
|
||||
requestVersion.current += 1;
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
setAuth(stored);
|
||||
await fetchUsage(stored);
|
||||
}, [fetchUsage]);
|
||||
|
||||
const clearAuth = useCallback(async () => {
|
||||
requestVersion.current += 1;
|
||||
await clearCodexAuth();
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setLastFetchedAt(null);
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
auth,
|
||||
usage,
|
||||
lastFetchedAt,
|
||||
status,
|
||||
error,
|
||||
importAuthFile,
|
||||
refresh,
|
||||
reloadCredentials,
|
||||
clearAuth,
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { ClaudeAuth, ClaudeOrg, ClaudeUsageResponse } from "@/types/claude";
|
||||
|
||||
const BASE_URL = "https://claude.ai/api";
|
||||
|
||||
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 {
|
||||
Cookie: cookies.join("; "),
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"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",
|
||||
};
|
||||
}
|
||||
|
||||
function isClaudeWindow(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const window = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof window.utilization === "number" &&
|
||||
Number.isFinite(window.utilization) &&
|
||||
(window.resets_at === undefined ||
|
||||
window.resets_at === null ||
|
||||
typeof window.resets_at === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export function parseClaudeOrgsResponse(data: unknown): ClaudeOrg[] {
|
||||
if (
|
||||
!Array.isArray(data) ||
|
||||
data.some(
|
||||
(org) =>
|
||||
!org ||
|
||||
typeof org !== "object" ||
|
||||
typeof (org as Record<string, unknown>).uuid !== "string" ||
|
||||
typeof (org as Record<string, unknown>).name !== "string"
|
||||
)
|
||||
) {
|
||||
throw new Error("INVALID_CLAUDE_ORGS_RESPONSE");
|
||||
}
|
||||
return data.map((org) => {
|
||||
const value = org as Record<string, unknown>;
|
||||
return { uuid: value.uuid as string, name: value.name as string };
|
||||
});
|
||||
}
|
||||
|
||||
export function parseClaudeUsageResponse(data: unknown): ClaudeUsageResponse {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("INVALID_CLAUDE_RESPONSE");
|
||||
}
|
||||
|
||||
const value = data as Record<string, unknown>;
|
||||
if (
|
||||
!isClaudeWindow(value.five_hour) ||
|
||||
!isClaudeWindow(value.seven_day) ||
|
||||
(value.seven_day_sonnet !== undefined &&
|
||||
value.seven_day_sonnet !== null &&
|
||||
!isClaudeWindow(value.seven_day_sonnet)) ||
|
||||
(value.seven_day_opus !== undefined &&
|
||||
value.seven_day_opus !== null &&
|
||||
!isClaudeWindow(value.seven_day_opus))
|
||||
) {
|
||||
throw new Error("INVALID_CLAUDE_RESPONSE");
|
||||
}
|
||||
|
||||
const toWindow = (window: unknown) => {
|
||||
const item = window as Record<string, unknown>;
|
||||
return {
|
||||
utilization: item.utilization as number,
|
||||
...(typeof item.resets_at === "string"
|
||||
? { resets_at: item.resets_at }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
five_hour: toWindow(value.five_hour),
|
||||
seven_day: toWindow(value.seven_day),
|
||||
...(value.seven_day_sonnet
|
||||
? { seven_day_sonnet: toWindow(value.seven_day_sonnet) }
|
||||
: {}),
|
||||
...(value.seven_day_opus
|
||||
? { seven_day_opus: toWindow(value.seven_day_opus) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise<ClaudeOrg[]> {
|
||||
const response = await fetch(`${BASE_URL}/organizations`, {
|
||||
headers: buildHeaders(auth),
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
return parseClaudeOrgsResponse(data);
|
||||
}
|
||||
|
||||
export async function fetchClaudeUsage(
|
||||
auth: ClaudeAuth,
|
||||
orgUuid: string
|
||||
): Promise<ClaudeUsageResponse> {
|
||||
const response = await fetch(`${BASE_URL}/organizations/${orgUuid}/usage`, {
|
||||
headers: buildHeaders(auth),
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
return parseClaudeUsageResponse(data);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
||||
|
||||
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const RESET_CREDITS_URL =
|
||||
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
|
||||
|
||||
interface RawUsageWindow {
|
||||
used_percent?: number;
|
||||
limit_window_seconds?: number;
|
||||
reset_at?: number;
|
||||
}
|
||||
|
||||
interface RawUsageResponse {
|
||||
plan_type?: string | null;
|
||||
rate_limit?: {
|
||||
primary_window?: RawUsageWindow | null;
|
||||
secondary_window?: RawUsageWindow | null;
|
||||
} | null;
|
||||
credits?: {
|
||||
has_credits?: boolean;
|
||||
unlimited?: boolean;
|
||||
balance?: string | number;
|
||||
} | null;
|
||||
rate_limit_reached_type?: string | null;
|
||||
rate_limit_reset_credits?: {
|
||||
available_count?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface RawResetCredit {
|
||||
status?: string;
|
||||
granted_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
interface RawResetCreditsResponse {
|
||||
available_count?: number | null;
|
||||
total_earned_count?: number | null;
|
||||
credits?: RawResetCredit[] | null;
|
||||
}
|
||||
|
||||
function isNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function formatDurationUntil(isoString: string | null | undefined): string | null {
|
||||
if (!isoString) return null;
|
||||
|
||||
const targetMs = new Date(isoString).getTime();
|
||||
if (Number.isNaN(targetMs)) return null;
|
||||
|
||||
let remainingSeconds = Math.max(Math.round((targetMs - Date.now()) / 1000), 0);
|
||||
const days = Math.floor(remainingSeconds / 86400);
|
||||
remainingSeconds -= days * 86400;
|
||||
const hours = Math.floor(remainingSeconds / 3600);
|
||||
remainingSeconds -= hours * 3600;
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
remainingSeconds -= minutes * 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function normalizeWindow(window: RawUsageWindow | null | undefined) {
|
||||
if (!window) return null;
|
||||
|
||||
const windowSeconds =
|
||||
typeof window.limit_window_seconds === "number" ? window.limit_window_seconds : 0;
|
||||
const resetsAt = typeof window.reset_at === "number" ? window.reset_at : 0;
|
||||
|
||||
return {
|
||||
usedPercent: typeof window.used_percent === "number" ? window.used_percent : 0,
|
||||
resetsAt,
|
||||
windowMinutes: Math.round(windowSeconds / 60),
|
||||
windowSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseHeaders(auth: CodexAuth): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${auth.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (auth.accountId) {
|
||||
headers["ChatGPT-Account-Id"] = auth.accountId;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchResetCoupons(auth: CodexAuth, usage: RawUsageResponse) {
|
||||
const headers = {
|
||||
...buildBaseHeaders(auth),
|
||||
"OpenAI-Beta": "codex-1",
|
||||
originator: "Codex Desktop",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(RESET_CREDITS_URL, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as RawResetCreditsResponse;
|
||||
const credits = Array.isArray(payload.credits) ? payload.credits : [];
|
||||
const normalizedCredits = credits
|
||||
.map((credit, index) => ({
|
||||
index: index + 1,
|
||||
status: credit.status,
|
||||
grantedAt: credit.granted_at ?? null,
|
||||
grantedAtLocal: credit.granted_at
|
||||
? new Date(credit.granted_at).toLocaleString()
|
||||
: null,
|
||||
expiresAt: credit.expires_at ?? null,
|
||||
expiresAtLocal: credit.expires_at
|
||||
? new Date(credit.expires_at).toLocaleString()
|
||||
: null,
|
||||
timeUntilExpiry: formatDurationUntil(credit.expires_at),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const left = a.expiresAt ? new Date(a.expiresAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
const right = b.expiresAt ? new Date(b.expiresAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return left - right;
|
||||
});
|
||||
|
||||
const availableCredits = normalizedCredits.filter(
|
||||
(credit) => credit.status === "available"
|
||||
);
|
||||
|
||||
return {
|
||||
source: "live_api" as const,
|
||||
sourceDescription: "Live Codex reset-credit endpoint",
|
||||
availableCount:
|
||||
payload.available_count ?? usage.rate_limit_reset_credits?.available_count ?? null,
|
||||
totalEarnedCount: payload.total_earned_count ?? null,
|
||||
credits: normalizedCredits,
|
||||
nextExpiringCredit: availableCredits[0] ?? normalizedCredits[0] ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return {
|
||||
source: "unavailable" as const,
|
||||
sourceDescription: "Reset-credit endpoint unavailable",
|
||||
availableCount: usage.rate_limit_reset_credits?.available_count ?? null,
|
||||
fallbackReason: message,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCodexUsageResponse(data: unknown): CodexUsageResponse {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("INVALID_CODEX_RESPONSE");
|
||||
}
|
||||
|
||||
const value = data as Record<string, unknown>;
|
||||
const primary = value.primary as Record<string, unknown> | null | undefined;
|
||||
const secondary = value.secondary as Record<string, unknown> | null | undefined;
|
||||
const credits = value.credits as Record<string, unknown> | undefined;
|
||||
|
||||
const validWindow = (window: Record<string, unknown> | null | undefined) =>
|
||||
window === null ||
|
||||
window === undefined ||
|
||||
(isNumber(window.usedPercent) &&
|
||||
isNumber(window.resetsAt) &&
|
||||
isNumber(window.windowMinutes) &&
|
||||
isNumber(window.windowSeconds));
|
||||
|
||||
if (
|
||||
!validWindow(primary) ||
|
||||
!validWindow(secondary) ||
|
||||
!credits ||
|
||||
typeof credits.hasCredits !== "boolean" ||
|
||||
typeof credits.unlimited !== "boolean" ||
|
||||
!isNumber(credits.balance)
|
||||
) {
|
||||
throw new Error("INVALID_CODEX_RESPONSE");
|
||||
}
|
||||
|
||||
return value as unknown as CodexUsageResponse;
|
||||
}
|
||||
|
||||
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
|
||||
const headers = buildBaseHeaders(auth);
|
||||
const response = await fetch(USAGE_URL, { headers });
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("TOKEN_EXPIRED");
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => null);
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as RawUsageResponse;
|
||||
const resetCoupons = await fetchResetCoupons(auth, payload);
|
||||
|
||||
return {
|
||||
planType: payload.plan_type ?? null,
|
||||
rateLimitReachedType: payload.rate_limit_reached_type ?? null,
|
||||
primary: normalizeWindow(payload.rate_limit?.primary_window),
|
||||
secondary: normalizeWindow(payload.rate_limit?.secondary_window),
|
||||
credits: {
|
||||
hasCredits: Boolean(payload.credits?.has_credits),
|
||||
unlimited: Boolean(payload.credits?.unlimited),
|
||||
balance: Number(payload.credits?.balance ?? 0),
|
||||
},
|
||||
resetCoupons,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
const CLAUDE_SESSION_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function validateClaudeSessionKey(value: string): string | null {
|
||||
const key = value.trim();
|
||||
if (!key.startsWith("sk-ant-")) {
|
||||
return "Session key must start with sk-ant-";
|
||||
}
|
||||
if (key.length <= 40) {
|
||||
return "Session key looks too short. Paste the complete cookie value.";
|
||||
}
|
||||
if (!CLAUDE_SESSION_KEY_PATTERN.test(key)) {
|
||||
return "Session key contains unexpected characters. Check the pasted value.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isClaudeSessionKeyValid(value: string): boolean {
|
||||
return validateClaudeSessionKey(value) === null;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export const COLORS = {
|
||||
codex: "#10a37f",
|
||||
claude: "#d97706",
|
||||
danger: "#ef4444",
|
||||
warning: "#f59e0b",
|
||||
disabled: "#e5e5e5",
|
||||
muted: "#a3a3a3",
|
||||
appBackground: "#090d11",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_THRESHOLDS = {
|
||||
warning: 60,
|
||||
danger: 85,
|
||||
} as const;
|
||||
|
||||
export const COUNTDOWN_INTERVAL_MS = 60_000;
|
||||
export const RETRY_DELAY_MS = 5_000;
|
||||
|
||||
export function getUsageColor(percent: number): string {
|
||||
if (percent >= PROGRESS_THRESHOLDS.danger) return COLORS.danger;
|
||||
if (percent >= PROGRESS_THRESHOLDS.warning) return COLORS.warning;
|
||||
return COLORS.codex;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import type { CodexAuth } from "@/types/codex";
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
}
|
||||
|
||||
async function readPickedTextFile(uri: string, fileName?: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(uri);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
} catch {
|
||||
try {
|
||||
return await LegacyFileSystem.readAsStringAsync(uri);
|
||||
} catch {
|
||||
const cacheDirectory = LegacyFileSystem.cacheDirectory;
|
||||
if (!cacheDirectory) {
|
||||
throw new Error("DOCUMENT_NOT_READABLE");
|
||||
}
|
||||
|
||||
const destination = `${cacheDirectory}${Date.now()}-${sanitizeFileName(
|
||||
fileName ?? "import.json"
|
||||
)}`;
|
||||
|
||||
try {
|
||||
await LegacyFileSystem.copyAsync({ from: uri, to: destination });
|
||||
return await LegacyFileSystem.readAsStringAsync(destination);
|
||||
} catch {
|
||||
throw new Error("DOCUMENT_NOT_READABLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: "application/json",
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (result.canceled) throw new Error("PICKER_CANCELLED");
|
||||
|
||||
const { uri, name } = result.assets[0];
|
||||
const text = await readPickedTextFile(uri, name);
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("INVALID_JSON");
|
||||
}
|
||||
|
||||
const tokens = parsed["tokens"] as Record<string, unknown> | undefined;
|
||||
const accessToken = (tokens?.["access_token"] ?? parsed["accessToken"]) as string | undefined;
|
||||
const accountId = (
|
||||
tokens?.["account_id"] ??
|
||||
parsed["accountId"] ??
|
||||
parsed["account_id"]
|
||||
) as string | undefined;
|
||||
|
||||
if (!accessToken || typeof accessToken !== "string") {
|
||||
throw new Error("MISSING_ACCESS_TOKEN");
|
||||
}
|
||||
|
||||
return { accessToken, accountId };
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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.secondary?.usedPercent ?? 0)}% 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.secondary?.usedPercent ?? 0);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
const KEY = "codexbar_onboarding_v1";
|
||||
|
||||
export async function hasCompletedOnboarding(): Promise<boolean> {
|
||||
const val = await SecureStore.getItemAsync(KEY);
|
||||
return val === "done";
|
||||
}
|
||||
|
||||
export async function markOnboardingComplete(): Promise<void> {
|
||||
await SecureStore.setItemAsync(KEY, "done");
|
||||
}
|
||||
|
||||
export async function resetOnboarding(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(KEY);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import type { ClaudeUsageResponse } from "@/types/claude";
|
||||
import type { CodexUsageResponse } from "@/types/codex";
|
||||
|
||||
const KEYS = {
|
||||
CODEX_AUTH: "codexbar_codex_auth",
|
||||
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
|
||||
CLAUDE_LAST_ACTIVE_ORG: "codexbar_claude_last_active_org",
|
||||
CODEX_USAGE_CACHE: "codexbar_codex_usage_cache",
|
||||
CLAUDE_USAGE_CACHE: "codexbar_claude_usage_cache",
|
||||
NOTIF_DAILY_ENABLED: "notifDailyEnabled",
|
||||
NOTIF_DAILY_HOUR: "notifDailyHour",
|
||||
NOTIF_DAILY_MINUTE: "notifDailyMinute",
|
||||
NOTIF_THRESHOLD_ENABLED: "notifThresholdEnabled",
|
||||
NOTIF_THRESHOLD_PCT: "notifThresholdPct",
|
||||
NOTIF_THRESHOLD_LAST_FIRED: "notifThresholdLastFired",
|
||||
} as const;
|
||||
|
||||
export interface UsageCache<T> {
|
||||
data: T;
|
||||
lastFetchedAt: number;
|
||||
}
|
||||
|
||||
async function loadJson<T>(key: string): Promise<T | null> {
|
||||
const raw = await SecureStore.getItemAsync(key);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJson(key: string, value: unknown): Promise<void> {
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
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: {
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
await saveJson(KEYS.CODEX_AUTH, auth);
|
||||
}
|
||||
|
||||
export async function loadCodexAuth(): Promise<{
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
} | null> {
|
||||
return loadJson(KEYS.CODEX_AUTH);
|
||||
}
|
||||
|
||||
export function loadCodexUsageCache(): Promise<UsageCache<CodexUsageResponse> | null> {
|
||||
return loadJson(KEYS.CODEX_USAGE_CACHE);
|
||||
}
|
||||
|
||||
export function saveCodexUsageCache(
|
||||
cache: UsageCache<CodexUsageResponse>
|
||||
): Promise<void> {
|
||||
return saveJson(KEYS.CODEX_USAGE_CACHE, cache);
|
||||
}
|
||||
|
||||
export function clearCodexUsageCache(): Promise<void> {
|
||||
return SecureStore.deleteItemAsync(KEYS.CODEX_USAGE_CACHE);
|
||||
}
|
||||
|
||||
export function loadClaudeUsageCache(): Promise<UsageCache<ClaudeUsageResponse> | null> {
|
||||
return loadJson(KEYS.CLAUDE_USAGE_CACHE);
|
||||
}
|
||||
|
||||
export function saveClaudeUsageCache(
|
||||
cache: UsageCache<ClaudeUsageResponse>
|
||||
): Promise<void> {
|
||||
return saveJson(KEYS.CLAUDE_USAGE_CACHE, cache);
|
||||
}
|
||||
|
||||
export function clearClaudeUsageCache(): Promise<void> {
|
||||
return SecureStore.deleteItemAsync(KEYS.CLAUDE_USAGE_CACHE);
|
||||
}
|
||||
|
||||
export async function saveClaudeSessionKey(key: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key.trim());
|
||||
}
|
||||
|
||||
export async function loadClaudeSessionKey(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
||||
}
|
||||
|
||||
export async function saveClaudeLastActiveOrg(orgId: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId.trim());
|
||||
}
|
||||
|
||||
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> {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(KEYS.CODEX_AUTH),
|
||||
clearCodexUsageCache(),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function clearClaudeSessionKey(): Promise<void> {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY),
|
||||
SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG),
|
||||
clearClaudeUsageCache(),
|
||||
]);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
export function formatResetCountdown(resetAtSeconds: number): string {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const diffSeconds = resetAtSeconds - nowSeconds;
|
||||
|
||||
if (diffSeconds <= 0) return "resetting now";
|
||||
if (diffSeconds < 60) return "resets in <1m";
|
||||
|
||||
const totalHours = Math.floor(diffSeconds / 3600);
|
||||
const minutes = Math.floor((diffSeconds % 3600) / 60);
|
||||
|
||||
if (totalHours >= 24) {
|
||||
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`;
|
||||
}
|
||||
|
||||
export function parseTimeInput(
|
||||
value: string
|
||||
): { hour: number; minute: number } | null {
|
||||
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const hour = Number.parseInt(match[1], 10);
|
||||
const minute = Number.parseInt(match[2], 10);
|
||||
if (hour > 23 || minute > 59) return null;
|
||||
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
export function formatTime(hour: number, minute: number): string {
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatLastUpdated(timestamp: number, now = Date.now()): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (elapsedSeconds < 60) return "Updated just now";
|
||||
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
if (minutes < 60) return `Updated ${minutes} min ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `Updated ${hours} hr${hours === 1 ? "" : "s"} ago`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `Updated ${days} day${days === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
export function formatResetCountdownISO(isoString: string): string {
|
||||
const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000);
|
||||
return formatResetCountdown(resetAtSeconds);
|
||||
}
|
||||
|
||||
export function windowLabel(limitWindowSeconds: number): string {
|
||||
const hours = limitWindowSeconds / 3600;
|
||||
if (hours < 24) return `${Math.round(hours)}h`;
|
||||
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`;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: "./global.css" });
|
||||
@@ -1,6 +0,0 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
|
||||
declare module "*.css" {
|
||||
const content: unknown;
|
||||
export default content;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"name": "codexbar.mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~56.0.15",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"babel-preset-expo": "~56.0.0",
|
||||
"expo": "~56.0.14",
|
||||
"expo-constants": "~56.0.20",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-linking": "~56.0.15",
|
||||
"expo-notifications": "~56.0.19",
|
||||
"expo-router": "~56.2.13",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-updates": "~56.0.21",
|
||||
"nativewind": "^4.2.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "^0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"update:preview": "eas update --channel preview --environment preview",
|
||||
"update:production": "eas update --channel production --environment production",
|
||||
"build:android:preview": "eas build --platform android --profile preview --non-interactive",
|
||||
"build:android:production": "eas build --platform android --profile production --non-interactive"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'CodexMobile'
|
||||
include ':app'
|
||||
@@ -1,20 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,jsx,ts,tsx}",
|
||||
"./components/**/*.{js,jsx,ts,tsx}",
|
||||
"./hooks/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
codex: "#10a37f",
|
||||
claude: "#d97706",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.d.ts",
|
||||
"nativewind-env.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export interface ClaudeAuth {
|
||||
sessionKey: string;
|
||||
lastActiveOrg?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeOrg {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ClaudeWindowUsage {
|
||||
utilization: number;
|
||||
resets_at?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeUsageResponse {
|
||||
five_hour: ClaudeWindowUsage;
|
||||
seven_day: ClaudeWindowUsage;
|
||||
seven_day_sonnet?: ClaudeWindowUsage;
|
||||
seven_day_opus?: ClaudeWindowUsage;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export interface CodexAuth {
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export interface CodexUsageWindow {
|
||||
usedPercent: number;
|
||||
resetsAt: number;
|
||||
windowMinutes: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
export interface CodexCreditsSummary {
|
||||
hasCredits: boolean;
|
||||
unlimited: boolean;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface CodexResetCredit {
|
||||
index: number;
|
||||
status?: string;
|
||||
grantedAt?: string | null;
|
||||
grantedAtLocal?: string | null;
|
||||
expiresAt?: string | null;
|
||||
expiresAtLocal?: string | null;
|
||||
timeUntilExpiry?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexResetCoupons {
|
||||
source: "live_api" | "local_state_fallback" | "unavailable";
|
||||
sourceDescription: string;
|
||||
availableCount?: number | null;
|
||||
totalEarnedCount?: number | null;
|
||||
credits?: CodexResetCredit[];
|
||||
nextExpiringCredit?: CodexResetCredit | null;
|
||||
dismissedAtLocal?: string | null;
|
||||
latestPossibleExpiryLocal?: string | null;
|
||||
latestPossibleExpiryNote?: string | null;
|
||||
fallbackReason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CodexDesktopSnapshot {
|
||||
sessionFile?: string;
|
||||
threadId?: string | null;
|
||||
snapshotTimestamp?: string | null;
|
||||
limitId?: string | null;
|
||||
planType?: string | null;
|
||||
rateLimitReachedType?: string | null;
|
||||
primary: CodexUsageWindow | null;
|
||||
secondary: CodexUsageWindow | null;
|
||||
}
|
||||
|
||||
export interface CodexUsageResponse extends CodexDesktopSnapshot {
|
||||
credits: CodexCreditsSummary;
|
||||
resetCoupons?: CodexResetCoupons | null;
|
||||
}
|
||||