diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 02834e8..af4fda0 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -610,491 +610,6 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag } } -@Composable -private fun BalanceMainArea(card: DashboardCardUi) { - val balance = card.displayBalance - when { - card.credentialState == CredentialStatus.State.NOT_CONFIGURED -> { - Text( - text = "—", - style = MaterialTheme.typography.displayMedium, - color = inkMuted() - ) - Text( - text = "点击配置凭据", - style = MaterialTheme.typography.bodySmall, - color = inkMuted() - ) - } - balance == null -> { - Text( - text = "—", - style = MaterialTheme.typography.displayMedium, - color = inkMuted() - ) - Text( - text = "下拉刷新", - style = MaterialTheme.typography.bodySmall, - color = inkMuted() - ) - } - card.service == ServiceType.OPENCODE_GO -> { - // OpenCode Go 卡片:3 个用量窗口平铺 - // 主大数字 = 5h 滚动用量 - OpenCodeGoMainBalance(balance) - Spacer(modifier = Modifier.height(12.dp)) - // 3 个窗口进度条 - OpenCodeGoUsageWindows(balance) - } - card.service == ServiceType.COMMANDCODE_GO -> { - // CommandCode Go 卡片:跟 OCGO 一样平铺三档窗口 - CommandCodeGoMainBalance(balance) - Spacer(modifier = Modifier.height(12.dp)) - CommandCodeGoUsageWindows(balance) - } - card.service == ServiceType.CODEX -> { - // Codex / ChatGPT Plus 卡片:显示用量窗口(5h / Weekly) - CodexMainBalance(balance) - Spacer(modifier = Modifier.height(12.dp)) - CodexUsageWindows(balance) - } - card.service == ServiceType.OLLAMA -> { - // Ollama Pro 卡片:Session + Weekly 用量百分比 - OllamaMainBalance(balance) - Spacer(modifier = Modifier.height(12.dp)) - OllamaUsageWindows(balance) - } - else -> { - // DeepSeek 等:主数字 + 单位 - Row(verticalAlignment = Alignment.Bottom) { - Text( - text = formatAmount(balance.amount), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = balance.unit, - style = MaterialTheme.typography.titleMedium, - color = inkMuted(), - modifier = Modifier.padding(bottom = 6.dp) - ) - } - if (!balance.isAvailable) { - Text( - text = "服务当前不可用", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - balance.monthlySpent?.let { spent -> - Text( - text = "本月已用 ${formatAmount(spent)}${balance.unit}", - style = MaterialTheme.typography.bodySmall, - color = inkMuted() - ) - } - } - } -} - -@Composable -private fun OpenCodeGoMainBalance(balance: com.rainy.token.domain.model.ServiceBalance) { - Row(verticalAlignment = Alignment.Bottom) { - Text( - text = formatAmount(balance.amount), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - // % 符号跟主数字走,字号略小 - Text( - text = "%", - style = MaterialTheme.typography.titleLarge, - color = inkMuted(), - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "5h 用量", - style = MaterialTheme.typography.titleMedium, - color = inkMuted(), - modifier = Modifier.padding(bottom = 6.dp) - ) - } -} -@Composable -private fun OpenCodeGoUsageWindows(balance: com.rainy.token.domain.model.ServiceBalance) { - val windows = listOf( - Triple("5 小时", balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), - Triple("本周", balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), - Triple("本月", balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) - ) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - windows.forEach { (label, pct, resetSec) -> - if (pct != null) { - CompactUsageRow(label = label, pct = pct, resetInSec = resetSec) - } else { - CompactUsageRowEmpty(label = label, resetInSec = resetSec) - } - } - } -} - -@Composable -private fun CommandCodeGoMainBalance(balance: com.rainy.token.domain.model.ServiceBalance) { - val total = balance.totalQuota - Row(verticalAlignment = Alignment.Bottom) { - Text( - text = formatAmount(balance.amount), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "$", - style = MaterialTheme.typography.titleLarge, - color = inkMuted(), - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "剩余", - style = MaterialTheme.typography.titleMedium, - color = inkMuted(), - modifier = Modifier.padding(bottom = 6.dp) - ) - if (total != null && total > 0) { - val used = total - balance.amount - Text( - text = " · 已用 ${formatAmount(used)} / 共 ${formatAmount(total)}", - style = MaterialTheme.typography.bodySmall, - color = inkMuted(), - modifier = Modifier.padding(bottom = 6.dp) - ) - } - } -} - -@Composable -private fun CommandCodeGoUsageWindows(balance: com.rainy.token.domain.model.ServiceBalance) { - val extras = balance.extras - fun calcPct(used: Double?, cap: Double?): Int? { - if (used == null || cap == null || cap <= 0) return null - return ((used / cap) * 100).toInt().coerceIn(0, 100) - } - val windows = listOf( - Triple("5 小时", calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()), - Triple("本周", calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()), - Triple("本月", calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull()) - ) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - windows.forEach { (label, pct, resetSec) -> - if (pct != null) { - CompactUsageRow(label = label, pct = pct, resetInSec = resetSec) - } else { - CompactUsageRowEmpty(label = label, resetInSec = resetSec) - } - } - } -} - -@Composable -private fun CodexMainBalance(balance: com.rainy.token.domain.model.ServiceBalance) { - val plan = balance.extras["plan"]?.let { - when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } } - } ?: "—" - Row(verticalAlignment = Alignment.Bottom) { - Text( - text = formatAmount(balance.amount), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "%", - style = MaterialTheme.typography.titleLarge, - color = inkMuted(), - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "5h 剩余", - style = MaterialTheme.typography.titleMedium, - color = inkMuted(), - modifier = Modifier.padding(bottom = 6.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "· $plan", - style = MaterialTheme.typography.bodySmall, - color = inkMuted(), - modifier = Modifier.padding(bottom = 6.dp) - ) - } -} - -@Composable -private fun CodexUsageWindows(balance: com.rainy.token.domain.model.ServiceBalance) { - val extras = balance.extras - // 从 extras 里动态提取窗口数据,数量不固定(通常 2 个:5h + Weekly) - val windowCount = extras.keys - .mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() } - .distinct() - .maxOrNull()?.plus(1) ?: 0 - - val windows = (0 until windowCount).map { i -> - val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage") - val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() - val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } - Triple(label, remainingPct, resetAt) - } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (windows.isEmpty()) { - CompactUsageRowEmpty(label = "Usage", resetInSec = null) - } else { - windows.forEach { (label, remainingPct, resetAt) -> - if (remainingPct != null) { - // remainingPct 是剩余百分比,CompactUsageRow 需要的是已用百分比 - val usedPct = (100 - remainingPct).coerceIn(0, 100) - CompactUsageRow(label = label, pct = usedPct, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) - } else { - CompactUsageRowEmpty(label = label, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) - } - } - } - } -} - -@Composable -private fun OllamaMainBalance(balance: com.rainy.token.domain.model.ServiceBalance) { - val plan = balance.extras["plan"]?.let { - when (it.lowercase()) { "pro" -> "Pro"; "max" -> "Max"; "free" -> "Free"; else -> it } - } ?: "—" - Row(verticalAlignment = Alignment.Bottom) { - Text( - text = formatAmount(balance.amount), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "%", - style = MaterialTheme.typography.titleLarge, - color = inkMuted(), - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "5h 已用", - style = MaterialTheme.typography.titleMedium, - color = inkMuted(), - modifier = Modifier.padding(bottom = 6.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "· $plan", - style = MaterialTheme.typography.bodySmall, - color = inkMuted(), - modifier = Modifier.padding(bottom = 6.dp) - ) - } -} - -@Composable -private fun OllamaUsageWindows(balance: com.rainy.token.domain.model.ServiceBalance) { - val extras = balance.extras - val sessionPct = extras["session.pct"]?.toFloatOrNull() - val weeklyPct = extras["weekly.pct"]?.toFloatOrNull() - val sessionResetAt = extras["session.resetAt"]?.toLongOrNull() - val weeklyResetAt = extras["weekly.resetAt"]?.toLongOrNull() - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (sessionPct != null) { - CompactUsageRow( - label = "5h", - pct = sessionPct.toInt().coerceIn(0, 100), - resetInSec = sessionResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } - ) - } else { - CompactUsageRowEmpty(label = "5h", resetInSec = null) - } - if (weeklyPct != null) { - CompactUsageRow( - label = "每周", - pct = weeklyPct.toInt().coerceIn(0, 100), - resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } - ) - } else { - CompactUsageRowEmpty(label = "每周", resetInSec = null) - } - } -} - -private fun normalizeWindowLabel(label: String): String = when (label.lowercase()) { - "weekly" -> "每周" - else -> label -} - -@Composable -private fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.bodySmall, - color = inkMuted(), - modifier = Modifier.weight(1f) - ) - Text( - text = "—", - style = MaterialTheme.typography.bodyMedium, - color = inkMuted() - ) - } - Spacer(modifier = Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { 0f }, - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .clip(RoundedCornerShape(2.dp)), - color = inkMuted().copy(alpha = 0.3f), - trackColor = MaterialTheme.colorScheme.surfaceVariant, - strokeCap = androidx.compose.ui.graphics.StrokeCap.Butt - ) - if (resetInSec != null && resetInSec > 0) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "${formatResetInSec(resetInSec)}后重置", - style = MaterialTheme.typography.bodySmall, - color = inkMuted() - ) - } - } -} - -@Composable -private fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { - val pctValue = pct.coerceIn(0, 100).toFloat() - Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.bodySmall, - color = inkMuted(), - modifier = Modifier.weight(1f) - ) - Text( - text = "$pct%", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = when { - pct >= 80 -> MaterialTheme.colorScheme.error - pct >= 50 -> com.rainy.token.ui.theme.StatusOrange - else -> MaterialTheme.colorScheme.onSurface - } - ) - } - Spacer(modifier = Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { pctValue / 100f }, - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .clip(RoundedCornerShape(2.dp)), - color = when { - pctValue >= 80f -> MaterialTheme.colorScheme.error - pctValue >= 50f -> com.rainy.token.ui.theme.StatusOrange - else -> StrawberryPink - }, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - // strokeCap = StrokeCap.Butt 让进度条右端不出现 Material 默认的小圆点 - strokeCap = androidx.compose.ui.graphics.StrokeCap.Butt - ) - if (resetInSec != null && resetInSec > 0) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "${formatResetInSec(resetInSec)}后重置", - style = MaterialTheme.typography.bodySmall, - color = inkMuted() - ) - } - } -} - -private fun formatResetInSec(sec: Long): String { - if (sec <= 0) return "—" - val days = sec / 86400 - val hours = (sec % 86400) / 3600 - val minutes = (sec % 3600) / 60 - return when { - days > 0 -> "$days 天 $hours 小时" - hours > 0 -> "$hours 小时 $minutes 分" - else -> "$minutes 分" - } -} - -private fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when { - credentialState == CredentialStatus.State.NOT_CONFIGURED -> - StatusStyle("未配置", StatusLevel.WARNING) - lastFetchError != null -> - StatusStyle("刷新失败", StatusLevel.ERROR) - credentialState == CredentialStatus.State.EXPIRED -> - StatusStyle("已过期", StatusLevel.ERROR) - credentialState == CredentialStatus.State.WARNING -> - StatusStyle("需重登", StatusLevel.WARNING) - cachedBalance == null -> - StatusStyle("待获取", StatusLevel.INFO) - else -> - StatusStyle("正常", StatusLevel.OK) -} - -private fun secondaryLine(card: DashboardCardUi): String = when (card.service) { - ServiceType.DEEPSEEK -> "REST API · ¥" - ServiceType.OPENCODE_GO -> "WebView 抓取 · 5h 配额" - ServiceType.COMMANDCODE_GO -> "JSON API · \$" - ServiceType.CODEX -> "ChatGPT Plus · Codex 额度" - ServiceType.OLLAMA -> "Cookie 抓取 · Cloud 配额" -} - -private fun footerText(card: DashboardCardUi): String { - if (card.lastFetchError != null) { - // 截短错误信息避免换行爆炸 - val msg = card.lastFetchError.take(60) - return "⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}" - } - val fetchedAt = card.cachedBalance?.fetchedAt ?: return "从未获取" - val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) - val now = System.currentTimeMillis() - val diffMin = (now - fetchedAt) / 60_000 - val timeStr = sdf.format(Date(fetchedAt)) - return when { - diffMin < 1 -> "刚刚更新" - diffMin < 60 -> "$diffMin 分钟前更新" - diffMin < 1440 -> "${diffMin / 60} 小时前更新" - else -> "$timeStr 更新" - } -} - -private fun formatAmount(value: Double): String { - // 整数显示无小数点;带小数的保留 2 位 - return if (value % 1.0 == 0.0) value.toInt().toString() - else String.format(Locale.US, "%.2f", value) -} - /** * 仪表盘底部 footer:填空白 + 显示版本号。 * 容器透明无边框,让卡片列表与背景融合自然。 diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt new file mode 100644 index 0000000..ae1d0e5 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -0,0 +1,511 @@ +package com.rainy.token.ui.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.service.ServiceType +import com.rainy.token.ui.components.StatusStyle +import com.rainy.token.ui.components.StatusLevel +import com.rainy.token.ui.theme.StrawberryPink +import com.rainy.token.ui.theme.StatusOrange +import com.rainy.token.ui.theme.inkMuted +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// ── Service-specific balance Composables ── + +@Composable +internal fun BalanceMainArea(card: DashboardCardUi) { + val balance = card.displayBalance + when { + card.credentialState == CredentialStatus.State.NOT_CONFIGURED -> { + Text( + text = "—", + style = MaterialTheme.typography.displayMedium, + color = inkMuted() + ) + Text( + text = "点击配置凭据", + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + balance == null -> { + Text( + text = "—", + style = MaterialTheme.typography.displayMedium, + color = inkMuted() + ) + Text( + text = "下拉刷新", + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + card.service == ServiceType.OPENCODE_GO -> { + OpenCodeGoMainBalance(balance) + Spacer(modifier = Modifier.height(12.dp)) + OpenCodeGoUsageWindows(balance) + } + card.service == ServiceType.COMMANDCODE_GO -> { + CommandCodeGoMainBalance(balance) + Spacer(modifier = Modifier.height(12.dp)) + CommandCodeGoUsageWindows(balance) + } + card.service == ServiceType.CODEX -> { + CodexMainBalance(balance) + Spacer(modifier = Modifier.height(12.dp)) + CodexUsageWindows(balance) + } + card.service == ServiceType.OLLAMA -> { + OllamaMainBalance(balance) + Spacer(modifier = Modifier.height(12.dp)) + OllamaUsageWindows(balance) + } + else -> { + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = formatAmount(balance.amount), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = balance.unit, + style = MaterialTheme.typography.titleMedium, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + } + if (!balance.isAvailable) { + Text( + text = "服务当前不可用", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + balance.monthlySpent?.let { spent -> + Text( + text = "本月已用 ${formatAmount(spent)}${balance.unit}", + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + } + } +} + +@Composable +internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = formatAmount(balance.amount), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "%", + style = MaterialTheme.typography.titleLarge, + color = inkMuted(), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "5h 用量", + style = MaterialTheme.typography.titleMedium, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + } +} + +@Composable +internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) { + val windows = listOf( + Triple("5 小时", balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), + Triple("本周", balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), + Triple("本月", balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + windows.forEach { (label, pct, resetSec) -> + if (pct != null) { + CompactUsageRow(label = label, pct = pct, resetInSec = resetSec) + } else { + CompactUsageRowEmpty(label = label, resetInSec = resetSec) + } + } + } +} + +@Composable +internal fun CommandCodeGoMainBalance(balance: ServiceBalance) { + val total = balance.totalQuota + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = formatAmount(balance.amount), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "$", + style = MaterialTheme.typography.titleLarge, + color = inkMuted(), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "剩余", + style = MaterialTheme.typography.titleMedium, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + if (total != null && total > 0) { + val used = total - balance.amount + Text( + text = " · 已用 ${formatAmount(used)} / 共 ${formatAmount(total)}", + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + } + } +} + +@Composable +internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) { + val extras = balance.extras + fun calcPct(used: Double?, cap: Double?): Int? { + if (used == null || cap == null || cap <= 0) return null + return ((used / cap) * 100).toInt().coerceIn(0, 100) + } + val windows = listOf( + Triple("5 小时", calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()), + Triple("本周", calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()), + Triple("本月", calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull()) + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + windows.forEach { (label, pct, resetSec) -> + if (pct != null) { + CompactUsageRow(label = label, pct = pct, resetInSec = resetSec) + } else { + CompactUsageRowEmpty(label = label, resetInSec = resetSec) + } + } + } +} + +@Composable +internal fun CodexMainBalance(balance: ServiceBalance) { + val plan = balance.extras["plan"]?.let { + when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } } + } ?: "—" + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = formatAmount(balance.amount), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "%", + style = MaterialTheme.typography.titleLarge, + color = inkMuted(), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "5h 剩余", + style = MaterialTheme.typography.titleMedium, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "· $plan", + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + } +} + +@Composable +internal fun CodexUsageWindows(balance: ServiceBalance) { + val extras = balance.extras + val windowCount = extras.keys + .mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() } + .distinct() + .maxOrNull()?.plus(1) ?: 0 + + val windows = (0 until windowCount).map { i -> + val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage") + val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() + val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } + Triple(label, remainingPct, resetAt) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (windows.isEmpty()) { + CompactUsageRowEmpty(label = "Usage", resetInSec = null) + } else { + windows.forEach { (label, remainingPct, resetAt) -> + if (remainingPct != null) { + val usedPct = (100 - remainingPct).coerceIn(0, 100) + CompactUsageRow(label = label, pct = usedPct, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) + } else { + CompactUsageRowEmpty(label = label, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) + } + } + } + } +} + +@Composable +internal fun OllamaMainBalance(balance: ServiceBalance) { + val plan = balance.extras["plan"]?.let { + when (it.lowercase()) { "pro" -> "Pro"; "max" -> "Max"; "free" -> "Free"; else -> it } + } ?: "—" + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = formatAmount(balance.amount), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "%", + style = MaterialTheme.typography.titleLarge, + color = inkMuted(), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "5h 已用", + style = MaterialTheme.typography.titleMedium, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "· $plan", + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + } +} + +@Composable +internal fun OllamaUsageWindows(balance: ServiceBalance) { + val extras = balance.extras + val sessionPct = extras["session.pct"]?.toFloatOrNull() + val weeklyPct = extras["weekly.pct"]?.toFloatOrNull() + val sessionResetAt = extras["session.resetAt"]?.toLongOrNull() + val weeklyResetAt = extras["weekly.resetAt"]?.toLongOrNull() + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (sessionPct != null) { + CompactUsageRow( + label = "5h", + pct = sessionPct.toInt().coerceIn(0, 100), + resetInSec = sessionResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } + ) + } else { + CompactUsageRowEmpty(label = "5h", resetInSec = null) + } + if (weeklyPct != null) { + CompactUsageRow( + label = "每周", + pct = weeklyPct.toInt().coerceIn(0, 100), + resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } + ) + } else { + CompactUsageRowEmpty(label = "每周", resetInSec = null) + } + } +} + +// ── Shared UI components ── + +internal fun normalizeWindowLabel(label: String): String = when (label.lowercase()) { + "weekly" -> "每周" + else -> label +} + +@Composable +internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.weight(1f) + ) + Text( + text = "—", + style = MaterialTheme.typography.bodyMedium, + color = inkMuted() + ) + } + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { 0f }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = inkMuted().copy(alpha = 0.3f), + trackColor = MaterialTheme.colorScheme.surfaceVariant, + strokeCap = StrokeCap.Butt + ) + if (resetInSec != null && resetInSec > 0) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${formatResetInSec(resetInSec)}后重置", + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + } +} + +@Composable +internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { + val pctValue = pct.coerceIn(0, 100).toFloat() + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.weight(1f) + ) + Text( + text = "$pct%", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = when { + pct >= 80 -> MaterialTheme.colorScheme.error + pct >= 50 -> StatusOrange + else -> MaterialTheme.colorScheme.onSurface + } + ) + } + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { pctValue / 100f }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = when { + pctValue >= 80f -> MaterialTheme.colorScheme.error + pctValue >= 50f -> StatusOrange + else -> StrawberryPink + }, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + strokeCap = StrokeCap.Butt + ) + if (resetInSec != null && resetInSec > 0) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${formatResetInSec(resetInSec)}后重置", + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + } +} + +// ── Dashboard utility functions ── + +internal fun formatResetInSec(sec: Long): String { + if (sec <= 0) return "—" + val days = sec / 86400 + val hours = (sec % 86400) / 3600 + val minutes = (sec % 3600) / 60 + return when { + days > 0 -> "$days 天 $hours 小时" + hours > 0 -> "$hours 小时 $minutes 分" + else -> "$minutes 分" + } +} + +internal fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when { + credentialState == CredentialStatus.State.NOT_CONFIGURED -> + StatusStyle("未配置", StatusLevel.WARNING) + lastFetchError != null -> + StatusStyle("刷新失败", StatusLevel.ERROR) + credentialState == CredentialStatus.State.EXPIRED -> + StatusStyle("已过期", StatusLevel.ERROR) + credentialState == CredentialStatus.State.WARNING -> + StatusStyle("需重登", StatusLevel.WARNING) + cachedBalance == null -> + StatusStyle("待获取", StatusLevel.INFO) + else -> + StatusStyle("正常", StatusLevel.OK) +} + +internal fun secondaryLine(card: DashboardCardUi): String = when (card.service) { + ServiceType.DEEPSEEK -> "REST API · ¥" + ServiceType.OPENCODE_GO -> "WebView 抓取 · 5h 配额" + ServiceType.COMMANDCODE_GO -> "JSON API · $" + ServiceType.CODEX -> "ChatGPT Plus · Codex 额度" + ServiceType.OLLAMA -> "Cookie 抓取 · Cloud 配额" +} + +internal fun footerText(card: DashboardCardUi): String { + if (card.lastFetchError != null) { + val msg = card.lastFetchError.take(60) + return "⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}" + } + val fetchedAt = card.cachedBalance?.fetchedAt ?: return "从未获取" + val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) + val now = System.currentTimeMillis() + val diffMin = (now - fetchedAt) / 60_000 + val timeStr = sdf.format(Date(fetchedAt)) + return when { + diffMin < 1 -> "刚刚更新" + diffMin < 60 -> "$diffMin 分钟前更新" + diffMin < 1440 -> "${diffMin / 60} 小时前更新" + else -> "$timeStr 更新" + } +} + +internal fun formatAmount(value: Double): String { + return if (value % 1.0 == 0.0) value.toInt().toString() + else String.format(Locale.US, "%.2f", value) +} \ No newline at end of file