feat(ui): Codex 主模型余额为主视觉,Spark 弱化为次要摘要
- 首页卡片:主模型窗口独占进度行,Spark 收为单行小字摘要\n- 详情页:Spark 收进分隔线 + 小标题次要小节,细条低饱和进度\n- 桌面小组件:跳过 SPARK 窗口,仅保留主模型三行布局\n- 新增 window_spark 多语言文案
This commit is contained in:
parent
7e44e545bf
commit
43d8df90f6
@ -297,21 +297,34 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
|
||||
|
||||
val weeklyLabel = stringResource(R.string.window_every_week)
|
||||
val monthlyLabel = stringResource(R.string.window_every_month)
|
||||
val usageLabel = stringResource(R.string.window_usage)
|
||||
val sparkTitle = stringResource(R.string.window_spark)
|
||||
|
||||
data class UiWindow(val label: String, val isSpark: Boolean, val remainingPct: Int?, val resetAt: Long?)
|
||||
|
||||
val windows = (0 until windowCount).map { i ->
|
||||
val rawLabel = extras["window_$i.label"] ?: "usage"
|
||||
val label = normalizeWindowLabel(
|
||||
extras["window_$i.label"] ?: "usage",
|
||||
rawLabel,
|
||||
weeklyLabel = weeklyLabel,
|
||||
monthlyLabel = monthlyLabel,
|
||||
usageLabel = stringResource(R.string.window_usage)
|
||||
usageLabel = usageLabel
|
||||
)
|
||||
UiWindow(
|
||||
label = label,
|
||||
isSpark = extras["window_$i.group"] == "SPARK",
|
||||
remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull(),
|
||||
resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||
)
|
||||
val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
|
||||
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||
Triple(label, remainingPct, resetAt)
|
||||
}
|
||||
|
||||
// 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签,用于保留空槽位)
|
||||
// 主模型(普通 Codex)窗口独占全部进度行;Spark 独立限额是次要通道,只留一行小字摘要
|
||||
val mainWindows = windows.filter { !it.isSpark }
|
||||
val sparkWindows = windows.filter { it.isSpark }
|
||||
|
||||
// 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签,仅看主模型窗口)
|
||||
val fiveHourLabel = stringResource(R.string.window_5h)
|
||||
val has5h = windows.any { isFiveHourLabel(it.first, fiveHourLabel, stringResource(R.string.window_5h_short)) }
|
||||
val has5h = mainWindows.any { isFiveHourLabel(it.label, fiveHourLabel, stringResource(R.string.window_5h_short)) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (windows.isEmpty()) {
|
||||
@ -321,14 +334,26 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
|
||||
if (!has5h) {
|
||||
CompactUsageRowEmpty(label = fiveHourLabel, resetInSec = null)
|
||||
}
|
||||
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 })
|
||||
mainWindows.forEach { w ->
|
||||
val resetSec = w.resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
|
||||
if (w.remainingPct != null) {
|
||||
CompactUsageRow(label = w.label, pct = (100 - w.remainingPct).coerceIn(0, 100), resetInSec = resetSec)
|
||||
} else {
|
||||
CompactUsageRowEmpty(label = label, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
|
||||
CompactUsageRowEmpty(label = w.label, resetInSec = resetSec)
|
||||
}
|
||||
}
|
||||
// Spark 次要限额:单行摘要,不与主模型争夺视觉焦点
|
||||
if (sparkWindows.isNotEmpty()) {
|
||||
val summary = sparkWindows.joinToString(" · ") { w ->
|
||||
val used = w.remainingPct?.let { (100 - it).coerceIn(0, 100) }
|
||||
if (used != null) "${w.label} $used%" else "${w.label} —"
|
||||
}
|
||||
Text(
|
||||
text = "$sparkTitle · $summary",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -485,10 +485,13 @@ private fun CodexUsageCard(state: State) {
|
||||
}
|
||||
val extras = balance?.extras ?: return
|
||||
val plan = extras["plan"].orEmpty()
|
||||
val windows = remember(extras) { extractCodexWindows(extras) }
|
||||
if (windows.isEmpty()) return
|
||||
val allWindows = remember(extras) { extractCodexWindows(extras) }
|
||||
if (allWindows.isEmpty()) return
|
||||
// 主模型窗口占据主视觉;Spark 独立限额收进底部次要小节
|
||||
val windows = allWindows.filter { it.group == CodexWindowGroup.CODEX }
|
||||
val sparkWindows = allWindows.filter { it.group == CodexWindowGroup.SPARK }
|
||||
|
||||
// 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签)
|
||||
// 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签;仅看主模型窗口)
|
||||
val has5h = windows.any {
|
||||
isFiveHourLabel(it.rawLabel, stringResource(R.string.window_5h), stringResource(R.string.window_5h_short))
|
||||
}
|
||||
@ -547,14 +550,100 @@ private fun CodexUsageCard(state: State) {
|
||||
decimals = 2
|
||||
)
|
||||
}
|
||||
|
||||
// Spark 独立限额(次要通道):分隔线 + 小标题 + 紧凑细条,视觉权重远低于主模型
|
||||
if (sparkWindows.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.window_spark),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
sparkWindows.forEach { window ->
|
||||
val resetInSec = window.resetAt?.let {
|
||||
(it - System.currentTimeMillis()) / 1000
|
||||
}?.takeIf { it > 0 }
|
||||
SparkUsageRow(
|
||||
label = codexWindowLabel(window.rawLabel),
|
||||
pct = window.usedPct,
|
||||
resetInSec = resetInSec
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spark 次要限额的紧凑行:小字号 + 细进度条,视觉权重明显低于主模型的 [UsageWindowRow]。
|
||||
*/
|
||||
@Composable
|
||||
private fun SparkUsageRow(label: String, pct: Float, resetInSec: Long?) {
|
||||
val durationText = DurationText(
|
||||
day = stringResource(R.string.format_day),
|
||||
hour = stringResource(R.string.format_hour),
|
||||
minute = stringResource(R.string.format_minute)
|
||||
)
|
||||
val pctValue = pct.coerceIn(0f, 100f)
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = String.format(Locale.US, "%.1f", pctValue),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(start = 1.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { pctValue / 100f },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.5.dp)
|
||||
.clip(RoundedCornerShape(1.25.dp)),
|
||||
color = when {
|
||||
pctValue >= 80f -> MaterialTheme.colorScheme.error.copy(alpha = 0.75f)
|
||||
else -> StrawberryPink.copy(alpha = 0.55f)
|
||||
},
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
if (resetInSec != null && resetInSec > 0) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CodexWindowGroup { CODEX, SPARK }
|
||||
|
||||
private data class CodexWindow(
|
||||
val rawLabel: String,
|
||||
val usedPct: Float,
|
||||
val resetAt: Long?
|
||||
val resetAt: Long?,
|
||||
val group: CodexWindowGroup = CodexWindowGroup.CODEX
|
||||
)
|
||||
|
||||
private fun extractCodexWindows(extras: Map<String, String>): List<CodexWindow> {
|
||||
@ -565,7 +654,8 @@ private fun extractCodexWindows(extras: Map<String, String>): List<CodexWindow>
|
||||
val remaining = extras["window_${i}.remainingPct"]?.toFloatOrNull() ?: 0f
|
||||
val resetAt = extras["window_${i}.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||
val usedPct = (100f - remaining).coerceIn(0f, 100f)
|
||||
result.add(CodexWindow(rawLabel, usedPct, resetAt))
|
||||
val group = if (extras["window_${i}.group"] == "SPARK") CodexWindowGroup.SPARK else CodexWindowGroup.CODEX
|
||||
result.add(CodexWindow(rawLabel, usedPct, resetAt, group))
|
||||
i++
|
||||
}
|
||||
return result
|
||||
@ -574,14 +664,14 @@ private fun extractCodexWindows(extras: Map<String, String>): List<CodexWindow>
|
||||
@Composable
|
||||
private fun codexWindowLabel(raw: String): String = when (raw.lowercase()) {
|
||||
"5h" -> stringResource(R.string.window_5h)
|
||||
"7d", "weekly", "每周" -> stringResource(R.string.window_weekly)
|
||||
"7d", "weekly", "每周", "每週" -> stringResource(R.string.window_weekly)
|
||||
"30d", "monthly", "每月" -> stringResource(R.string.window_monthly)
|
||||
"usage" -> stringResource(R.string.window_usage)
|
||||
else -> raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama Pro 专属:5h + 每周用量窗口卡 + 模型级调用次数。
|
||||
* Ollama 专属:5h + 每周用量窗口卡 + 模型级调用次数。
|
||||
*/
|
||||
@Composable
|
||||
private fun OllamaUsageCard(state: State) {
|
||||
|
||||
@ -205,26 +205,27 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
val weeklyLabel = context.getString(R.string.window_every_week)
|
||||
val monthlyLabel = context.getString(R.string.window_every_month)
|
||||
val usageLabel = context.getString(R.string.window_usage)
|
||||
val windows = (0 until windowCount).map { index ->
|
||||
// 仅主模型窗口进入桌面组件;Spark 独立限额为次要通道,不占用组件行位
|
||||
val normal = mutableListOf<Triple<String, Int?, Long?>>()
|
||||
for (index in 0 until windowCount) {
|
||||
if (extras["window_$index.group"] == "SPARK") continue
|
||||
val rawLabel = extras["window_$index.label"] ?: "usage"
|
||||
val label = normalizeWindowLabel(
|
||||
extras["window_$index.label"] ?: "usage",
|
||||
rawLabel,
|
||||
weeklyLabel = weeklyLabel,
|
||||
monthlyLabel = monthlyLabel,
|
||||
usageLabel = usageLabel
|
||||
)
|
||||
val remaining = extras["window_$index.remainingPct"]?.toIntOrNull()
|
||||
val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||
Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
|
||||
normal.add(Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }))
|
||||
}
|
||||
// 判断是否有 5h 窗口
|
||||
val has5h = windows.any { it.first.contains("5") }
|
||||
// 始终保留 5h 槽位在第一行
|
||||
val row1 = if (!has5h) {
|
||||
Triple("5h", null, null)
|
||||
} else {
|
||||
windows.firstOrNull { it.first.contains("5") } ?: windows.getOrNull(0) ?: Triple("5h", null, null)
|
||||
}
|
||||
val otherWindows = windows.filter { it != row1 }
|
||||
val has5h = normal.any { it.first.contains("5") }
|
||||
// 始终保留 5h 槽位在第一行(按索引取行,避免内容相同的窗口被误过滤)
|
||||
val row1Idx = if (has5h) (normal.indexOfFirst { it.first.contains("5") }.takeIf { it >= 0 } ?: 0) else -1
|
||||
val row1 = if (row1Idx >= 0) normal[row1Idx] else Triple("5h", null, null)
|
||||
val otherWindows = normal.filterIndexed { idx, _ -> idx != row1Idx }
|
||||
val row2 = otherWindows.getOrNull(0) ?: Triple(weeklyLabel, null, null)
|
||||
val row3 = otherWindows.getOrNull(1) ?: Triple(monthlyLabel, null, null)
|
||||
setRowLabel(views, row1.first, row2.first, row3.first)
|
||||
|
||||
@ -106,7 +106,7 @@
|
||||
<string name="service_desc_deepseek">REST API · ¥</string>
|
||||
<string name="service_desc_opencode_go">WebView 抓取 · 5h 配额</string>
|
||||
<string name="service_desc_commandcode_go">JSON API · $</string>
|
||||
<string name="service_desc_codex">ChatGPT Plus · Codex 额度</string>
|
||||
<string name="service_desc_codex">ChatGPT · Codex 额度</string>
|
||||
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配额</string>
|
||||
|
||||
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
|
||||
@ -385,7 +385,7 @@
|
||||
<string name="help_codex_12"> }}</string>
|
||||
<string name="help_codex_note">提示:导入后 APP 会自动刷新 token,</string>
|
||||
<string name="help_codex_note2">不需要手动更新。</string>
|
||||
<string name="credential_title_ollama">Ollama Pro 凭据</string>
|
||||
<string name="credential_title_ollama">Ollama 凭据</string>
|
||||
<string name="credential_hint_ollama">需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。</string>
|
||||
<string name="credential_update">更新凭据</string>
|
||||
<string name="credential_save">保存凭据</string>
|
||||
@ -414,7 +414,7 @@
|
||||
<string name="api_key_form_title">请输入 API Key</string>
|
||||
<string name="api_key_form_hint">凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。</string>
|
||||
<string name="action_update">更新</string>
|
||||
<string name="credential_title_codex">Codex / ChatGPT Plus 凭据</string>
|
||||
<string name="credential_title_codex">Codex / ChatGPT 凭据</string>
|
||||
<string name="credential_hint_codex">推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。</string>
|
||||
<string name="action_oauth_login">🔐 OAuth 登录(推荐)</string>
|
||||
<string name="or_manual_import">── 或手动导入 ──</string>
|
||||
@ -435,6 +435,7 @@
|
||||
<string name="error_parse_models_empty">模型列表为空</string>
|
||||
<string name="error_parse_malformed">响应格式异常</string>
|
||||
<string name="window_every_week">每周</string>
|
||||
<string name="window_spark">Spark</string>
|
||||
<string name="msg_model_call_count">模型调用次数</string>
|
||||
<string name="msg_stale_cache">缓存数据 · %1$s</string>
|
||||
<string name="msg_showing_last_balance">显示的是上次成功获取的余额</string>
|
||||
|
||||
@ -106,7 +106,7 @@
|
||||
<string name="service_desc_deepseek">REST API · ¥</string>
|
||||
<string name="service_desc_opencode_go">WebView 抓取 · 5h 配額</string>
|
||||
<string name="service_desc_commandcode_go">JSON API · $</string>
|
||||
<string name="service_desc_codex">ChatGPT Plus · Codex 額度</string>
|
||||
<string name="service_desc_codex">ChatGPT · Codex 額度</string>
|
||||
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配額</string>
|
||||
|
||||
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
|
||||
@ -385,7 +385,7 @@
|
||||
<string name="help_codex_12"> }}</string>
|
||||
<string name="help_codex_note">提示:匯入後 APP 會自動重新整理 token,</string>
|
||||
<string name="help_codex_note2">不需要手動更新。</string>
|
||||
<string name="credential_title_ollama">Ollama Pro 憑據</string>
|
||||
<string name="credential_title_ollama">Ollama 憑據</string>
|
||||
<string name="credential_hint_ollama">需要從瀏覽器複製完整 Cookie。登入 ollama.com/settings 後,在 DevTools → Network → 請求頭裡找到 Cookie 行複製。</string>
|
||||
<string name="credential_update">更新憑據</string>
|
||||
<string name="credential_save">儲存憑據</string>
|
||||
@ -414,7 +414,7 @@
|
||||
<string name="api_key_form_title">請輸入 API Key</string>
|
||||
<string name="api_key_form_hint">憑據會使用 Android Keystore (AES-256 GCM) 加密後儲存到本機 DataStore。</string>
|
||||
<string name="action_update">更新</string>
|
||||
<string name="credential_title_codex">Codex / ChatGPT Plus 憑據</string>
|
||||
<string name="credential_title_codex">Codex / ChatGPT 憑據</string>
|
||||
<string name="credential_hint_codex">推薦使用 OAuth 登入自動獲取憑據。也可手動貼上 auth.json 內容。</string>
|
||||
<string name="action_oauth_login">🔐 OAuth 登入(推薦)</string>
|
||||
<string name="or_manual_import">── 或手動匯入 ──</string>
|
||||
@ -435,6 +435,7 @@
|
||||
<string name="error_parse_models_empty">模型清單為空</string>
|
||||
<string name="error_parse_malformed">回應格式異常</string>
|
||||
<string name="window_every_week">每週</string>
|
||||
<string name="window_spark">Spark</string>
|
||||
<string name="msg_model_call_count">模型呼叫次數</string>
|
||||
<string name="msg_stale_cache">快取數據 · %1$s</string>
|
||||
<string name="msg_showing_last_balance">顯示的是上次成功獲取的餘額</string>
|
||||
|
||||
@ -109,7 +109,7 @@
|
||||
<string name="service_desc_deepseek">REST API · ¥</string>
|
||||
<string name="service_desc_opencode_go">WebView fetch · 5h quota</string>
|
||||
<string name="service_desc_commandcode_go">JSON API · $</string>
|
||||
<string name="service_desc_codex">ChatGPT Plus · Codex quota</string>
|
||||
<string name="service_desc_codex">ChatGPT · Codex quota</string>
|
||||
<string name="service_desc_ollama">Cookie fetch · Cloud quota</string>
|
||||
|
||||
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
|
||||
@ -388,7 +388,7 @@
|
||||
<string name="help_codex_12"> }}</string>
|
||||
<string name="help_codex_note">Tip: the app auto-refreshes the token after import,</string>
|
||||
<string name="help_codex_note2">no manual updates needed.</string>
|
||||
<string name="credential_title_ollama">Ollama Pro credentials</string>
|
||||
<string name="credential_title_ollama">Ollama credentials</string>
|
||||
<string name="credential_hint_ollama">Copy the full cookie from your browser. After logging into ollama.com/settings, find the Cookie header in DevTools → Network and copy it.</string>
|
||||
<string name="credential_update">Update credentials</string>
|
||||
<string name="credential_save">Save credentials</string>
|
||||
@ -417,7 +417,7 @@
|
||||
<string name="api_key_form_title">Enter API Key</string>
|
||||
<string name="api_key_form_hint">Credentials are encrypted with Android Keystore (AES-256 GCM) and stored in local DataStore.</string>
|
||||
<string name="action_update">Update</string>
|
||||
<string name="credential_title_codex">Codex / ChatGPT Plus credentials</string>
|
||||
<string name="credential_title_codex">Codex / ChatGPT credentials</string>
|
||||
<string name="credential_hint_codex">OAuth login fetches credentials automatically. You can also paste auth.json manually.</string>
|
||||
<string name="action_oauth_login">🔐 OAuth login (recommended)</string>
|
||||
<string name="or_manual_import">── or import manually ──</string>
|
||||
@ -438,6 +438,7 @@
|
||||
<string name="error_parse_models_empty">Model list is empty</string>
|
||||
<string name="error_parse_malformed">Response format is abnormal</string>
|
||||
<string name="window_every_week">Weekly</string>
|
||||
<string name="window_spark">Spark</string>
|
||||
<string name="msg_model_call_count">Model call count</string>
|
||||
<string name="msg_stale_cache">Cached data · %1$s</string>
|
||||
<string name="msg_showing_last_balance">Showing the last successfully fetched balance</string>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user