Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d6548d77d | |||
| 81e351aa38 | |||
| 896f9149f1 | |||
| dc7e497388 | |||
| 5a00822690 | |||
| 2c31be11c4 | |||
| 9dd17cf565 | |||
| 6992e58b9c | |||
| 050c8e5cce | |||
| 6bf66c0afd | |||
| 2c63dcfd75 |
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"enabledPlugins": {
|
|
||||||
"expo@claude-plugins-official": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,93 +1,85 @@
|
|||||||
name: CodexBar Mobile Build
|
name: android-release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
tags:
|
||||||
- '**/*.ts'
|
- 'v*'
|
||||||
- '**/*.tsx'
|
workflow_dispatch:
|
||||||
- '**/*.js'
|
|
||||||
- '**/*.json'
|
|
||||||
- '.gitea/workflows/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 🏗 Setup repo
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 🏗 Setup Node
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: 🏗 Setup Java
|
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 17
|
java-version: 17
|
||||||
|
|
||||||
- name: 🏗 Setup Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@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
|
|
||||||
run: |
|
run: |
|
||||||
eas build --local \
|
export ANDROID_SDK_ROOT="$HOME/android-sdk"
|
||||||
--non-interactive \
|
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
--output=./app-build \
|
curl -fsSL -o /tmp/cmdline-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
--platform=android \
|
unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools
|
||||||
--profile=preview
|
mv /tmp/cmdline-tools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
|
export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$PATH"
|
||||||
|
yes | sdkmanager --sdk_root="$ANDROID_SDK_ROOT" --licenses >/dev/null || true
|
||||||
|
sdkmanager --sdk_root="$ANDROID_SDK_ROOT" 'platform-tools' 'platforms;android-34' 'build-tools;34.0.0'
|
||||||
|
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
|
||||||
|
echo "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||||
|
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: 📝 Rename build to APK
|
- name: Set up Gradle cache
|
||||||
run: mv app-build codexbar-release.apk
|
uses: gradle/actions/setup-gradle@v3
|
||||||
|
|
||||||
- name: 🗜 Zip APK
|
- name: Write release keystore
|
||||||
run: zip codexbar-release.zip codexbar-release.apk
|
env:
|
||||||
|
CODEXMOBILE_KEYSTORE_BASE64: ${{ secrets.CODEXMOBILE_KEYSTORE_BASE64 }}
|
||||||
- name: 📤 Upload build artifact
|
CODEXMOBILE_KEYSTORE_PASSWORD: ${{ secrets.CODEXMOBILE_KEYSTORE_PASSWORD }}
|
||||||
uses: actions/upload-artifact@v3
|
CODEXMOBILE_KEY_ALIAS: ${{ secrets.CODEXMOBILE_KEY_ALIAS }}
|
||||||
with:
|
CODEXMOBILE_KEY_PASSWORD: ${{ secrets.CODEXMOBILE_KEY_PASSWORD }}
|
||||||
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
|
|
||||||
run: |
|
run: |
|
||||||
TAG="codexbar-build-$(git rev-parse --short HEAD)"
|
if [ -z "$CODEXMOBILE_KEYSTORE_BASE64" ] || [ -z "$CODEXMOBILE_KEYSTORE_PASSWORD" ] || [ -z "$CODEXMOBILE_KEY_ALIAS" ] || [ -z "$CODEXMOBILE_KEY_PASSWORD" ]; then
|
||||||
git tag "$TAG"
|
echo "Missing release signing secrets" >&2
|
||||||
git push origin "$TAG"
|
exit 1
|
||||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
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: 🚀 Create release
|
- name: Build release APK
|
||||||
uses: softprops/action-gh-release@v1
|
run: |
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
- 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:
|
with:
|
||||||
tag_name: ${{ env.RELEASE_TAG }}
|
tag_name: ${{ env.RELEASE_TAG }}
|
||||||
name: ${{ env.RELEASE_TAG }}
|
target_commitish: ${{ github.sha }}
|
||||||
files: codexbar-release.zip
|
files: |
|
||||||
generate_release_notes: true
|
app/build/outputs/apk/release/*.apk
|
||||||
env:
|
body: |
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
CodexMobile release built from ${{ github.ref_name }} at ${{ github.sha }}.
|
||||||
|
prerelease: false
|
||||||
|
draft: false
|
||||||
|
|||||||
+9
-34
@@ -1,41 +1,16 @@
|
|||||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
.gradle/
|
||||||
|
build/
|
||||||
# dependencies
|
app/build/
|
||||||
node_modules/
|
local.properties
|
||||||
|
keystore.properties
|
||||||
# Expo
|
|
||||||
.expo/
|
|
||||||
dist/
|
|
||||||
web-build/
|
|
||||||
expo-env.d.ts
|
|
||||||
|
|
||||||
# Native
|
|
||||||
.kotlin/
|
.kotlin/
|
||||||
*.orig.*
|
|
||||||
*.jks
|
*.jks
|
||||||
|
*.keystore
|
||||||
*.p8
|
*.p8
|
||||||
*.p12
|
*.p12
|
||||||
*.key
|
*.key
|
||||||
*.mobileprovision
|
|
||||||
|
|
||||||
# Metro
|
|
||||||
.metro-health-check*
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.*
|
|
||||||
yarn-debug.*
|
|
||||||
yarn-error.*
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
*.pem
|
||||||
|
*.orig.*
|
||||||
# local env files
|
.env.ci
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.DS_Store
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# generated native folders
|
|
||||||
/ios
|
|
||||||
/android
|
|
||||||
|
|||||||
@@ -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,41 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"name": "codexbar-mobile",
|
|
||||||
"slug": "codexbar-mobile",
|
|
||||||
"scheme": "codexbar",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"icon": "./assets/icon.png",
|
|
||||||
"userInterfaceStyle": "dark",
|
|
||||||
"ios": {
|
|
||||||
"supportsTablet": true
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"package": "dev.reversed.codexbar",
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"backgroundColor": "#E6F4FE",
|
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
|
||||||
"backgroundImage": "./assets/android-icon-background.png",
|
|
||||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
|
||||||
},
|
|
||||||
"predictiveBackGestureEnabled": false
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"favicon": "./assets/favicon.png"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"expo-router",
|
|
||||||
"expo-status-bar",
|
|
||||||
"expo-secure-store",
|
|
||||||
"expo-document-picker",
|
|
||||||
"expo-notifications"
|
|
||||||
],
|
|
||||||
"extra": {
|
|
||||||
"router": {},
|
|
||||||
"eas": {
|
|
||||||
"projectId": "9196c5ee-4c6e-4229-9a99-54b50afe38e8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"owner": "spacebanane"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +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";
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const isDark = colorScheme === "dark";
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
tabBarActiveTintColor: "#10a37f",
|
|
||||||
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,208 +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";
|
|
||||||
|
|
||||||
export default function ClaudeTab() {
|
|
||||||
const {
|
|
||||||
auth,
|
|
||||||
pendingKey,
|
|
||||||
setPendingKey,
|
|
||||||
pendingOrg,
|
|
||||||
setPendingOrg,
|
|
||||||
usage,
|
|
||||||
status,
|
|
||||||
error,
|
|
||||||
saveKey,
|
|
||||||
refresh,
|
|
||||||
clearKey,
|
|
||||||
} = useClaudeUsage();
|
|
||||||
|
|
||||||
const canSave = pendingKey.trim().startsWith("sk-ant-");
|
|
||||||
|
|
||||||
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="#d97706"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 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: "#d9770622" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="psychology" size={18} color="#d97706" />
|
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
<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={!canSave}
|
|
||||||
className="py-3.5 px-4 rounded-xl items-center"
|
|
||||||
style={{ backgroundColor: canSave ? "#d97706" : "#e5e5e5" }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className="font-semibold text-sm"
|
|
||||||
style={{ color: canSave ? "white" : "#a3a3a3" }}
|
|
||||||
>
|
|
||||||
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" && (
|
|
||||||
<ActivityIndicator color="#d97706" style={{ marginVertical: 24 }} />
|
|
||||||
)}
|
|
||||||
{status === "error" && error && <ErrorMessage message={error} />}
|
|
||||||
{status === "success" && 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>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,167 +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 { windowLabel } from "@/lib/timeUtils";
|
|
||||||
|
|
||||||
export default function CodexTab() {
|
|
||||||
const { auth, usage, status, error, importAuthFile, refresh, clearAuth } =
|
|
||||||
useCodexUsage();
|
|
||||||
|
|
||||||
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="#10a37f"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 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: "#10a37f22" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="auto-awesome" size={18} color="#10a37f" />
|
|
||||||
</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: "#10a37f" }}
|
|
||||||
>
|
|
||||||
<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" && (
|
|
||||||
<ActivityIndicator color="#10a37f" style={{ marginVertical: 24 }} />
|
|
||||||
)}
|
|
||||||
{status === "error" && error && <ErrorMessage message={error} />}
|
|
||||||
{status === "success" && 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.plan_type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<UsageStat
|
|
||||||
label={`Primary (${windowLabel(usage.rate_limit.primary_window.limit_window_seconds)})`}
|
|
||||||
percent={usage.rate_limit.primary_window.used_percent}
|
|
||||||
resetAtSeconds={usage.rate_limit.primary_window.reset_at}
|
|
||||||
/>
|
|
||||||
<UsageStat
|
|
||||||
label={`Secondary (${windowLabel(usage.rate_limit.secondary_window.limit_window_seconds)})`}
|
|
||||||
percent={usage.rate_limit.secondary_window.used_percent}
|
|
||||||
resetAtSeconds={usage.rate_limit.secondary_window.reset_at}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 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="#10a37f" />
|
|
||||||
<Text className="text-sm font-semibold text-green-600 dark:text-green-400">
|
|
||||||
Unlimited
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : usage.credits.has_credits ? (
|
|
||||||
<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>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,315 +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 { windowLabel } from "@/lib/timeUtils";
|
|
||||||
|
|
||||||
function UsageRow({
|
|
||||||
label,
|
|
||||||
percent,
|
|
||||||
resetAtSeconds,
|
|
||||||
resetAtISO,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
percent: number;
|
|
||||||
resetAtSeconds?: number;
|
|
||||||
resetAtISO?: string;
|
|
||||||
}) {
|
|
||||||
const color =
|
|
||||||
percent >= 90 ? "#ef4444" : percent >= 70 ? "#f59e0b" : "#10a37f";
|
|
||||||
return (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex-row justify-between items-baseline mb-1.5">
|
|
||||||
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-sm font-bold" style={{ color }}>
|
|
||||||
{Math.round(percent)}% used
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<ProgressBar percent={percent} />
|
|
||||||
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SetupPrompt({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<Text className="text-sm text-neutral-400 dark:text-neutral-500 mb-3">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.navigate("/(tabs)/settings")}
|
|
||||||
className="flex-row items-center self-start gap-x-1.5 px-3 py-2 rounded-xl border border-neutral-200 dark:border-neutral-700"
|
|
||||||
>
|
|
||||||
<Text className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
|
||||||
Set up in Settings
|
|
||||||
</Text>
|
|
||||||
<MaterialIcons name="arrow-forward" size={12} color="#737373" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardTab() {
|
|
||||||
const codex = useCodexUsage();
|
|
||||||
const claude = useClaudeUsage();
|
|
||||||
|
|
||||||
// Fire daily digest reschedule + threshold check whenever fresh data arrives
|
|
||||||
useEffect(() => {
|
|
||||||
if (codex.status === "success" || claude.status === "success") {
|
|
||||||
void onUsageDataLoaded(
|
|
||||||
claude.status === "success" ? claude.usage : null,
|
|
||||||
codex.status === "success" ? codex.usage : null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [codex.status, claude.status]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
codex.reload();
|
|
||||||
claude.reload();
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const isRefreshing =
|
|
||||||
codex.status === "loading" || claude.status === "loading";
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
codex.refresh();
|
|
||||||
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="#10a37f"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 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="#10a37f" />
|
|
||||||
</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: "#10a37f" }} />
|
|
||||||
<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: "#10a37f18" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="auto-awesome" size={16} color="#10a37f" />
|
|
||||||
</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?.plan_type && (
|
|
||||||
<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.plan_type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{codex.auth && codex.status === "error" && (
|
|
||||||
<View className="w-2 h-2 rounded-full bg-red-500" />
|
|
||||||
)}
|
|
||||||
{codex.status === "success" && !codex.usage?.plan_type && (
|
|
||||||
<View className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{!codex.auth && (
|
|
||||||
<SetupPrompt label="Import auth.json to see your Codex rate limits." />
|
|
||||||
)}
|
|
||||||
{codex.auth && codex.status === "loading" && (
|
|
||||||
<ActivityIndicator
|
|
||||||
color="#10a37f"
|
|
||||||
style={{ marginVertical: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{codex.auth && codex.status === "error" && (
|
|
||||||
<View className="flex-row items-center gap-x-2 py-1">
|
|
||||||
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
|
||||||
<Text className="text-sm text-red-500 dark:text-red-400">
|
|
||||||
Failed to fetch usage
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{codex.status === "success" && codex.usage && (
|
|
||||||
<View>
|
|
||||||
<UsageRow
|
|
||||||
label={`${windowLabel(codex.usage.rate_limit.primary_window.limit_window_seconds)} window`}
|
|
||||||
percent={codex.usage.rate_limit.primary_window.used_percent}
|
|
||||||
resetAtSeconds={codex.usage.rate_limit.primary_window.reset_at}
|
|
||||||
/>
|
|
||||||
<UsageRow
|
|
||||||
label={`${windowLabel(codex.usage.rate_limit.secondary_window.limit_window_seconds)} window`}
|
|
||||||
percent={
|
|
||||||
codex.usage.rate_limit.secondary_window.used_percent
|
|
||||||
}
|
|
||||||
resetAtSeconds={
|
|
||||||
codex.usage.rate_limit.secondary_window.reset_at
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{!codex.usage.credits.unlimited &&
|
|
||||||
codex.usage.credits.has_credits && (
|
|
||||||
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
|
||||||
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
|
|
||||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
||||||
${Number(codex.usage.credits.balance).toFixed(2)}{" "}
|
|
||||||
credits remaining
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{codex.usage.credits.unlimited && (
|
|
||||||
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
|
||||||
<MaterialIcons
|
|
||||||
name="all-inclusive"
|
|
||||||
size={14}
|
|
||||||
color="#10a37f"
|
|
||||||
/>
|
|
||||||
<Text className="text-xs text-green-600 dark:text-green-400">
|
|
||||||
Unlimited credits
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Claude card */}
|
|
||||||
<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: "#d97706" }} />
|
|
||||||
<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: "#d9770618" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="psychology" size={16} color="#d97706" />
|
|
||||||
</View>
|
|
||||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
|
||||||
Claude.ai
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{claude.auth && claude.status === "error" && (
|
|
||||||
<View className="w-2 h-2 rounded-full bg-red-500" />
|
|
||||||
)}
|
|
||||||
{claude.status === "success" && (
|
|
||||||
<View className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{!claude.auth && (
|
|
||||||
<SetupPrompt label="Add your session key to see Claude usage windows." />
|
|
||||||
)}
|
|
||||||
{claude.auth && claude.status === "loading" && (
|
|
||||||
<ActivityIndicator
|
|
||||||
color="#d97706"
|
|
||||||
style={{ marginVertical: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{claude.auth && claude.status === "error" && (
|
|
||||||
<View className="flex-row items-center gap-x-2 py-1">
|
|
||||||
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
|
||||||
<Text className="text-sm text-red-500 dark:text-red-400">
|
|
||||||
Failed to fetch usage
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{claude.status === "success" && claude.usage && (
|
|
||||||
<View>
|
|
||||||
<UsageRow
|
|
||||||
label="5-hour window"
|
|
||||||
percent={claude.usage.five_hour.utilization}
|
|
||||||
resetAtISO={claude.usage.five_hour.resets_at}
|
|
||||||
/>
|
|
||||||
<UsageRow
|
|
||||||
label="7-day window"
|
|
||||||
percent={claude.usage.seven_day.utilization}
|
|
||||||
resetAtISO={claude.usage.seven_day.resets_at}
|
|
||||||
/>
|
|
||||||
{(claude.usage.seven_day_sonnet ||
|
|
||||||
claude.usage.seven_day_opus) && (
|
|
||||||
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800">
|
|
||||||
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
|
|
||||||
By Model (7-day)
|
|
||||||
</Text>
|
|
||||||
{claude.usage.seven_day_sonnet && (
|
|
||||||
<UsageRow
|
|
||||||
label="Sonnet"
|
|
||||||
percent={claude.usage.seven_day_sonnet.utilization}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{claude.usage.seven_day_opus && (
|
|
||||||
<UsageRow
|
|
||||||
label="Opus"
|
|
||||||
percent={claude.usage.seven_day_opus.utilization}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
Alert,
|
|
||||||
ScrollView,
|
|
||||||
TextInput,
|
|
||||||
Switch,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
} 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 type { CodexAuth } from "@/types/codex";
|
|
||||||
import Constants from "expo-constants";
|
|
||||||
|
|
||||||
function parseTimeInput(s: string): { hour: number; minute: number } | null {
|
|
||||||
const m = s.trim().match(/^(\d{1,2}):(\d{2})$/);
|
|
||||||
if (!m) return null;
|
|
||||||
const h = parseInt(m[1], 10);
|
|
||||||
const min = parseInt(m[2], 10);
|
|
||||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
|
||||||
return { hour: h, minute: min };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(hour: number, minute: number): string {
|
|
||||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingRow({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
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" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsTab() {
|
|
||||||
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 [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", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveClaude = async () => {
|
|
||||||
const trimmed = pendingKey.trim();
|
|
||||||
if (!trimmed.startsWith("sk-ant-")) {
|
|
||||||
setClaudeError("Key must start with sk-ant-");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const orgTrimmed = pendingOrg.trim() || undefined;
|
|
||||||
await saveClaudeSessionKey(trimmed);
|
|
||||||
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
|
|
||||||
else await clearClaudeLastActiveOrg();
|
|
||||||
setClaudeKey(trimmed);
|
|
||||||
setPendingKey("");
|
|
||||||
setPendingOrg("");
|
|
||||||
setClaudeError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearCodex = () => {
|
|
||||||
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 });
|
|
||||||
} else {
|
|
||||||
// Reset to last valid value
|
|
||||||
setTimeInput(formatTime(notif.settings.dailyHour, notif.settings.dailyMinute));
|
|
||||||
}
|
|
||||||
}, [timeInput, notif]);
|
|
||||||
|
|
||||||
const handleThresholdBlur = useCallback(() => {
|
|
||||||
const n = parseInt(thresholdInput, 10);
|
|
||||||
if (!isNaN(n) && n >= 1 && n <= 99) {
|
|
||||||
void notif.update({ thresholdPct: n });
|
|
||||||
} else {
|
|
||||||
setThresholdInput(String(notif.settings.thresholdPct));
|
|
||||||
}
|
|
||||||
}, [thresholdInput, notif]);
|
|
||||||
|
|
||||||
const canSaveClaude = pendingKey.trim().startsWith("sk-ant-");
|
|
||||||
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
|
||||||
|
|
||||||
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={{ 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">
|
|
||||||
{claudeError && (
|
|
||||||
<Text className="text-xs text-red-500 mb-2">
|
|
||||||
{claudeError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<TextInput
|
|
||||||
value={pendingKey}
|
|
||||||
onChangeText={(t) => {
|
|
||||||
setPendingKey(t);
|
|
||||||
setClaudeError(null);
|
|
||||||
}}
|
|
||||||
placeholder="sk-ant-… (required)"
|
|
||||||
placeholderTextColor="#a3a3a3"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
secureTextEntry
|
|
||||||
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-neutral-50 dark:bg-neutral-800 mb-2"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
value={pendingOrg}
|
|
||||||
onChangeText={setPendingOrg}
|
|
||||||
placeholder="lastActiveOrg UUID (optional)"
|
|
||||||
placeholderTextColor="#a3a3a3"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-neutral-50 dark:bg-neutral-800 mb-3"
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleSaveClaude}
|
|
||||||
disabled={!canSaveClaude}
|
|
||||||
className="py-3 px-4 rounded-xl items-center"
|
|
||||||
style={{
|
|
||||||
backgroundColor: canSaveClaude ? "#d97706" : "#e5e5e5",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className="font-semibold text-sm"
|
|
||||||
style={{ color: canSaveClaude ? "white" : "#a3a3a3" }}
|
|
||||||
>
|
|
||||||
Save Session Key
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="text-xs text-neutral-400 text-center mt-3 leading-relaxed">
|
|
||||||
Chrome DevTools → Application → Cookies → claude.ai →{" "}
|
|
||||||
<Text className="font-mono text-neutral-500">sessionKey</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Notifications section */}
|
|
||||||
<SectionHeader title="Notifications" />
|
|
||||||
<SectionCard>
|
|
||||||
{notif.permissionStatus !== "granted" ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={async () => {
|
|
||||||
const granted = await notif.askPermissions();
|
|
||||||
if (!granted && notif.permissionStatus === "denied") {
|
|
||||||
Alert.alert(
|
|
||||||
"Notifications blocked",
|
|
||||||
"Go to Settings → CodexBar → Notifications to enable them."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-row items-center gap-x-3 py-3.5 px-4"
|
|
||||||
>
|
|
||||||
<View className="w-8 items-center">
|
|
||||||
<MaterialIcons name="notifications-off" size={20} color="#737373" />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
||||||
Enable notifications
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-neutral-400 mt-0.5">
|
|
||||||
{notif.permissionStatus === "denied"
|
|
||||||
? "Blocked — open system Settings to allow"
|
|
||||||
: "Required to receive alerts"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{notif.permissionStatus !== "denied" && (
|
|
||||||
<View className="px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30">
|
|
||||||
<Text className="text-xs font-semibold text-amber-700 dark:text-amber-300">
|
|
||||||
Allow
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Daily digest */}
|
|
||||||
<View className="flex-row items-center gap-x-3 py-3.5 px-4">
|
|
||||||
<View className="w-8 items-center">
|
|
||||||
<MaterialIcons name="alarm" size={20} color="#737373" />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
||||||
Daily digest
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-neutral-400 mt-0.5">
|
|
||||||
Daily reminder with last-known usage
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={notif.settings.dailyEnabled}
|
|
||||||
onValueChange={(v) => void notif.update({ dailyEnabled: v })}
|
|
||||||
trackColor={{ false: "#e5e5e5", true: "#10a37f" }}
|
|
||||||
thumbColor="white"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{notif.settings.dailyEnabled && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<View className="flex-row items-center gap-x-3 py-3 px-4">
|
|
||||||
<View className="w-8 items-center">
|
|
||||||
<MaterialIcons name="schedule" size={20} color="#737373" />
|
|
||||||
</View>
|
|
||||||
<Text className="flex-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
||||||
Time
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={timeInput}
|
|
||||||
onChangeText={setTimeInput}
|
|
||||||
onBlur={handleTimeBlur}
|
|
||||||
placeholder="09:00"
|
|
||||||
placeholderTextColor="#a3a3a3"
|
|
||||||
keyboardType="numbers-and-punctuation"
|
|
||||||
returnKeyType="done"
|
|
||||||
maxLength={5}
|
|
||||||
className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right"
|
|
||||||
style={{ minWidth: 52 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Threshold alert */}
|
|
||||||
<View className="flex-row items-center gap-x-3 py-3.5 px-4">
|
|
||||||
<View className="w-8 items-center">
|
|
||||||
<MaterialIcons name="warning" size={20} color="#737373" />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
||||||
Low quota alert
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-neutral-400 mt-0.5">
|
|
||||||
Alert when weekly quota runs low
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={notif.settings.thresholdEnabled}
|
|
||||||
onValueChange={(v) => void notif.update({ thresholdEnabled: v })}
|
|
||||||
trackColor={{ false: "#e5e5e5", true: "#d97706" }}
|
|
||||||
thumbColor="white"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{notif.settings.thresholdEnabled && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<View className="flex-row items-center gap-x-3 py-3 px-4">
|
|
||||||
<View className="w-8 items-center">
|
|
||||||
<MaterialIcons name="battery-alert" size={20} color="#737373" />
|
|
||||||
</View>
|
|
||||||
<Text className="flex-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
||||||
Alert below
|
|
||||||
</Text>
|
|
||||||
<View className="flex-row items-center gap-x-1">
|
|
||||||
<TextInput
|
|
||||||
value={thresholdInput}
|
|
||||||
onChangeText={setThresholdInput}
|
|
||||||
onBlur={handleThresholdBlur}
|
|
||||||
keyboardType="numeric"
|
|
||||||
returnKeyType="done"
|
|
||||||
maxLength={2}
|
|
||||||
className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right"
|
|
||||||
style={{ minWidth: 28 }}
|
|
||||||
/>
|
|
||||||
<Text className="text-sm text-neutral-400 dark:text-neutral-500">
|
|
||||||
% remaining
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* App section */}
|
|
||||||
<SectionHeader title="App" />
|
|
||||||
<SectionCard>
|
|
||||||
<SettingRow
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,18 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { View, ActivityIndicator } from "react-native";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { hasCompletedOnboarding } from "@/lib/setupState";
|
|
||||||
|
|
||||||
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="#10a37f" 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,225 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
TextInput,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
} from "react-native";
|
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
saveClaudeSessionKey,
|
|
||||||
saveClaudeLastActiveOrg,
|
|
||||||
clearClaudeLastActiveOrg,
|
|
||||||
loadClaudeSessionKey,
|
|
||||||
loadClaudeLastActiveOrg,
|
|
||||||
} from "@/lib/storage";
|
|
||||||
|
|
||||||
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 = pendingKey.trim().startsWith("sk-ant-");
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
const trimmed = pendingKey.trim();
|
|
||||||
if (!trimmed.startsWith("sk-ant-")) {
|
|
||||||
setError("Session key must start with sk-ant-");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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={Platform.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: "#10a37f" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="check" size={14} color="white" />
|
|
||||||
</View>
|
|
||||||
<View className="w-12 h-0.5" style={{ backgroundColor: "#d97706" }} />
|
|
||||||
<View
|
|
||||||
className="w-6 h-6 rounded-full items-center justify-center"
|
|
||||||
style={{ backgroundColor: "#d97706" }}
|
|
||||||
>
|
|
||||||
<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: "#d9770622" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="psychology" size={32} color="#d97706" />
|
|
||||||
</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: "#d9770622" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="check-circle" size={22} color="#d97706" />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-white font-semibold">Connected</Text>
|
|
||||||
<Text className="text-neutral-500 text-sm font-mono">
|
|
||||||
{savedKey.slice(0, 16)}…
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setSavedKey(null);
|
|
||||||
setSavedOrg(null);
|
|
||||||
setPendingKey("");
|
|
||||||
setPendingOrg("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-neutral-400 text-xs">Change</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{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>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<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 ? "#d97706" : "#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 ? "#d97706" : "#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,154 +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";
|
|
||||||
|
|
||||||
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(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: "#10a37f" }}>
|
|
||||||
<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: "#10a37f22" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="auto-awesome" size={32} color="#10a37f" />
|
|
||||||
</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: "#10a37f22" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="check-circle" size={22} color="#10a37f" />
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="text-white font-semibold">Connected</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" : "#10a37f" }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<ActivityIndicator color="#10a37f" />
|
|
||||||
) : (
|
|
||||||
<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 ? "#10a37f" : "#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,120 +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";
|
|
||||||
|
|
||||||
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: "#10a37f22" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={noneConfigured ? "info-outline" : "check-circle"}
|
|
||||||
size={40}
|
|
||||||
color={noneConfigured ? "#a3a3a3" : "#10a37f"}
|
|
||||||
/>
|
|
||||||
</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 ? "#10a37f22" : "#27272a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={codexConfigured ? "check-circle" : "radio-button-unchecked"}
|
|
||||||
size={22}
|
|
||||||
color={codexConfigured ? "#10a37f" : "#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 ? "Connected" : "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 ? "#d9770622" : "#27272a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={claudeConfigured ? "check-circle" : "radio-button-unchecked"}
|
|
||||||
size={22}
|
|
||||||
color={claudeConfigured ? "#d97706" : "#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 ? "Connected" : "Not configured"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleEnterApp}
|
|
||||||
className="w-full rounded-2xl py-4 items-center"
|
|
||||||
style={{ backgroundColor: "#10a37f" }}
|
|
||||||
>
|
|
||||||
<Text className="text-white font-bold text-base">Open App</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +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";
|
|
||||||
|
|
||||||
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: "#10a37f" }}
|
|
||||||
>
|
|
||||||
<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: "#d97706" }}
|
|
||||||
>
|
|
||||||
<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: "#10a37f22" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="auto-awesome" size={20} color="#10a37f" />
|
|
||||||
</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: "#d9770622" }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="psychology" size={20} color="#d97706" />
|
|
||||||
</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: "#10a37f" }}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Vendored
+2
@@ -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>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 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,29 +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.",
|
|
||||||
NO_ORGS_FOUND: "No Claude organizations found for this session key.",
|
|
||||||
};
|
|
||||||
|
|
||||||
function humanize(code: string): string {
|
|
||||||
if (code.startsWith("HTTP_ERROR_")) {
|
|
||||||
return `Unexpected server error (HTTP ${code.replace("HTTP_ERROR_", "")}).`;
|
|
||||||
}
|
|
||||||
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,23 +0,0 @@
|
|||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
interface ProgressBarProps {
|
|
||||||
percent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBarColor(percent: number): string {
|
|
||||||
if (percent >= 85) return "bg-red-500";
|
|
||||||
if (percent >= 60) 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,30 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Text } from "react-native";
|
|
||||||
import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils";
|
|
||||||
|
|
||||||
interface ResetCountdownProps {
|
|
||||||
resetAtSeconds?: number;
|
|
||||||
resetAtISO?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) {
|
|
||||||
const getLabel = () => {
|
|
||||||
if (resetAtSeconds !== undefined) return formatResetCountdown(resetAtSeconds);
|
|
||||||
if (resetAtISO) return formatResetCountdownISO(resetAtISO);
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const [label, setLabel] = useState(getLabel);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLabel(getLabel());
|
|
||||||
const interval = setInterval(() => setLabel(getLabel()), 60_000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [resetAtSeconds, resetAtISO]);
|
|
||||||
|
|
||||||
if (!label) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">{label}</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,22 +0,0 @@
|
|||||||
{
|
|
||||||
"cli": {
|
|
||||||
"version": ">= 16.0.0",
|
|
||||||
"appVersionSource": "remote"
|
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"preview": {
|
|
||||||
"distribution": "internal",
|
|
||||||
"android": {
|
|
||||||
"buildType": "apk"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"production": {
|
|
||||||
"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
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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 "$@"
|
||||||
Vendored
+23
@@ -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,124 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
|
||||||
import { fetchClaudeOrgs, fetchClaudeUsage } from "@/lib/api/claudeApi";
|
|
||||||
import {
|
|
||||||
loadClaudeSessionKey,
|
|
||||||
saveClaudeSessionKey,
|
|
||||||
clearClaudeSessionKey,
|
|
||||||
loadClaudeLastActiveOrg,
|
|
||||||
saveClaudeLastActiveOrg,
|
|
||||||
clearClaudeLastActiveOrg,
|
|
||||||
} from "@/lib/storage";
|
|
||||||
import type { ClaudeAuth, ClaudeUsageResponse } from "@/types/claude";
|
|
||||||
|
|
||||||
type Status = "idle" | "loading" | "success" | "error";
|
|
||||||
|
|
||||||
async function doFetch(
|
|
||||||
auth: ClaudeAuth,
|
|
||||||
set: {
|
|
||||||
auth: (v: ClaudeAuth | null) => void;
|
|
||||||
usage: (v: ClaudeUsageResponse | null) => void;
|
|
||||||
status: (v: Status) => void;
|
|
||||||
error: (v: string | null) => void;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
set.status("loading");
|
|
||||||
set.error(null);
|
|
||||||
try {
|
|
||||||
let orgUuid = auth.lastActiveOrg;
|
|
||||||
if (!orgUuid) {
|
|
||||||
const orgs = await fetchClaudeOrgs(auth);
|
|
||||||
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
|
|
||||||
orgUuid = orgs[0].uuid;
|
|
||||||
}
|
|
||||||
const data = await fetchClaudeUsage(auth, orgUuid);
|
|
||||||
set.usage(data);
|
|
||||||
set.status("success");
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
|
||||||
set.error(msg);
|
|
||||||
set.status("error");
|
|
||||||
if (msg === "TOKEN_EXPIRED") {
|
|
||||||
await clearClaudeSessionKey();
|
|
||||||
set.auth(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [status, setStatus] = useState<Status>("idle");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError };
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]).then(
|
|
||||||
([key, org]) => {
|
|
||||||
if (key) setAuth({ sessionKey: key, lastActiveOrg: org ?? undefined });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (auth) void doFetch(auth, setters);
|
|
||||||
}, [auth]);
|
|
||||||
|
|
||||||
const saveKey = useCallback(async () => {
|
|
||||||
const trimmed = pendingKey.trim();
|
|
||||||
if (!trimmed.startsWith("sk-ant-")) {
|
|
||||||
setError("Session key must start with sk-ant-");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const orgTrimmed = pendingOrg.trim() || undefined;
|
|
||||||
await saveClaudeSessionKey(trimmed);
|
|
||||||
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
|
|
||||||
else await clearClaudeLastActiveOrg();
|
|
||||||
setAuth({ sessionKey: trimmed, lastActiveOrg: orgTrimmed });
|
|
||||||
setPendingKey("");
|
|
||||||
setPendingOrg("");
|
|
||||||
setError(null);
|
|
||||||
}, [pendingKey, pendingOrg]);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (!auth) return;
|
|
||||||
await doFetch(auth, setters);
|
|
||||||
}, [auth]);
|
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
|
||||||
const [key, org] = await Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]);
|
|
||||||
if (!key) {
|
|
||||||
setAuth(null);
|
|
||||||
setUsage(null);
|
|
||||||
setStatus("idle");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Creating a new object always triggers the [auth] useEffect even if values are identical
|
|
||||||
setAuth({ sessionKey: key, lastActiveOrg: org ?? undefined });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearKey = useCallback(async () => {
|
|
||||||
await clearClaudeSessionKey();
|
|
||||||
setAuth(null);
|
|
||||||
setUsage(null);
|
|
||||||
setStatus("idle");
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
auth,
|
|
||||||
pendingKey,
|
|
||||||
setPendingKey,
|
|
||||||
pendingOrg,
|
|
||||||
setPendingOrg,
|
|
||||||
usage,
|
|
||||||
status,
|
|
||||||
error,
|
|
||||||
saveKey,
|
|
||||||
refresh,
|
|
||||||
reload,
|
|
||||||
clearKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
|
||||||
import { fetchCodexUsage } from "@/lib/api/codexApi";
|
|
||||||
import { loadCodexAuth, saveCodexAuth, clearCodexAuth } from "@/lib/storage";
|
|
||||||
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
|
||||||
import type { CodexUsageResponse, CodexAuth } from "@/types/codex";
|
|
||||||
|
|
||||||
type Status = "idle" | "loading" | "success" | "error";
|
|
||||||
|
|
||||||
async function doFetch(
|
|
||||||
auth: CodexAuth,
|
|
||||||
set: {
|
|
||||||
auth: (v: CodexAuth | null) => void;
|
|
||||||
usage: (v: CodexUsageResponse | null) => void;
|
|
||||||
status: (v: Status) => void;
|
|
||||||
error: (v: string | null) => void;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
set.status("loading");
|
|
||||||
set.error(null);
|
|
||||||
try {
|
|
||||||
const data = await fetchCodexUsage(auth);
|
|
||||||
set.usage(data);
|
|
||||||
set.status("success");
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
|
||||||
set.error(msg);
|
|
||||||
set.status("error");
|
|
||||||
if (msg === "TOKEN_EXPIRED") {
|
|
||||||
await clearCodexAuth();
|
|
||||||
set.auth(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCodexUsage() {
|
|
||||||
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
|
||||||
const [usage, setUsage] = useState<CodexUsageResponse | null>(null);
|
|
||||||
const [status, setStatus] = useState<Status>("idle");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError };
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCodexAuth().then((stored) => {
|
|
||||||
if (stored) setAuth(stored);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (auth) void doFetch(auth, setters);
|
|
||||||
}, [auth]);
|
|
||||||
|
|
||||||
const importAuthFile = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const parsed = await pickAndReadCodexAuth();
|
|
||||||
await saveCodexAuth(parsed);
|
|
||||||
setAuth(parsed);
|
|
||||||
setError(null);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
|
||||||
if (msg !== "PICKER_CANCELLED") setError(msg);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (!auth) return;
|
|
||||||
await doFetch(auth, setters);
|
|
||||||
}, [auth]);
|
|
||||||
|
|
||||||
// Re-reads auth from storage and fetches fresh data. Call from useFocusEffect
|
|
||||||
// so the dashboard stays current when credentials are saved from another tab.
|
|
||||||
const reload = useCallback(async () => {
|
|
||||||
const stored = await loadCodexAuth();
|
|
||||||
if (!stored) {
|
|
||||||
setAuth(null);
|
|
||||||
setUsage(null);
|
|
||||||
setStatus("idle");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Always pass a fresh object so the [auth] effect fires even if the content
|
|
||||||
// is identical (JSON.parse returns a new reference each time, which is fine).
|
|
||||||
setAuth(stored);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearAuth = useCallback(async () => {
|
|
||||||
await clearCodexAuth();
|
|
||||||
setAuth(null);
|
|
||||||
setUsage(null);
|
|
||||||
setStatus("idle");
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { auth, usage, status, error, importAuthFile, refresh, reload, 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,53 +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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<ClaudeOrg[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<ClaudeUsageResponse>;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
|
||||||
|
|
||||||
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
||||||
|
|
||||||
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
Authorization: `Bearer ${auth.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (auth.accountId) {
|
|
||||||
headers["ChatGPT-Account-Id"] = auth.accountId;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<CodexUsageResponse>;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import * as DocumentPicker from "expo-document-picker";
|
|
||||||
import type { CodexAuth } from "@/types/codex";
|
|
||||||
|
|
||||||
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 } = result.assets[0];
|
|
||||||
const response = await fetch(uri);
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
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"]) as string | undefined;
|
|
||||||
|
|
||||||
if (!accessToken || typeof accessToken !== "string") {
|
|
||||||
throw new Error("MISSING_ACCESS_TOKEN");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { accessToken, accountId };
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import {
|
|
||||||
loadNotifSettings,
|
|
||||||
loadNotifThresholdLastFired,
|
|
||||||
saveNotifThresholdLastFired,
|
|
||||||
} from "@/lib/storage";
|
|
||||||
import type { ClaudeUsageResponse } from "@/types/claude";
|
|
||||||
import type { CodexUsageResponse } from "@/types/codex";
|
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
|
||||||
handleNotification: async () => ({
|
|
||||||
shouldShowAlert: true,
|
|
||||||
shouldShowBanner: true,
|
|
||||||
shouldShowList: true,
|
|
||||||
shouldPlaySound: false,
|
|
||||||
shouldSetBadge: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function requestPermissions(): Promise<boolean> {
|
|
||||||
const { status } = await Notifications.requestPermissionsAsync();
|
|
||||||
return status === "granted";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPermissionStatus(): Promise<string> {
|
|
||||||
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> {
|
|
||||||
try {
|
|
||||||
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (claudeUsage) {
|
|
||||||
parts.push(`Claude 7d: ${Math.round(claudeUsage.seven_day.utilization)}% used`);
|
|
||||||
}
|
|
||||||
if (codexUsage) {
|
|
||||||
parts.push(
|
|
||||||
`Codex: ${Math.round(codexUsage.rate_limit.secondary_window.used_percent)}% used`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Notifications.scheduleNotificationAsync({
|
|
||||||
identifier: DAILY_ID,
|
|
||||||
content: {
|
|
||||||
title: "Usage Digest",
|
|
||||||
body:
|
|
||||||
parts.length > 0
|
|
||||||
? parts.join(" · ")
|
|
||||||
: "Open to check your usage limits",
|
|
||||||
},
|
|
||||||
trigger: {
|
|
||||||
type: Notifications.SchedulableTriggerInputTypes.CALENDAR,
|
|
||||||
hour,
|
|
||||||
minute,
|
|
||||||
repeats: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelDailyDigest(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThresholdAlert {
|
|
||||||
service: string;
|
|
||||||
window: string;
|
|
||||||
remaining: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildThresholdAlerts(
|
|
||||||
thresholdPct: number,
|
|
||||||
claudeUsage: ClaudeUsageResponse | null,
|
|
||||||
codexUsage: CodexUsageResponse | null
|
|
||||||
): ThresholdAlert[] {
|
|
||||||
const alerts: ThresholdAlert[] = [];
|
|
||||||
|
|
||||||
if (claudeUsage) {
|
|
||||||
const remaining = 100 - claudeUsage.seven_day.utilization;
|
|
||||||
if (remaining <= thresholdPct) {
|
|
||||||
alerts.push({ service: "Claude", window: "7-day", remaining });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codexUsage) {
|
|
||||||
const remaining = 100 - codexUsage.rate_limit.secondary_window.used_percent;
|
|
||||||
if (remaining <= thresholdPct) {
|
|
||||||
alerts.push({ service: "Codex", window: "weekly", remaining });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Call this after every successful usage fetch. Reschedules daily digest and fires threshold alerts. */
|
|
||||||
export async function onUsageDataLoaded(
|
|
||||||
claudeUsage: ClaudeUsageResponse | null,
|
|
||||||
codexUsage: CodexUsageResponse | null
|
|
||||||
): Promise<void> {
|
|
||||||
const settings = await loadNotifSettings();
|
|
||||||
|
|
||||||
if (settings.dailyEnabled) {
|
|
||||||
await scheduleDailyDigest(
|
|
||||||
settings.dailyHour,
|
|
||||||
settings.dailyMinute,
|
|
||||||
claudeUsage,
|
|
||||||
codexUsage
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.thresholdEnabled) {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const lastFired = await loadNotifThresholdLastFired();
|
|
||||||
if (lastFired !== today) {
|
|
||||||
const alerts = buildThresholdAlerts(
|
|
||||||
settings.thresholdPct,
|
|
||||||
claudeUsage,
|
|
||||||
codexUsage
|
|
||||||
);
|
|
||||||
if (alerts.length > 0) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
-102
@@ -1,102 +0,0 @@
|
|||||||
import * as SecureStore from "expo-secure-store";
|
|
||||||
|
|
||||||
const KEYS = {
|
|
||||||
CODEX_AUTH: "codexbar_codex_auth",
|
|
||||||
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
|
|
||||||
CLAUDE_LAST_ACTIVE_ORG: "codexbar_claude_last_active_org",
|
|
||||||
NOTIF_DAILY_ENABLED: "notifDailyEnabled",
|
|
||||||
NOTIF_DAILY_HOUR: "notifDailyHour",
|
|
||||||
NOTIF_DAILY_MINUTE: "notifDailyMinute",
|
|
||||||
NOTIF_THRESHOLD_ENABLED: "notifThresholdEnabled",
|
|
||||||
NOTIF_THRESHOLD_PCT: "notifThresholdPct",
|
|
||||||
NOTIF_THRESHOLD_LAST_FIRED: "notifThresholdLastFired",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
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 SecureStore.setItemAsync(KEYS.CODEX_AUTH, JSON.stringify(auth));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadCodexAuth(): Promise<{
|
|
||||||
accessToken: string;
|
|
||||||
accountId?: string;
|
|
||||||
} | null> {
|
|
||||||
const raw = await SecureStore.getItemAsync(KEYS.CODEX_AUTH);
|
|
||||||
if (!raw) return null;
|
|
||||||
return JSON.parse(raw) as { accessToken: string; accountId?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveClaudeSessionKey(key: string): Promise<void> {
|
|
||||||
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 SecureStore.deleteItemAsync(KEYS.CODEX_AUTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearClaudeSessionKey(): Promise<void> {
|
|
||||||
await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
|
||||||
await SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
|
|
||||||
}
|
|
||||||
@@ -1,35 +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 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" });
|
|
||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="nativewind/types" />
|
|
||||||
|
|
||||||
declare module "*.css" {
|
|
||||||
const content: unknown;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
Generated
-8467
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "codexbar.mobile",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "expo-router/entry",
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/vector-icons": "^15.1.1",
|
|
||||||
"babel-preset-expo": "~56.0.0",
|
|
||||||
"expo": "~56.0.12",
|
|
||||||
"expo-constants": "~56.0.18",
|
|
||||||
"expo-document-picker": "~56.0.4",
|
|
||||||
"expo-file-system": "~56.0.8",
|
|
||||||
"expo-linking": "~56.0.14",
|
|
||||||
"expo-notifications": "~56.0.18",
|
|
||||||
"expo-router": "~56.2.11",
|
|
||||||
"expo-secure-store": "~56.0.4",
|
|
||||||
"expo-status-bar": "~56.0.4",
|
|
||||||
"nativewind": "^4.2.6",
|
|
||||||
"react": "19.2.7",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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,23 +0,0 @@
|
|||||||
export interface CodexAuth {
|
|
||||||
accessToken: string;
|
|
||||||
accountId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageWindow {
|
|
||||||
used_percent: number;
|
|
||||||
reset_at: number;
|
|
||||||
limit_window_seconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexUsageResponse {
|
|
||||||
plan_type: string;
|
|
||||||
rate_limit: {
|
|
||||||
primary_window: UsageWindow;
|
|
||||||
secondary_window: UsageWindow;
|
|
||||||
};
|
|
||||||
credits: {
|
|
||||||
has_credits: boolean;
|
|
||||||
unlimited: boolean;
|
|
||||||
balance: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user