Files
codexbar-mobile/app/src/main/java/dev/reversed/codexbarmobile/MainActivity.kt
T
Space-Banane 3d6548d77d
android-release / build (push) Successful in 3m5s
Migrate app to native Kotlin
2026-07-06 13:49:22 +02:00

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)
})
}