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