856 lines
39 KiB
Kotlin
856 lines
39 KiB
Kotlin
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)
|
|
})
|
|
}
|