This commit is contained in:
@@ -0,0 +1,855 @@
|
||||
package dev.reversed.codexbarmobile
|
||||
|
||||
import android.Manifest
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Alarm
|
||||
import androidx.compose.material.icons.rounded.AutoAwesome
|
||||
import androidx.compose.material.icons.rounded.BarChart
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Psychology
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.RestartAlt
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.UploadFile
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
CodexMobileTheme {
|
||||
val vm: AppViewModel = viewModel()
|
||||
CodexMobileApp(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodexMobileApp(vm: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val pickCodexAuth = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
|
||||
if (text != null) vm.importCodexAuthText(text)
|
||||
}
|
||||
}
|
||||
val requestNotifications = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
|
||||
val pickAuth = { pickCodexAuth.launch(arrayOf("application/json", "text/*", "*/*")) }
|
||||
val askNotifications = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
when (vm.screen) {
|
||||
AppScreen.Splash -> SplashScreen()
|
||||
AppScreen.Welcome -> WelcomeScreen(vm)
|
||||
AppScreen.OnboardingCodex -> OnboardingCodexScreen(vm, pickAuth)
|
||||
AppScreen.OnboardingClaude -> OnboardingClaudeScreen(vm)
|
||||
AppScreen.OnboardingDone -> OnboardingDoneScreen(vm)
|
||||
AppScreen.Main -> MainScreen(vm, pickAuth, askNotifications)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SplashScreen() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = CodexGreen)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeScreen(vm: AppViewModel) {
|
||||
OnboardingShell {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
AppMark(size = 72)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Text("CodexMobile", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black)
|
||||
Text(
|
||||
"Monitor Codex CLI and Claude.ai usage limits without a web stack.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
FeaturePill(Icons.Rounded.AutoAwesome, "Codex CLI", "Rate limits, credits, reset coupons", CodexGreen)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
FeaturePill(Icons.Rounded.Psychology, "Claude.ai", "5-hour, 7-day, and model windows", ClaudeAmber)
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton("Get started", CodexGreen) { vm.goTo(AppScreen.OnboardingCodex) }
|
||||
Text(
|
||||
"You can configure services later in Settings.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingCodexScreen(vm: AppViewModel, pickAuth: () -> Unit) {
|
||||
OnboardingShell {
|
||||
StepHeader(1, 2)
|
||||
Spacer(Modifier.height(36.dp))
|
||||
ServiceIcon(Icons.Rounded.AutoAwesome, CodexGreen)
|
||||
Text("Connect Codex CLI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Import your ~/.codex/auth.json file to track rate limits, credits, and reset coupons.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
vm.codexError?.let { ErrorCard(it) }
|
||||
if (vm.codexAuth != null) {
|
||||
ConnectedCard("Imported", vm.codexAuth?.accountId?.take(16), CodexGreen)
|
||||
TextButton(onClick = pickAuth) { Text("Re-import auth.json") }
|
||||
} else {
|
||||
PrimaryButton("Import auth.json", CodexGreen, icon = Icons.Rounded.UploadFile, onClick = pickAuth)
|
||||
Text("File picker accepts the Codex auth JSON export.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton("Continue", if (vm.codexAuth != null) CodexGreen else MutedButton) { vm.goTo(AppScreen.OnboardingClaude) }
|
||||
TextButton(onClick = { vm.goTo(AppScreen.OnboardingClaude) }) { Text("Skip for now") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingClaudeScreen(vm: AppViewModel) {
|
||||
var key by remember { mutableStateOf("") }
|
||||
var org by remember { mutableStateOf("") }
|
||||
val inlineError = if (key.isBlank()) null else validateClaudeSessionKey(key)
|
||||
OnboardingShell {
|
||||
StepHeader(2, 2)
|
||||
Spacer(Modifier.height(36.dp))
|
||||
ServiceIcon(Icons.Rounded.Psychology, ClaudeAmber)
|
||||
Text("Connect Claude.ai", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Paste your sessionKey cookie and optionally lastActiveOrg to monitor Claude usage windows.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
if (vm.claudeAuth != null) {
|
||||
ConnectedCard("Connected", vm.claudeAuth?.sessionKey?.take(16), ClaudeAmber)
|
||||
TextButton(onClick = { vm.clearClaude() }) { Text("Change session key") }
|
||||
} else {
|
||||
(vm.claudeError ?: inlineError)?.let { ErrorCard(it) }
|
||||
OutlinedTextField(
|
||||
value = key,
|
||||
onValueChange = { key = it },
|
||||
label = { Text("sessionKey") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = org,
|
||||
onValueChange = { org = it },
|
||||
label = { Text("lastActiveOrg (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PrimaryButton("Save session key", if (isClaudeSessionKeyValid(key)) ClaudeAmber else MutedButton) {
|
||||
vm.saveClaude(key, org)
|
||||
}
|
||||
HelpCard("Find it in Chrome DevTools > Application > Cookies > claude.ai > sessionKey.")
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton("Continue", if (vm.claudeAuth != null) ClaudeAmber else MutedButton) { vm.goTo(AppScreen.OnboardingDone) }
|
||||
TextButton(onClick = { vm.goTo(AppScreen.OnboardingDone) }) { Text("Skip for now") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingDoneScreen(vm: AppViewModel) {
|
||||
OnboardingShell(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
ServiceIcon(if (vm.codexAuth != null || vm.claudeAuth != null) Icons.Rounded.CheckCircle else Icons.Rounded.Info, CodexGreen, 84)
|
||||
Text(
|
||||
if (vm.codexAuth == null && vm.claudeAuth == null) "Almost there" else "You're all set",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
Text(
|
||||
if (vm.codexAuth == null && vm.claudeAuth == null) "No services are configured yet. Settings can finish setup anytime." else "Your connected services are ready to monitor.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
SummaryRow("Codex CLI", if (vm.codexAuth != null) "Auth imported" else "Not configured", vm.codexAuth != null, CodexGreen)
|
||||
SummaryRow("Claude.ai", if (vm.claudeAuth != null) "Session saved" else "Not configured", vm.claudeAuth != null, ClaudeAmber)
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton("Open app", CodexGreen) { vm.openMain() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(vm: AppViewModel, pickAuth: () -> Unit, askNotifications: () -> Unit) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
MainTab.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = vm.tab == item,
|
||||
onClick = { vm.selectTab(item) },
|
||||
icon = { Icon(item.icon, null) },
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.padding(padding)) {
|
||||
when (vm.tab) {
|
||||
MainTab.Dashboard -> DashboardScreen(vm)
|
||||
MainTab.Codex -> CodexScreen(vm, pickAuth)
|
||||
MainTab.Claude -> ClaudeScreen(vm)
|
||||
MainTab.Settings -> SettingsScreen(vm, pickAuth, askNotifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardScreen(vm: AppViewModel) {
|
||||
ScreenColumn {
|
||||
Header("CodexMobile", connectedSummary(vm), Icons.Rounded.BarChart, CodexGreen) { vm.refreshAll() }
|
||||
ServiceCard("Codex CLI", Icons.Rounded.AutoAwesome, CodexGreen, vm.codexStatus, vm.codexError, vm.codexAuth != null) {
|
||||
vm.codexUsage?.let { CodexUsageContent(it, vm.codexLastFetchedAt, compact = true) }
|
||||
?: EmptyState("Import auth.json in Settings to see Codex rate limits.") { vm.selectTab(MainTab.Settings) }
|
||||
}
|
||||
ServiceCard("Claude.ai", Icons.Rounded.Psychology, ClaudeAmber, vm.claudeStatus, vm.claudeError, vm.claudeAuth != null) {
|
||||
vm.claudeUsage?.let { ClaudeUsageContent(it, vm.claudeLastFetchedAt) }
|
||||
?: EmptyState("Add a session key in Settings to see Claude usage.") { vm.selectTab(MainTab.Settings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodexScreen(vm: AppViewModel, pickAuth: () -> Unit) {
|
||||
ScreenColumn {
|
||||
Header("Codex CLI", "Rate limits and reset credits", Icons.Rounded.AutoAwesome, CodexGreen) { vm.refreshCodex() }
|
||||
ConfigCard(
|
||||
connected = vm.codexAuth != null,
|
||||
label = vm.codexAuth?.accountId?.take(16),
|
||||
color = CodexGreen,
|
||||
connectLabel = "Import auth.json",
|
||||
onConnect = pickAuth,
|
||||
onClear = vm::clearCodex,
|
||||
)
|
||||
vm.codexError?.let { ErrorCard(it) }
|
||||
UsageSurface(vm.codexStatus) {
|
||||
vm.codexUsage?.let { CodexUsageContent(it, vm.codexLastFetchedAt, compact = false) }
|
||||
?: EmptyState("Import Codex auth.json to load usage windows.") { pickAuth() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClaudeScreen(vm: AppViewModel) {
|
||||
var key by remember { mutableStateOf("") }
|
||||
var org by remember { mutableStateOf("") }
|
||||
ScreenColumn {
|
||||
Header("Claude.ai", "Usage windows", Icons.Rounded.Psychology, ClaudeAmber) { vm.refreshClaude() }
|
||||
if (vm.claudeAuth != null) {
|
||||
ConfigCard(
|
||||
connected = true,
|
||||
label = vm.claudeAuth?.sessionKey?.take(16),
|
||||
color = ClaudeAmber,
|
||||
connectLabel = "Save session key",
|
||||
onConnect = {},
|
||||
onClear = vm::clearClaude,
|
||||
)
|
||||
} else {
|
||||
ClaudeCredentialCard(key, org, vm.claudeError, onKey = { key = it }, onOrg = { org = it }) { vm.saveClaude(key, org) }
|
||||
}
|
||||
UsageSurface(vm.claudeStatus) {
|
||||
vm.claudeUsage?.let { ClaudeUsageContent(it, vm.claudeLastFetchedAt) }
|
||||
?: EmptyState("Enter a Claude session key to load usage windows.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(vm: AppViewModel, pickAuth: () -> Unit, askNotifications: () -> Unit) {
|
||||
var key by remember { mutableStateOf("") }
|
||||
var org by remember { mutableStateOf("") }
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
ScreenColumn {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Black)
|
||||
SectionTitle("Codex CLI")
|
||||
ConfigCard(vm.codexAuth != null, vm.codexAuth?.accountId?.take(16), CodexGreen, "Import auth.json", pickAuth, vm::clearCodex)
|
||||
SectionTitle("Claude.ai")
|
||||
if (vm.claudeAuth != null) {
|
||||
ConfigCard(true, vm.claudeAuth?.sessionKey?.take(16), ClaudeAmber, "Save session key", {}, vm::clearClaude)
|
||||
} else {
|
||||
ClaudeCredentialCard(key, org, vm.claudeError, onKey = { key = it }, onOrg = { org = it }) { vm.saveClaude(key, org) }
|
||||
}
|
||||
SectionTitle("Notifications")
|
||||
SettingsCard {
|
||||
val hasPermission = NotificationScheduler.hasNotificationPermission(context)
|
||||
if (!hasPermission) {
|
||||
RowItem(Icons.Rounded.Notifications, "Enable notifications", "Required for digests and quota alerts") {
|
||||
OutlinedButton(onClick = askNotifications) { Text("Allow") }
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
SwitchRow("Daily digest", "Last-known usage at a chosen time", vm.notificationSettings.dailyEnabled) {
|
||||
vm.updateNotificationSettings(vm.notificationSettings.copy(dailyEnabled = it))
|
||||
}
|
||||
if (vm.notificationSettings.dailyEnabled) {
|
||||
TimeSetting(vm.notificationSettings) { hour, minute ->
|
||||
vm.updateNotificationSettings(vm.notificationSettings.copy(dailyHour = hour, dailyMinute = minute))
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
SwitchRow("Low quota alert", "Warn when weekly quota runs low", vm.notificationSettings.thresholdEnabled) {
|
||||
vm.updateNotificationSettings(vm.notificationSettings.copy(thresholdEnabled = it))
|
||||
}
|
||||
if (vm.notificationSettings.thresholdEnabled) {
|
||||
ThresholdSetting(vm.notificationSettings) {
|
||||
vm.updateNotificationSettings(vm.notificationSettings.copy(thresholdPct = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTitle("App")
|
||||
SettingsCard {
|
||||
RowItem(Icons.Rounded.Info, "Version", "1.0.0") {}
|
||||
HorizontalDivider()
|
||||
RowItem(Icons.Rounded.RestartAlt, "Re-run setup wizard", "Clears credentials and onboarding", danger = true) {
|
||||
TextButton(onClick = { showResetDialog = true }) { Text("Reset") }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = { Text("Reset setup?") },
|
||||
text = { Text("This clears stored credentials and restarts onboarding.") },
|
||||
confirmButton = { TextButton(onClick = { showResetDialog = false; vm.resetSetup() }) { Text("Reset") } },
|
||||
dismissButton = { TextButton(onClick = { showResetDialog = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodexUsageContent(usage: CodexUsage, lastFetchedAt: Long?, compact: Boolean) {
|
||||
usage.planType?.let { AssistChip(onClick = {}, label = { Text(it.replaceFirstChar(Char::uppercase)) }) }
|
||||
usage.primary?.let { UsageRow("${windowLabel(it.windowSeconds)} window", it, CodexGreen) }
|
||||
usage.secondary?.let { UsageRow("${windowLabel(it.windowSeconds)} window", it, CodexGreen) }
|
||||
CreditCard(usage)
|
||||
if (!compact) ResetCreditsCard(usage.resetCoupons)
|
||||
LastUpdated(lastFetchedAt)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClaudeUsageContent(usage: ClaudeUsage, lastFetchedAt: Long?) {
|
||||
UsageRow("5-hour window", UsageWindow(usage.fiveHour.utilization, resetsAtIso = usage.fiveHour.resetsAt), ClaudeAmber)
|
||||
UsageRow("7-day window", UsageWindow(usage.sevenDay.utilization, resetsAtIso = usage.sevenDay.resetsAt), ClaudeAmber)
|
||||
if (usage.sevenDaySonnet != null || usage.sevenDayOpus != null) {
|
||||
CardBlock {
|
||||
Text("By model (7-day)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
usage.sevenDaySonnet?.let { UsageRow("Sonnet", UsageWindow(it.utilization), ClaudeAmber) }
|
||||
usage.sevenDayOpus?.let { UsageRow("Opus", UsageWindow(it.utilization), ClaudeAmber) }
|
||||
}
|
||||
}
|
||||
LastUpdated(lastFetchedAt)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageRow(label: String, window: UsageWindow, accent: Color) {
|
||||
val percent = window.usedPercent.roundToInt().coerceIn(0, 100)
|
||||
Column(Modifier.padding(vertical = 10.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("$percent% used", style = MaterialTheme.typography.titleSmall, color = usageColor(percent, accent), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { percent / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp),
|
||||
color = usageColor(percent, accent),
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
resetText(window)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreditCard(usage: CodexUsage) {
|
||||
CardBlock {
|
||||
Text("Credits", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val text = when {
|
||||
usage.credits.unlimited -> "Unlimited credits"
|
||||
usage.credits.hasCredits -> "\$${"%.2f".format(usage.credits.balance)} remaining"
|
||||
else -> "No credits"
|
||||
}
|
||||
Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
usage.resetCoupons?.let {
|
||||
Text("${it.availableCount ?: 0} reset credits available", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
it.nextExpiringCredit?.timeUntilExpiry?.let { expiry -> Text("Next expires in $expiry", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetCreditsCard(resetCoupons: ResetCoupons?) {
|
||||
if (resetCoupons == null) return
|
||||
CardBlock {
|
||||
Text("Reset credits", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${resetCoupons.availableCount ?: 0} available", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
resetCoupons.totalEarnedCount?.let { Text("$it earned total", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
resetCoupons.credits.filter { it.status == "available" }.forEach { credit ->
|
||||
Text("Credit ${credit.index}: expires ${credit.timeUntilExpiry ?: credit.expiresAt ?: "unknown"}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (resetCoupons.source != "live_api") Text("Source: ${resetCoupons.sourceDescription}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceCard(title: String, icon: ImageVector, color: Color, status: LoadStatus, error: String?, connected: Boolean, content: @Composable () -> Unit) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(28.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ServiceIcon(icon, color, 40)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
StatusDot(status, connected)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (status == LoadStatus.Loading && !connected) CircularProgressIndicator(color = color)
|
||||
if (status == LoadStatus.Error && error != null) ErrorCard(error)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageSurface(status: LoadStatus, content: @Composable () -> Unit) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(28.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp)) {
|
||||
if (status == LoadStatus.Loading) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Refreshing usage…", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfigCard(connected: Boolean, label: String?, color: Color, connectLabel: String, onConnect: () -> Unit, onClear: () -> Unit) {
|
||||
SettingsCard {
|
||||
if (connected) {
|
||||
RowItem(Icons.Rounded.CheckCircle, "Connected", label ?: "Credentials saved") {
|
||||
TextButton(onClick = onClear) { Text("Clear", color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
} else {
|
||||
RowItem(Icons.Rounded.UploadFile, connectLabel, "Credentials stay encrypted on this device") {
|
||||
Button(onClick = onConnect, colors = ButtonDefaults.buttonColors(containerColor = color)) { Text("Connect") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClaudeCredentialCard(key: String, org: String, error: String?, onKey: (String) -> Unit, onOrg: (String) -> Unit, onSave: () -> Unit) {
|
||||
SettingsCard {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
(error ?: if (key.isBlank()) null else validateClaudeSessionKey(key))?.let { ErrorCard(it) }
|
||||
OutlinedTextField(key, onKey, Modifier.fillMaxWidth(), label = { Text("sessionKey") }, singleLine = true, visualTransformation = PasswordVisualTransformation())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(org, onOrg, Modifier.fillMaxWidth(), label = { Text("lastActiveOrg (optional)") }, singleLine = true)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PrimaryButton("Save session key", if (isClaudeSessionKeyValid(key)) ClaudeAmber else MutedButton, onClick = onSave)
|
||||
HelpCard("Chrome DevTools > Application > Cookies > claude.ai > sessionKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenColumn(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingShell(horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp),
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(title: String, subtitle: String, icon: ImageVector, color: Color, onRefresh: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ServiceIcon(icon, color, 48)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onRefresh) { Icon(Icons.Rounded.Refresh, "Refresh") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryButton(text: String, color: Color, icon: ImageVector? = null, onClick: () -> Unit) {
|
||||
Button(onClick = onClick, colors = ButtonDefaults.buttonColors(containerColor = color), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth().height(54.dp)) {
|
||||
if (icon != null) { Icon(icon, null); Spacer(Modifier.width(8.dp)) }
|
||||
Text(text, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceIcon(icon: ImageVector, color: Color, size: Int = 56) {
|
||||
Surface(shape = RoundedCornerShape((size / 3).dp), color = color.copy(alpha = 0.16f), modifier = Modifier.size(size.dp)) {
|
||||
Box(contentAlignment = Alignment.Center) { Icon(icon, null, tint = color, modifier = Modifier.size((size / 2).dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppMark(size: Int) {
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
ServiceIcon(Icons.Rounded.AutoAwesome, CodexGreen, size)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
ServiceIcon(Icons.Rounded.Psychology, ClaudeAmber, size)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepHeader(current: Int, total: Int) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
(1..total).forEach { step ->
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (step <= current) CodexGreen else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(28.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
step.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (step <= current) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (step < total) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
HorizontalDivider(Modifier.width(42.dp), color = if (step < current) CodexGreen else MaterialTheme.colorScheme.surfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeaturePill(icon: ImageVector, title: String, subtitle: String, color: Color) {
|
||||
SettingsCard {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
ServiceIcon(icon, color, 42)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(title, fontWeight = FontWeight.Bold)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectedCard(title: String, label: String?, color: Color) {
|
||||
SettingsCard {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.CheckCircle, null, tint = color)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(title, fontWeight = FontWeight.Bold)
|
||||
label?.let { Text("$it…", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(title: String, subtitle: String, ok: Boolean, color: Color) {
|
||||
SettingsCard {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(shape = CircleShape, color = if (ok) color else MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(10.dp)) {}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column { Text(title, fontWeight = FontWeight.Bold); Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(text: String) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpCard(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardBlock(content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)), shape = RoundedCornerShape(20.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
Column(Modifier.padding(14.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(text: String, onAction: (() -> Unit)? = null) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (onAction != null) TextButton(onClick = onAction) { Text("Open Settings") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LastUpdated(timestamp: Long?) {
|
||||
Text("Updated ${timestamp?.let(::formatTimestamp) ?: "never"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(title.uppercase(), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowItem(icon: ImageVector, title: String, subtitle: String, danger: Boolean = false, action: @Composable () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontWeight = FontWeight.SemiBold, color = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(title: String, subtitle: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||
RowItem(Icons.Rounded.Alarm, title, subtitle) { Switch(checked = checked, onCheckedChange = onChecked) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeSetting(settings: NotificationSettings, onChange: (Int, Int) -> Unit) {
|
||||
var value by remember(settings.dailyHour, settings.dailyMinute) { mutableStateOf("%02d:%02d".format(settings.dailyHour, settings.dailyMinute)) }
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it.take(5)
|
||||
parseTime(value)?.let { parsed -> onChange(parsed.first, parsed.second) }
|
||||
},
|
||||
label = { Text("Digest time") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
supportingText = { Text("Use 24-hour HH:MM format.") },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThresholdSetting(settings: NotificationSettings, onChange: (Int) -> Unit) {
|
||||
var value by remember(settings.thresholdPct) { mutableStateOf(settings.thresholdPct.toString()) }
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { next -> value = next.filter(Char::isDigit).take(2); value.toIntOrNull()?.takeIf { it in 1..99 }?.let(onChange) },
|
||||
label = { Text("Alert below % remaining") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusDot(status: LoadStatus, connected: Boolean) {
|
||||
val color = when {
|
||||
status == LoadStatus.Error -> MaterialTheme.colorScheme.error
|
||||
connected -> CodexGreen
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
}
|
||||
Surface(shape = CircleShape, color = color, modifier = Modifier.size(10.dp)) {}
|
||||
}
|
||||
|
||||
private fun connectedSummary(vm: AppViewModel): String = when (listOf(vm.codexAuth, vm.claudeAuth).count { it != null }) {
|
||||
0 -> "No services connected"
|
||||
1 -> "1 of 2 services connected"
|
||||
else -> "2 services connected"
|
||||
}
|
||||
|
||||
private val MainTab.icon: ImageVector get() = when (this) {
|
||||
MainTab.Dashboard -> Icons.Rounded.Home
|
||||
MainTab.Codex -> Icons.Rounded.AutoAwesome
|
||||
MainTab.Claude -> Icons.Rounded.Psychology
|
||||
MainTab.Settings -> Icons.Rounded.Settings
|
||||
}
|
||||
|
||||
private fun usageColor(percent: Int, fallback: Color): Color = when {
|
||||
percent >= 85 -> DangerRed
|
||||
percent >= 60 -> WarningAmber
|
||||
else -> fallback
|
||||
}
|
||||
|
||||
private fun resetText(window: UsageWindow): String? = when {
|
||||
window.resetsAt != null -> "Resets ${formatTimestamp(window.resetsAt * 1000L)}"
|
||||
window.resetsAtIso != null -> runCatching { "Resets ${formatTimestamp(Instant.parse(window.resetsAtIso).toEpochMilli())}" }.getOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun windowLabel(seconds: Long?): String = when {
|
||||
seconds == null || seconds <= 0L -> "Usage"
|
||||
seconds < 3_600L -> "${seconds / 60L}m"
|
||||
seconds < 86_400L -> "${seconds / 3_600L}h"
|
||||
else -> "${seconds / 86_400L}d"
|
||||
}
|
||||
|
||||
private fun formatTimestamp(value: Long): String = DateTimeFormatter.ofPattern("MMM d, HH:mm")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochMilli(value))
|
||||
|
||||
private fun parseTime(value: String): Pair<Int, Int>? {
|
||||
val parts = value.split(":")
|
||||
if (parts.size != 2) return null
|
||||
val hour = parts[0].toIntOrNull() ?: return null
|
||||
val minute = parts[1].toIntOrNull() ?: return null
|
||||
return if (hour in 0..23 && minute in 0..59) hour to minute else null
|
||||
}
|
||||
|
||||
private val CodexGreen = Color(0xFF10A37F)
|
||||
private val ClaudeAmber = Color(0xFFD97706)
|
||||
private val WarningAmber = Color(0xFFF59E0B)
|
||||
private val DangerRed = Color(0xFFEF4444)
|
||||
private val MutedButton = Color(0xFF52525B)
|
||||
|
||||
@Composable
|
||||
private fun CodexMobileTheme(content: @Composable () -> Unit) {
|
||||
val dark = darkColorScheme(
|
||||
primary = CodexGreen,
|
||||
secondary = ClaudeAmber,
|
||||
background = Color(0xFF090D11),
|
||||
surface = Color(0xFF10161B),
|
||||
surfaceVariant = Color(0xFF1A2229),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color(0xFFE7ECEF),
|
||||
onSurface = Color(0xFFE7ECEF),
|
||||
onSurfaceVariant = Color(0xFF9CA3AF),
|
||||
)
|
||||
val light = lightColorScheme(
|
||||
primary = CodexGreen,
|
||||
secondary = ClaudeAmber,
|
||||
background = Color(0xFFF6F8F7),
|
||||
surface = Color.White,
|
||||
surfaceVariant = Color(0xFFECEFED),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
)
|
||||
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) dark else light, content = {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, content = content)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user