diff --git a/.gitignore b/.gitignore
index f8cf874..91b2124 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ release.jks
.kotlin/
*.jks
tmp_cc/
+goal.md
diff --git a/AGENTS.md b/AGENTS.md
index c25b15b..ee7f4c2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,14 @@
# 雨晴Token — AI 余额查询 APP
+## ⚠️ 提交/推送许可红线(最高优先级)
+
+> 本仓库的所有 Git 写操作(`git commit`、`git push`、创建/合并 PR、推送 tag 等)**必须事先获得水晴喵的明确同意**,未经允许一律禁止执行。
+>
+> - 任何代码修改完成后,只做本地编辑,**不得擅自 commit / push**。需要提交时,先向水晴喵说明改动内容与提交意图,等她说「可以提交」「push 吧」之类的明确许可后再执行。
+> - 不得以「PR 审查」「CI 验证」等理由绕过许可——创建 PR、推送分支也属于写操作,同样需要先征求同意。
+> - 水晴喵说「不用了」「先别提交」「再看看」时,保持当前状态,不 push 任何东西。
+> - 这条规则优先级高于文件中其它任何流程(包括「重大修改 PR 审计红线」),其它规则只决定**怎么提交**,这条决定**能不能提交**。
+
## 项目概述
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额/配额。
diff --git a/README.en.md b/README.en.md
new file mode 100644
index 0000000..b90c814
--- /dev/null
+++ b/README.en.md
@@ -0,0 +1,224 @@
+# RainyToken (雨晴Token)
+
+> *"AI Balance & Usage at a Glance"*
+
+[](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
+[](https://github.com/CATMIAOZHI/Rainytoken/actions)
+[](https://github.com/CATMIAOZHI/Rainytoken/releases)
+Android app for checking AI balance & usage — unified view of DeepSeek, OpenCode Go, CommandCode Go, Codex / ChatGPT Plus, and Ollama Pro balances and usage quotas. Pink-toned brand UI, with companion home screen widgets.
+RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family tools.
+
+---
+
+## 📸 Screenshots
+
+
+
+
+
+
+
+
+
+ Dashboard (light) · Dashboard (dark) · Usage charts · Ollama Pro model call counts
+
+
+
+
+
+
+
+
+ Home screen widget · Ollama Pro home card
+
+
+---
+
+## ✨ Features
+
+| Feature | Description |
+|------|------|
+| 📊 **Dashboard** | DeepSeek balance (¥) + usage/balance cards for each service; OCGO/CCGO cards link directly to usage details; long-press & drag to reorder (persisted); pull-to-refresh globally · tablet-adaptive two-pane layout |
+| 📈 **Usage charts** | 3 Canvas-drawn charts — amount spent / API request count / token consumption (dual data sources: OCGO & CCGO); UTC+0/UTC+8 timezone switching and custom day/month/range; automatic fallback (no data in last 5h → 12h → 7 days → current month); side-by-side on tablets |
+| 📱 **Tablet adaptation** | Global `BoxWithConstraints` adaptive container width; ≥600dp dual-column cards, ≥700dp side-by-side charts; two-pane 35/65 split (Expanded mode); supports Android 13+ predictive back gestures |
+| 📋 **Detailed data** | Paginated browsing of raw records with time + model filtering; tap to view full fields |
+| 🔍 **Multi-granularity filters** | 5 hours / 12 hours (10-minute buckets) / 24 hours / today / yesterday / last 7 days / current month / custom day · month · range |
+| 🏷️ **Model filtering** | Multi-select / single-select / select all, with dynamic legends that wrap automatically |
+| 📱 **Home screen widget** | Check usage without opening the app; switch between four services (OCGO/CCGO/Codex/Ollama) + DeepSeek balance; top-left corner → open app, tap elsewhere to switch, ↻ to refresh; can be added to the At a Glance screen; auto-refreshes when scrolled into view (MIUI exposure refresh) |
+| 🔄 **Auto sync** | Pull-to-refresh on home auto-syncs usage; auto full sync on startup when no cache; CCGO detail page supports manual clear & re-sync |
+| 🌙 **Dark mode** | Fully adaptive — in-app text/icons/background switch automatically; widget adapts independently with a dark layout |
+| ➕ **One-tap add to home screen** | Tap + inside the app to add the widget directly, no need to browse the system list; double confirmation + permission check |
+| 💡 **Usage tips** | Home page shows a random operation tip (refreshed on each launch); settings page lists all 13 hidden tips |
+| ⚡ **Room database** | Usage records stored in Room (indexed on workspaceId+timeCreated); DAO queries replace full JSON serialization; auto-migration from the legacy DataStore on first launch |
+| 🎀 **Rainy pink theme** | Material Design 3 · Strawberry Pink #FF85A2 · Sakura Pink #FFD1DC |
+| 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed |
+| ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable |
+| ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev |
+| 🐛 **Debug logs** | In-app "Debug Logs" page showing detailed logs of Repository network requests, token refreshes, parse errors, etc.; no computer needed |
+| 🌐 **Multilingual** | Simplified Chinese / 繁體中文 / English interface; one-tap switching in settings (follow system / Simplified / Traditional / English); two-way sync with the system "App languages" page on Android 13+; non-CN/EN system languages fall back to English; home screen app name and widget follow the language |
+
+---
+
+## 📦 Download
+
+Download the latest APK from [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases).
+
+> ⚠️ You need a DeepSeek API Key, OpenCode Go login credentials, CommandCode Go API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Pro Cookie to fetch data.
+
+---
+
+## 🏗️ Architecture
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Android App │
+│ │
+│ ┌────────────────────────────────────────────────────────────┐ │
+│ │ Compose UI (3-layer screens) │ │
+│ │ Dashboard · Usage Charts · Totals · Detail · Settings │ │
+│ └──────────────────────────────┬─────────────────────────────┘ │
+│ │ │
+│ ┌──────────────────────────────▼─────────────────────────────┐ │
+│ │ ViewModel Layer (MVVM) │ │
+│ │ DashboardVM · UsageVM · UsageChartVM │ │
+│ │ · UsageDataVM (Hilt injected) │ │
+│ └──────────────────────────────┬─────────────────────────────┘ │
+│ │ │
+│ ┌──────────────────────────────▼─────────────────────────────┐ │
+│ │ UseCase Layer │ │
+│ │ RefreshBalanceUseCase (balance) │ │
+│ │ SyncUsageUseCase / SyncCommandCodeUsageUseCase │ │
+│ └──────────────────────────────┬─────────────────────────────┘ │
+│ │ │
+│ ┌──────────────────────────────▼─────────────────────────────┐ │
+│ │ Repository + Network │ │
+│ │ DeepSeekApi (Retrofit) · OpenCodeGo web scraping │ │
+│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository │ │
+│ │ · CodexRepository · OllamaRepository (OkHttp) │ │
+│ └──────────────────────────────┬─────────────────────────────┘ │
+│ │ │
+│ ┌──────────────────────────────▼─────────────────────────────┐ │
+│ │ Local Storage │ │
+│ │ BalanceCache (DataStore) │ │
+│ │ UsageCache (Room, indexed on workspaceId+timeCreated) │ │
+│ │ CredentialRepository (Keystore AES-256 GCM) │ │
+│ └────────────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 📁 Project Structure
+
+```
+Rainytoken/
+├── app/src/main/java/com/rainy/token/
+│ ├── data/
+│ │ ├── cache/ # BalanceCache (DataStore)
+│ │ ├── local/ # UsageCache (Room), UsageRecordEntity, UsageDao, UsageDatabase, ChartBucket
+│ │ ├── remote/ # DeepSeekApi (Retrofit) + OpenCodeGo scraping + UsageRepository
+│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Codex / Ollama / Credential repositories
+│ ├── domain/
+│ │ ├── model/ # ServiceBalance, Credential, etc.
+│ │ ├── service/ # ServiceType enum
+│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase
+│ ├── ui/
+│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen / UsageOverviewScreen / UsageDataScreen
+│ │ ├── widget/ # Home screen widget (OpenCodeGoWidgetProvider)
+│ │ ├── components/ # ServiceIcon / StatusChip, etc.
+│ │ ├── theme/ # Rainy pink theme (StrawberryPink / InkMuted)
+│ │ └── RainyTokenNavHost.kt # Navigation routes
+│ └── di/ # Hilt modules
+├── gradle/libs.versions.toml # Version Catalog dependency management
+├── build.gradle.kts # Project-level configuration
+└── settings.gradle.kts # Project settings
+```
+
+---
+
+## 🛠️ Build
+
+### Option 1: Android Studio (recommended)
+1. Clone the repository: `git clone https://github.com/CATMIAOZHI/Rainytoken.git`
+2. Open the project in Android Studio
+3. Sync Gradle, connect a device, and hit Run ▶️
+
+### Option 2: Command line
+```bash
+# Run unit tests
+./gradlew testDebugUnitTest
+
+# Build a Debug APK
+./gradlew assembleDebug
+# APK output: app/build/outputs/apk/debug/app-debug.apk
+
+# Build a Release APK
+./gradlew assembleRelease
+```
+
+
+🔧 ARM64 environment notes (optional)
+
+The project bundles an ARM64 AAPT2 binary, which is enabled automatically in ARM64 environments such as Proot/Termux:
+```bash
+chmod +x ./setup_android_env.sh
+./setup_android_env.sh
+```
+The script configures `$ANDROID_HOME` and uses the project-bundled ARM64 build-tools.
+
+**Extra Release build configuration**: Under ARM64 Proot, AGP 9.0 passes `--resource-path-shortening-map=` (equals-sign syntax) to `optimizeReleaseResources`, which the ARM64 AAPT2 does not accept. Configure the AAPT2 wrapper in `~/.gradle/gradle.properties` (global, not committed to the repository):
+```properties
+android.aapt2FromMavenOverride=/path/to/android-aapt2-wrapper/aapt2
+```
+The wrapper script splits the equals-sign syntax into two separate space-delimited argv entries. The project's `build.gradle.kts` includes a built-in `guardReleaseResources` task as a build safeguard (falls back to the linked `.ap_` automatically when the optimized `.ap_` is missing).
+> On x86_64 environments (GitHub Actions / regular Linux), the official AAPT2 is used automatically — no extra steps required.
+
+
+---
+
+## 📦 Dependency Management
+
+The project manages all dependencies with a Gradle Version Catalog (`gradle/libs.versions.toml`).
+
+| Dependency | Purpose |
+|------|------|
+| `androidx.compose:compose-bom` | Jetpack Compose BOM |
+| `androidx.navigation:navigation-compose` | Page navigation |
+| `com.squareup.retrofit2:retrofit` | DeepSeek REST API |
+| `com.squareup.okhttp3:okhttp` | OpenCode Go web scraping |
+| `org.jetbrains.kotlinx:kotlinx-serialization-json` | JSON serialization |
+| `androidx.room:room-runtime` | Local usage database (Room) |
+| `androidx.datastore:datastore-preferences` | Local caching |
+| `com.google.dagger:hilt-android` | Dependency injection |
+| `com.google.devtools.ksp:symbol-processing-api` | KSP annotation processing |
+
+---
+
+## 🔒 Security
+
+- ✅ API keys / session credentials are stored in the **Android Keystore** (AES-256 GCM encrypted)
+- ✅ Network requests are only sent to the official DeepSeek / OpenCode APIs
+- ✅ `allowBackup=false`: encrypted credentials and usage data are excluded from system backups, preventing undecryptable ciphertext when restoring on a new device
+- ✅ Fixed signing key — every Release can be installed over the previous version
+- ✅ Signing keys and passwords are stored encrypted in GitHub Secrets and decrypted during CI
+
+---
+
+## 🐱 About
+
+RainyToken (雨晴Token) is the 4th member of the "Rainy Family" tools, maintained by [雨晴喵 (Rainy)](https://github.com/CATMIAOZHI), released alongside the [Rainy Family](https://github.com/CATMIAOZHI?tab=repositories):
+
+- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — fully offline Android local LLM inference server (Gemma + OpenAI-compatible API)
+- [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — Android QR scanner that neither intercepts nor redirects
+- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — fully local · zero network · biometric-protected TOTP authenticator
+- **RainyToken** — AI Balance & Usage Quota Query (this project)
+
+---
+
+## 📄 License
+
+MIT License © 2026 Rainy
+
+---
+
+RainyToken · the Rainy Family tools
diff --git a/README.md b/README.md
index bd23c2b..5723e06 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
# 🌧️ 雨晴Token (RainyToken)
-> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* 🐱✨
+> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"*
[](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
[](https://github.com/CATMIAOZHI/Rainytoken/releases)
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
-🐱 雨晴Token — AI Balance & Usage Quota Query | the Rainy Family tools.
+RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Family tools.
---
@@ -55,6 +55,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
| ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
| ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 |
| 🐛 **调试日志** | APP 内「调试日志」页面,查看 Repository 网络请求、Token 刷新、解析错误等详细日志,无需连接电脑 |
+| 🌐 **多语言** | 简体中文 / 繁體中文 / English 三语言界面;设置页一键切换(跟随系统 / 简体 / 繁体 / English);Android 13+ 与系统「应用语言」页双向同步;非中英系统语言自动回退英文;桌面 APP 名称与小组件跟随语言 |
---
@@ -204,14 +205,14 @@ wrapper 脚本将等号形式拆分为空格分隔的两个独立 argv。项目
## 🐱 关于
-RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 编纂,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布:
+RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 开发维护,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布:
- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — 纯离线 Android 本地 LLM 推理服务器(Gemma + OpenAI 兼容 API)
- [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — 不拦截不跳转的 Android 扫码工具
- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — 纯本地 · 零联网 · 生物识别保护的 TOTP 验证器
- **RainyToken** — AI 余额与用量查询 APP | AI Balance & Usage Quota Query(本项目)
-> 🎀 Made with love by 雨晴喵 — 守护主人的每一分算力预算 💖
+> 守护每一分 AI 算力预算 💖
---
@@ -221,4 +222,4 @@ MIT License © 2026 Rainy
---
-💖 Made with love by 雨晴喵
\ No newline at end of file
+RainyToken · the Rainy Family tools
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ca55121..d50923a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -76,6 +76,10 @@ android {
buildFeatures {
compose = true
}
+ androidResources {
+ // Android 13+ 应用级语言设置:根据 values-* 目录自动生成 locales_config
+ generateLocaleConfig = true
+ }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
diff --git a/app/src/main/java/com/rainy/token/MainActivity.kt b/app/src/main/java/com/rainy/token/MainActivity.kt
index 98065c8..98ab02e 100644
--- a/app/src/main/java/com/rainy/token/MainActivity.kt
+++ b/app/src/main/java/com/rainy/token/MainActivity.kt
@@ -1,5 +1,6 @@
package com.rainy.token
+import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -20,6 +21,11 @@ import dagger.hilt.android.AndroidEntryPoint
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ override fun attachBaseContext(newBase: Context) {
+ // 应用内语言偏好在此层生效:recreate() 后资源按所选语言重新解析
+ super.attachBaseContext(com.rainy.token.util.LocaleManager.wrapContext(newBase))
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
diff --git a/app/src/main/java/com/rainy/token/RainyTokenApplication.kt b/app/src/main/java/com/rainy/token/RainyTokenApplication.kt
index 6b981ec..1939403 100644
--- a/app/src/main/java/com/rainy/token/RainyTokenApplication.kt
+++ b/app/src/main/java/com/rainy/token/RainyTokenApplication.kt
@@ -12,6 +12,12 @@ import dagger.hilt.android.HiltAndroidApp
*/
@HiltAndroidApp
class RainyTokenApplication : Application() {
+ override fun attachBaseContext(base: Context) {
+ // 应用内语言偏好(跟随系统 / 中文 / English)在 Application 层生效,
+ // 保证 appContext 与系统级回调(如小组件)之外的代码都使用所选语言。
+ super.attachBaseContext(com.rainy.token.util.LocaleManager.wrapContext(base))
+ }
+
override fun onCreate() {
super.onCreate()
appContext = applicationContext
diff --git a/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt b/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt
index 348862b..b73007e 100644
--- a/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt
+++ b/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt
@@ -1,5 +1,7 @@
package com.rainy.token.data.local
+import androidx.annotation.StringRes
+import com.rainy.token.R
import java.time.Instant
import java.time.ZoneOffset
@@ -25,18 +27,18 @@ data class ModelBucket(
val outputTokens: Long
)
-/** 时间粒度 */
-enum class ChartGranularity(val label: String) {
- LAST_5H_HOURLY("最近5小时"),
- LAST_12H_10MIN("最近12小时"),
- LAST_24H_HOURLY("最近24小时"),
- TODAY_HOURLY("今天"),
- YESTERDAY_HOURLY("昨天"),
- LAST_7D_DAILY("最近7天"),
- THIS_MONTH_DAILY("当月"),
- CUSTOM_DAY_HOURLY("自定义日"),
- CUSTOM_MONTH_DAILY("自定义月"),
- CUSTOM_RANGE_DAILY("自定义日期")
+/** 时间粒度(label 用字符串资源 ID,UI 层按当前语言解析) */
+enum class ChartGranularity(@StringRes val labelRes: Int) {
+ LAST_5H_HOURLY(R.string.time_last_5h),
+ LAST_12H_10MIN(R.string.time_last_12h),
+ LAST_24H_HOURLY(R.string.time_last_24h),
+ TODAY_HOURLY(R.string.time_today),
+ YESTERDAY_HOURLY(R.string.time_yesterday),
+ LAST_7D_DAILY(R.string.time_last_7d),
+ THIS_MONTH_DAILY(R.string.time_this_month),
+ CUSTOM_DAY_HOURLY(R.string.time_custom_day),
+ CUSTOM_MONTH_DAILY(R.string.time_custom_month),
+ CUSTOM_RANGE_DAILY(R.string.time_custom_range)
}
/** 聚合工具 */
diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt
index c399033..a33847d 100644
--- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt
@@ -3,6 +3,7 @@ package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
+import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
@@ -114,9 +115,9 @@ class CodexRepository(
parseUsageWindows(usageResult)
} catch (e: Exception) {
DebugLog.e(TAG, "解析 Codex 用量失败: ${e::class.simpleName}: ${e.message}")
- return@withContext Result.failure(RepositoryError.ParseError("Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}"))
+ return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}"))
}
- if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据"))
+ if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据"))
val config = ServiceConfigProvider.get(ServiceType.CODEX)
val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first()
@@ -154,16 +155,16 @@ class CodexRepository(
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
- val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject
- ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
+ val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
// 结构: { "openai": { "models": { "gpt-5.6": {...}, ... } } }
val openaiProvider = root["openai"] as? JsonObject
val modelsObj = openaiProvider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
- ?: throw RepositoryError.ParseError("未找到 OpenAI 模型列表")
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenAI 模型列表")
}
if (models.isEmpty()) {
- return@withContext Result.failure(RepositoryError.ParseError("模型列表为空"))
+ return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
@@ -203,7 +204,7 @@ class CodexRepository(
* @param model 用户选择的模型 slug
* @return Result.success(响应体原文) — 供 UI 展示
*/
- suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) {
+ suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.CODEX)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
if (credential !is Credential.CodexCredential)
@@ -239,10 +240,10 @@ class CodexRepository(
DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}")
if (!resp2.isSuccessful) {
DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}")
- return@use Result.failure(
+ return@use Result.failure(
TriggerError(
"HTTP ${resp2.code}",
- body2.ifBlank { "无响应体" }
+ body2.ifBlank { "" }
)
)
}
@@ -251,12 +252,12 @@ class CodexRepository(
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "triggerUsage: 二次刷新失败: ${r.reason}")
- return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", ""))
+ return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", "", TriggerErrorReason.TOKEN_REFRESH))
}
}
} else {
- return@use Result.failure(
- TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
+ return@use Result.failure(
+ TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
)
}
} else {
@@ -385,22 +386,28 @@ class CodexRepository(
DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url")
throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code)
}
- return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject)
- ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
+ return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject)
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
}
}
@Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null)
}
+/** TriggerError 的结构化原因(UI 层据此映射本地化文案;null = HTTP 错误,summary 为 ASCII 可直显) */
+enum class TriggerErrorReason {
+ /** token 刷新失败(summary 中为中文用户提示,不直接透传 UI) */
+ TOKEN_REFRESH
+}
+
/** 携带完整响应体的错误类,供 UI 展示服务端返回的详细信息 */
-class TriggerError(val summary: String, val responseBody: String) : Exception(summary)
+class TriggerError(val summary: String, val responseBody: String, val reason: TriggerErrorReason? = null) : Exception(summary)
/**
* 将 SSE 流响应解析为简洁的文本摘要。
* 提取:模型回复文本、用量统计(input/output tokens)。
*/
-internal fun parseSseResponse(sseText: String, model: String): String {
+internal fun parseSseResponse(sseText: String, model: String): TriggerSummary {
val sseJson = Json { ignoreUnknownKeys = true }
val outputText = StringBuilder()
var inputTokens: String? = null
@@ -429,16 +436,11 @@ internal fun parseSseResponse(sseText: String, model: String): String {
} catch (_: Exception) { }
}
- val sb = StringBuilder()
- sb.appendLine("✓ 激活成功")
- sb.appendLine("模型: $model")
- if (responseId != null) sb.appendLine("Response ID: $responseId")
- sb.appendLine()
- sb.appendLine("回复:")
- sb.appendLine(outputText.ifEmpty { "(空)" })
- if (inputTokens != null || outputTokens != null) {
- sb.appendLine()
- sb.appendLine("用量: input=$inputTokens output=$outputTokens tokens")
- }
- return sb.toString().trim()
+ return TriggerSummary(
+ model = model,
+ reply = outputText.toString(),
+ inputTokens = inputTokens,
+ outputTokens = outputTokens,
+ responseId = responseId
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt
index 12ea98b..2ae118c 100644
--- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt
@@ -106,7 +106,7 @@ class CommandCodeUsageRepository(
}
if (body == null) return@withContext Result.failure(
- RepositoryError.ParseError("响应体为空")
+ RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val records = parseUsageResponse(body)
diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt
index 0cd1a1b..a401d65 100644
--- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt
@@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
+import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -177,13 +178,14 @@ class OllamaRepository(
}
val html = resp.body?.string() ?: return@withContext Result.failure(
- RepositoryError.ParseError("响应体为空")
+ RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val parsed = parseUsage(html)
if (parsed == null) {
return@withContext Result.failure(
RepositoryError.ParseError(
+ RepositoryError.ParseErrorReason.NO_WINDOWS,
"解析失败:未找到 Ollama Cloud 用量数据。HTML=${html.length} 字节。Cookie 可能已过期。"
)
)
@@ -240,15 +242,15 @@ class OllamaRepository(
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
- val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject
- ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
+ val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
val provider = root["ollama-cloud"] as? JsonObject
val modelsObj = provider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
- ?: throw RepositoryError.ParseError("未找到 Ollama Cloud 模型列表")
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 Ollama Cloud 模型列表")
}
if (models.isEmpty()) {
- return@withContext Result.failure(RepositoryError.ParseError("模型列表为空"))
+ return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
@@ -268,7 +270,7 @@ class OllamaRepository(
* @param model 用户选择的模型 slug
* @return Result.success(响应摘要文本) — 供 UI 展示
*/
- suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) {
+ suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OLLAMA)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据"))
if (credential !is Credential.SessionCredential)
@@ -296,8 +298,8 @@ class OllamaRepository(
val bodyStr = resp.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
if (!resp.isSuccessful) {
- return@withContext Result.failure(
- TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
+ return@withContext Result.failure(
+ TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
)
}
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
@@ -317,7 +319,7 @@ class OllamaRepository(
/**
* 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。
*/
-internal fun parseOllamaChatResponse(responseBody: String, model: String): String {
+internal fun parseOllamaChatResponse(responseBody: String, model: String): TriggerSummary {
val json = Json { ignoreUnknownKeys = true }
return try {
val root = json.parseToJsonElement(responseBody) as? JsonObject
@@ -330,18 +332,14 @@ internal fun parseOllamaChatResponse(responseBody: String, model: String): Strin
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
- val sb = StringBuilder()
- sb.appendLine("✓ 激活成功")
- sb.appendLine("模型: $model")
- sb.appendLine()
- sb.appendLine("回复:")
- sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)")
- if (promptTokens != null || completionTokens != null) {
- sb.appendLine()
- sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens")
- }
- sb.toString().trim()
+ TriggerSummary(
+ model = model,
+ reply = content,
+ inputTokens = promptTokens,
+ outputTokens = completionTokens,
+ totalTokens = totalTokens
+ )
} catch (_: Exception) {
- "✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)"
+ TriggerSummary(model = model, reply = null, parseFailed = true)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt
index a7738f4..fb56f8d 100644
--- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt
@@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
+import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
@@ -85,13 +86,14 @@ class OpenCodeGoRepository(
}
val html = resp.body?.string() ?: return@withContext Result.failure(
- RepositoryError.ParseError("响应体为空")
+ RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val windows = parseWindows(html)
if (windows.isEmpty()) {
return@withContext Result.failure(
RepositoryError.ParseError(
+ RepositoryError.ParseErrorReason.NO_WINDOWS,
"解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。"
)
)
@@ -150,15 +152,15 @@ class OpenCodeGoRepository(
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
- val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject
- ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
+ val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
val provider = root["opencode-go"] as? JsonObject
val modelsObj = provider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
- ?: throw RepositoryError.ParseError("未找到 OpenCode Go 模型列表")
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenCode Go 模型列表")
}
if (models.isEmpty()) {
- return@withContext Result.failure(RepositoryError.ParseError("模型列表为空"))
+ return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
@@ -178,7 +180,7 @@ class OpenCodeGoRepository(
* @param model 用户选择的模型 slug
* @return Result.success(响应摘要文本) — 供 UI 展示
*/
- suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) {
+ suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据"))
if (credential !is Credential.SessionCredential)
@@ -206,8 +208,8 @@ class OpenCodeGoRepository(
val bodyStr = resp.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
if (!resp.isSuccessful) {
- return@withContext Result.failure(
- TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
+ return@withContext Result.failure(
+ TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
)
}
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
@@ -318,7 +320,7 @@ class OpenCodeGoRepository(
/**
* 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。
*/
-internal fun parseChatResponse(responseBody: String, model: String): String {
+internal fun parseChatResponse(responseBody: String, model: String): TriggerSummary {
val json = Json { ignoreUnknownKeys = true }
return try {
val root = json.parseToJsonElement(responseBody) as? JsonObject
@@ -331,18 +333,14 @@ internal fun parseChatResponse(responseBody: String, model: String): String {
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
- val sb = StringBuilder()
- sb.appendLine("✓ 激活成功")
- sb.appendLine("模型: $model")
- sb.appendLine()
- sb.appendLine("回复:")
- sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)")
- if (promptTokens != null || completionTokens != null) {
- sb.appendLine()
- sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens")
- }
- sb.toString().trim()
+ TriggerSummary(
+ model = model,
+ reply = content,
+ inputTokens = promptTokens,
+ outputTokens = completionTokens,
+ totalTokens = totalTokens
+ )
} catch (_: Exception) {
- "✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)"
+ TriggerSummary(model = model, reply = null, parseFailed = true)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt
index 26c8f30..55f0a6f 100644
--- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt
@@ -88,7 +88,7 @@ class OpenCodeUsageRepository(
}
val body = resp.body?.string() ?: return@withContext Result.failure(
- RepositoryError.ParseError("响应体为空")
+ RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val records = parseUsageRecords(body)
diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt
index b0c80bd..08395d4 100644
--- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt
@@ -23,9 +23,25 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
class ServerError(val code: Int, cause: Throwable? = null) :
RepositoryError("服务端异常 (HTTP $code)", cause)
- /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败) */
- class ParseError(message: String, cause: Throwable? = null) :
- RepositoryError("解析失败: $message", cause)
+ /** 解析失败的具体原因(结构化错误码,UI 层据此映射本地化文案,detail 仅作日志/调试) */
+ enum class ParseErrorReason {
+ /** 响应体为空 */
+ EMPTY_BODY,
+ /** 响应根节点不是 JSON 对象 */
+ NOT_JSON_OBJECT,
+ /** 未找到用量窗口数据 */
+ NO_WINDOWS,
+ /** 未找到模型列表 */
+ NO_MODELS,
+ /** 模型列表为空 */
+ MODELS_EMPTY,
+ /** 响应格式异常(detail 含异常信息,仅供日志) */
+ MALFORMED_RESPONSE
+ }
+
+ /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因(日志用,不直接透传 UI) */
+ class ParseError(val reason: ParseErrorReason, val detail: String, cause: Throwable? = null) :
+ RepositoryError("解析失败: $detail", cause)
/** 未知错误 */
class Unknown(cause: Throwable? = null) : RepositoryError(
diff --git a/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt b/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt
new file mode 100644
index 0000000..0345e32
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt
@@ -0,0 +1,24 @@
+package com.rainy.token.domain.model
+
+/**
+ * 一键激活用量的结构化响应摘要。
+ *
+ * Repository 只负责从响应中提取数据,不拼接展示文案;
+ * UI 层按当前语言环境用 stringResource 组装本地化文本。
+ */
+data class TriggerSummary(
+ /** 发起请求时使用的模型 */
+ val model: String,
+ /** 模型回复文本;null 表示响应中不存在回复内容 */
+ val reply: String?,
+ /** 输入 token 数(OCGO/Ollama 为 prompt_tokens,Codex 为 input_tokens) */
+ val inputTokens: String? = null,
+ /** 输出 token 数(OCGO/Ollama 为 completion_tokens,Codex 为 output_tokens) */
+ val outputTokens: String? = null,
+ /** 总 token 数(OCGO/Ollama 的 total_tokens;Codex 无此字段) */
+ val totalTokens: String? = null,
+ /** Codex 响应 ID */
+ val responseId: String? = null,
+ /** 响应解析失败,但请求已成功发出 */
+ val parseFailed: Boolean = false
+)
diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt
index b943352..0fb8b2b 100644
--- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt
+++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt
@@ -10,6 +10,7 @@ import com.rainy.token.data.repository.RefreshWriteSession
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.retryOnTransientError
import com.rainy.token.domain.model.ServiceBalance
+import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceType
import javax.inject.Inject
import javax.inject.Provider
@@ -55,7 +56,7 @@ class RefreshBalanceUseCase @Inject constructor(
suspend fun fetchCodexModels(): Result> =
codexRepositoryProvider.get().fetchModels()
- suspend fun triggerCodexUsage(model: String): Result =
+ suspend fun triggerCodexUsage(model: String): Result =
withCredentialSession(ServiceType.CODEX) {
codexRepositoryProvider.get().triggerUsage(model)
}
@@ -63,7 +64,7 @@ class RefreshBalanceUseCase @Inject constructor(
suspend fun fetchOpenCodeGoModels(): Result> =
openCodeGoRepositoryProvider.get().fetchModels()
- suspend fun triggerOpenCodeGoUsage(model: String): Result =
+ suspend fun triggerOpenCodeGoUsage(model: String): Result =
withCredentialSession(ServiceType.OPENCODE_GO) {
openCodeGoRepositoryProvider.get().triggerUsage(model)
}
@@ -71,7 +72,7 @@ class RefreshBalanceUseCase @Inject constructor(
suspend fun fetchOllamaModels(): Result> =
ollamaRepositoryProvider.get().fetchModels()
- suspend fun triggerOllamaUsage(model: String): Result =
+ suspend fun triggerOllamaUsage(model: String): Result =
withCredentialSession(ServiceType.OLLAMA) {
ollamaRepositoryProvider.get().triggerUsage(model)
}
diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt
index 49b49cc..9035a0b 100644
--- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt
+++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt
@@ -24,6 +24,7 @@ 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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -33,6 +34,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
+import com.rainy.token.R
import com.rainy.token.data.repository.CommandCodeUsageRepository
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.rememberWindowSizeClass
@@ -368,8 +370,8 @@ private fun ExpandedDetailPane(
is DetailPane.Empty -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text("👈 选择服务查看详情", style = MaterialTheme.typography.titleMedium, color = inkMuted())
- Text("点击左侧卡片即可", style = MaterialTheme.typography.bodySmall, color = inkMuted())
+ Text(stringResource(R.string.nav_empty_title), style = MaterialTheme.typography.titleMedium, color = inkMuted())
+ Text(stringResource(R.string.nav_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
}
diff --git a/app/src/main/java/com/rainy/token/ui/components/AppTips.kt b/app/src/main/java/com/rainy/token/ui/components/AppTips.kt
index a688fb8..403d715 100644
--- a/app/src/main/java/com/rainy/token/ui/components/AppTips.kt
+++ b/app/src/main/java/com/rainy/token/ui/components/AppTips.kt
@@ -28,44 +28,50 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import androidx.annotation.StringRes
+import com.rainy.token.R
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
/**
* APP 使用小技巧列表。
* Dashboard 卡片上方随机展示一条,设置页可查看全部。
+ *
+ * 文案全部改为字符串资源 ID(@StringRes),由 UI 层按当前语言环境解析,
+ * 保证英文系统下自动显示英文翻译。
*/
object AppTips {
data class Tip(
- val title: String,
- val hint: String,
- val detail: String
+ @StringRes val titleRes: Int,
+ @StringRes val hintRes: Int,
+ @StringRes val detailRes: Int
)
val tips: List = listOf(
- Tip("长按卡片拖拽排序", "长按首页卡片可以拖拽排序", "在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。"),
- Tip("小组件点左上角进 APP", "小组件左上角标识可打开 APP", "桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。"),
- Tip("图表点击查看详情", "点击图表柱子可查看时段详情", "在用量图表上点击或滑动手指,可查看对应时段的详细数值。"),
- Tip("图表标题可点击", "点击图表标题弹出按模型明细", "图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。"),
- Tip("切换图表时区", "用量详情页可切换 UTC+0 / UTC+8", "用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。"),
- Tip("CCGO 清除并重新同步", "CCGO 详情页可清除缓存重新同步", "CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。"),
- Tip("查看用量详情", "服务卡片底部可进入用量图表", "有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。"),
- Tip("原始数据浏览", "用量详情页右上角可查看原始记录", "用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。"),
- Tip("下拉刷新联动用量", "下拉刷新会同步余额和用量", "首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。"),
- Tip("凭据获取教程", "凭据页底部有获取教程", "凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。"),
- Tip("剪贴板自动导入", "Codex 支持从剪贴板一键导入", "Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。"),
- Tip("详细数据页码跳转", "原始数据页可输入页码直跳", "原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。"),
- Tip("小组件自动刷新", "划到小组件会自动刷新余额", "桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。"),
+ Tip(R.string.tip_1_title, R.string.tip_1_hint, R.string.tip_1_detail),
+ Tip(R.string.tip_2_title, R.string.tip_2_hint, R.string.tip_2_detail),
+ Tip(R.string.tip_3_title, R.string.tip_3_hint, R.string.tip_3_detail),
+ Tip(R.string.tip_4_title, R.string.tip_4_hint, R.string.tip_4_detail),
+ Tip(R.string.tip_5_title, R.string.tip_5_hint, R.string.tip_5_detail),
+ Tip(R.string.tip_6_title, R.string.tip_6_hint, R.string.tip_6_detail),
+ Tip(R.string.tip_7_title, R.string.tip_7_hint, R.string.tip_7_detail),
+ Tip(R.string.tip_8_title, R.string.tip_8_hint, R.string.tip_8_detail),
+ Tip(R.string.tip_9_title, R.string.tip_9_hint, R.string.tip_9_detail),
+ Tip(R.string.tip_10_title, R.string.tip_10_hint, R.string.tip_10_detail),
+ Tip(R.string.tip_11_title, R.string.tip_11_hint, R.string.tip_11_detail),
+ Tip(R.string.tip_12_title, R.string.tip_12_hint, R.string.tip_12_detail),
+ Tip(R.string.tip_13_title, R.string.tip_13_hint, R.string.tip_13_detail),
)
- /** 首页轮换用的一句话文案。 */
- val shortHints: List = tips.map { it.hint }
+ /** 首页轮换用的一句话文案(资源 ID 形式)。 */
+ val shortHintRes: List = tips.map { it.hintRes }
- /** 随机取一条短文案。 */
- fun randomHint(): String = shortHints.random()
+ /** 随机取一条短文案的资源 ID。 */
+ fun randomHintRes(): Int = shortHintRes.random()
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -77,7 +83,7 @@ fun TipsScreen(onBack: () -> Unit) {
TopAppBar(
title = {
Text(
- "使用小技巧",
+ stringResource(R.string.title_tips),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
@@ -86,7 +92,7 @@ fun TipsScreen(onBack: () -> Unit) {
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
- contentDescription = "返回",
+ contentDescription = stringResource(R.string.action_back),
tint = StrawberryPink
)
}
@@ -123,14 +129,14 @@ fun TipsScreen(onBack: () -> Unit) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = tip.title,
+ text = stringResource(tip.titleRes),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
- text = tip.detail,
+ text = stringResource(tip.detailRes),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
diff --git a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt
index 2a180b2..15d7cb8 100644
--- a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt
@@ -31,10 +31,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
@@ -50,7 +52,7 @@ fun DebugLogScreen(onBack: () -> Unit) {
TopAppBar(
title = {
Text(
- "调试日志",
+ stringResource(R.string.title_debug_log),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
@@ -59,14 +61,14 @@ fun DebugLogScreen(onBack: () -> Unit) {
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
- contentDescription = "返回",
+ contentDescription = stringResource(R.string.action_back),
tint = StrawberryPink
)
}
},
actions = {
TextButton(onClick = { DebugLog.clear() }) {
- Text("清空", color = StrawberryPink)
+ Text(stringResource(R.string.action_clear), color = StrawberryPink)
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -83,7 +85,7 @@ fun DebugLogScreen(onBack: () -> Unit) {
contentAlignment = Alignment.Center
) {
Text(
- "暂无日志记录\n刷新 Codex 用量后这里会出现调试信息",
+ stringResource(R.string.debug_log_empty),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted,
modifier = Modifier.padding(32.dp)
diff --git a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt
index 5e98ff9..992156a 100644
--- a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt
+++ b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt
@@ -6,42 +6,63 @@ import java.util.Locale
* 共享格式化工具函数。
*
* 从 DashboardScreen / ServiceDetailScreen / WidgetProvider 提取,消除三处重复。
+ *
+ * 本地化策略:函数保持纯 JVM(不依赖 Context / Resources,可被单元测试直接调用),
+ * 本地化标签通过 [DurationText] / 字符串参数注入。调用方(Compose / Widget)用
+ * `context.getString(...)` 取得当前语言标签后传入;测试继续使用默认中文标签。
*/
-/** 格式化金额:整数无小数点,带小数保留 2 位 */
+/** 时长标签(用于 "X 天 Y 小时" 这类拼接)。 */
+data class DurationText(
+ val day: String,
+ val hour: String,
+ val minute: String,
+ /** 无有效时长时展示的占位符 */
+ val dash: String = "—"
+)
+
+/** 默认中文标签(保持与原实现输出完全一致,供单元测试与兜底使用)。 */
+val ChineseDurationText = DurationText(day = "天", hour = "小时", minute = "分")
+
+/** 格式化金额:整数无小数点,带小数保留 2 位(金额数值格式固定 Locale.US,避免小数点歧义) */
fun formatAmount(value: Double): String {
return if (value % 1.0 == 0.0) value.toInt().toString()
else String.format(Locale.US, "%.2f", value)
}
/** 格式化重置倒计时(秒 → "X 天 Y 小时" / "X 小时 Y 分" / "Y 分"),无效返回 "—" */
-fun formatResetInSec(sec: Long): String {
- if (sec <= 0) return "—"
+fun formatResetInSec(sec: Long, text: DurationText = ChineseDurationText): String {
+ if (sec <= 0) return text.dash
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 分"
+ days > 0 -> "$days ${text.day} $hours ${text.hour}"
+ hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}"
+ else -> "$minutes ${text.minute}"
}
}
/** Widget 版本:逻辑相同但空值返回空串(非 "—"),适配 RemoteViews 布局 */
-fun formatResetForWidget(sec: Long): String {
+fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): 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 分"
+ days > 0 -> "$days ${text.day} $hours ${text.hour}"
+ hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}"
+ else -> "$minutes ${text.minute}"
}
}
-/** 标准化窗口标签:weekly → 每周,其他原样返回 */
-fun normalizeWindowLabel(label: String): String = when (label.lowercase()) {
- "weekly" -> "每周"
+/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,其他原样返回 */
+fun normalizeWindowLabel(
+ label: String,
+ weeklyLabel: String = "每周",
+ monthlyLabel: String = "每月"
+): String = when (label.lowercase()) {
+ "weekly", "每周" -> weeklyLabel
+ "monthly", "每月" -> monthlyLabel
else -> label
}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt b/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt
index a0ed758..4ae9658 100644
--- a/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt
+++ b/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,6 +17,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.rainy.token.ui.theme.StatusBlue
import com.rainy.token.ui.theme.StatusGreen
@@ -33,7 +35,7 @@ import com.rainy.token.ui.theme.StatusRed
enum class StatusLevel { OK, WARNING, ERROR, INFO }
data class StatusStyle(
- val label: String,
+ @StringRes val labelRes: Int,
val level: StatusLevel
)
@@ -64,7 +66,7 @@ fun StatusChip(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
- text = style.label,
+ text = stringResource(style.labelRes),
style = MaterialTheme.typography.labelSmall,
color = textColor
)
diff --git a/app/src/main/java/com/rainy/token/ui/components/UiText.kt b/app/src/main/java/com/rainy/token/ui/components/UiText.kt
new file mode 100644
index 0000000..339e47b
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/ui/components/UiText.kt
@@ -0,0 +1,44 @@
+package com.rainy.token.ui.components
+
+import android.content.Context
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+
+/**
+ * 可本地化的文本抽象。
+ *
+ * ViewModel / Repository 层不直接持有本地化字符串,而是返回 [UiText]:
+ * - [Resource]:引用字符串资源(可带格式化参数),由 UI 层按当前语言环境解析
+ * - [Dynamic]:服务端返回的动态文本(未知内容,无法预翻译),原样透传
+ *
+ * 调试日志保持使用原始 [Throwable.message](Repository 中文消息仅用于排查,不用于 UI 展示)。
+ */
+sealed interface UiText {
+
+ data class Resource(
+ @StringRes val resId: Int,
+ val args: List = emptyList()
+ ) : UiText
+
+ data class Dynamic(val value: String) : UiText
+}
+
+/** 在非 Composable 上下文(协程/回调/事件)中把 [UiText] 解析为字符串(嵌套 [UiText] 参数会递归扁平化)。 */
+fun UiText.resolve(context: Context): String = when (this) {
+ is UiText.Resource -> {
+ val flatArgs = args.map { arg -> if (arg is UiText) arg.resolve(context) else arg }
+ context.getString(resId, *flatArgs.toTypedArray())
+ }
+ is UiText.Dynamic -> value
+}
+
+/** 在 Composable 上下文中把 [UiText] 解析为当前语言环境的字符串(嵌套 [UiText] 参数会递归扁平化)。 */
+@Composable
+fun UiText.asString(): String = when (this) {
+ is UiText.Resource -> {
+ val flatArgs = args.map { arg -> if (arg is UiText) arg.asString() else arg }
+ stringResource(resId, *flatArgs.toTypedArray())
+ }
+ is UiText.Dynamic -> value
+}
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt
index 2fd6a5b..0c9ee7d 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt
@@ -24,10 +24,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
+import com.rainy.token.ui.components.asString
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
@@ -68,12 +71,12 @@ fun CommandCodeUsageStatsCard(
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
- text = "CommandCode 用量",
+ text = stringResource(R.string.usage_title_commandcode),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
- text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步",
+ text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
@@ -88,7 +91,7 @@ fun CommandCodeUsageStatsCard(
IconButton(onClick = { viewModel.sync() }) {
Icon(
imageVector = Icons.Filled.Refresh,
- contentDescription = "同步用量数据",
+ contentDescription = stringResource(R.string.action_sync_usage),
tint = StrawberryPink
)
}
@@ -101,13 +104,13 @@ fun CommandCodeUsageStatsCard(
// 输入 Token = input + cacheRead
val inputTotal = overview.inputTokens + overview.cacheReadTokens
- MetricRow("输入 Token", formatTokenCount(inputTotal))
+ MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
// 缓存子行
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
val parts = mutableListOf()
- if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}")
- if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}")
+ if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens)))
+ if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens)))
Text(
text = parts.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@@ -120,8 +123,8 @@ fun CommandCodeUsageStatsCard(
// 输出 Token + 总花费 同一排
Row(Modifier.fillMaxWidth()) {
- MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f))
- MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
+ MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f))
+ MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
}
}
@@ -129,7 +132,7 @@ fun CommandCodeUsageStatsCard(
if (uiState.overview == null && !uiState.syncing) {
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步",
+ text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
@@ -139,7 +142,7 @@ fun CommandCodeUsageStatsCard(
if (uiState.overview != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onOpenDetail) {
- Text("查看详情", color = StrawberryPink)
+ Text(stringResource(R.string.action_view_detail), color = StrawberryPink)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
@@ -154,7 +157,7 @@ fun CommandCodeUsageStatsCard(
if (uiState.lastSyncResult > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "✓ 新增 ${uiState.lastSyncResult} 条",
+ text = stringResource(R.string.usage_new_records, uiState.lastSyncResult),
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink
)
@@ -162,7 +165,7 @@ fun CommandCodeUsageStatsCard(
uiState.lastSyncError?.let { err ->
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "✗ $err",
+ text = "✗ ${err.asString()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
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 0cdc65c..dcc2e5c 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
@@ -67,6 +67,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -77,6 +78,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
@@ -85,6 +87,7 @@ import com.rainy.token.ui.components.StatusChip
import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.StatusStyle
import com.rainy.token.ui.components.AppTips
+import com.rainy.token.ui.components.asString
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import android.app.AppOpsManager
@@ -161,8 +164,8 @@ fun DashboardScreen(
if (showAddWidgetConfirm) {
AlertDialog(
onDismissRequest = { showAddWidgetConfirm = false },
- title = { Text("添加桌面小组件?") },
- text = { Text("确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。") },
+ title = { Text(stringResource(R.string.dialog_add_widget_title)) },
+ text = { Text(stringResource(R.string.dialog_add_widget_body)) },
confirmButton = {
TextButton(
onClick = {
@@ -170,12 +173,12 @@ fun DashboardScreen(
requestRainyTokenWidgetPin(context)
}
) {
- Text("确认添加", color = StrawberryPink)
+ Text(stringResource(R.string.action_confirm_add), color = StrawberryPink)
}
},
dismissButton = {
TextButton(onClick = { showAddWidgetConfirm = false }) {
- Text("取消")
+ Text(stringResource(R.string.action_cancel))
}
}
)
@@ -193,7 +196,7 @@ fun DashboardScreen(
fontWeight = FontWeight.Bold
)
Text(
- text = "AI 余额一览",
+ text = stringResource(R.string.dashboard_subtitle),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -203,7 +206,7 @@ fun DashboardScreen(
IconButton(onClick = { showAddWidgetConfirm = true }) {
Icon(
imageVector = Icons.Filled.Add,
- contentDescription = "添加小组件到桌面",
+ contentDescription = stringResource(R.string.action_add_widget),
tint = StrawberryPink
)
}
@@ -213,14 +216,14 @@ fun DashboardScreen(
) {
Icon(
imageVector = Icons.Filled.Refresh,
- contentDescription = "刷新",
+ contentDescription = stringResource(R.string.action_refresh),
tint = StrawberryPink
)
}
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Filled.Settings,
- contentDescription = "设置",
+ contentDescription = stringResource(R.string.action_settings),
tint = StrawberryPink
)
}
@@ -263,9 +266,9 @@ fun DashboardScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 随机小技巧提示(每次启动随机一条)
- val tipText = remember { AppTips.randomHint() }
+ val tipTextRes = remember { AppTips.randomHintRes() }
Text(
- text = "💡 $tipText",
+ text = "💡 " + stringResource(tipTextRes),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(horizontal = 4.dp)
@@ -314,13 +317,13 @@ fun DashboardScreen(
verticalAlignment = Alignment.CenterVertically
) {
Text(
- text = "💡 长按卡片可拖拽排序",
+ text = stringResource(R.string.dashboard_drag_hint),
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink,
modifier = Modifier.weight(1f)
)
Text(
- text = "知道了",
+ text = stringResource(R.string.action_got_it),
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink,
fontWeight = FontWeight.SemiBold
@@ -639,7 +642,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag
fontWeight = FontWeight.SemiBold
)
Text(
- text = secondaryLine(card),
+ text = stringResource(secondaryLineRes(card)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -656,7 +659,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag
if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onOpenUsageDetail) {
- Text("查看用量详情", color = StrawberryPink)
+ Text(stringResource(R.string.action_view_usage_detail), color = StrawberryPink)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
@@ -670,7 +673,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag
// ─── 底部:更新时间 / 错误信息 ───
Spacer(modifier = Modifier.height(12.dp))
Text(
- text = footerText(card),
+ text = footerText(card).asString(),
style = MaterialTheme.typography.bodySmall,
color = if (card.lastFetchError != null)
MaterialTheme.colorScheme.error
@@ -694,13 +697,13 @@ private fun DashboardFooter() {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
- text = "RainyToken · v1.0",
+ text = stringResource(R.string.dashboard_footer_version),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "本地加密存储,不上传任何数据",
+ text = stringResource(R.string.dashboard_footer_privacy),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -726,14 +729,14 @@ private fun requestRainyTokenWidgetPin(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
if (!canInstallLauncherShortcut(context)) {
- Toast.makeText(context, "桌面快捷方式权限已被拒绝,请在系统设置中允许后重试", Toast.LENGTH_LONG).show()
+ Toast.makeText(context, context.getString(R.string.toast_shortcut_permission_denied), Toast.LENGTH_LONG).show()
return
}
if (appWidgetManager.isRequestPinAppWidgetSupported) {
val requested = appWidgetManager.requestPinAppWidget(component, null, null)
if (!requested) {
- Toast.makeText(context, "桌面未接受添加请求,请检查启动器权限", Toast.LENGTH_LONG).show()
+ Toast.makeText(context, context.getString(R.string.toast_add_rejected), Toast.LENGTH_LONG).show()
}
return
}
@@ -745,6 +748,6 @@ private fun requestRainyTokenWidgetPin(context: Context) {
if (canOpenPicker) {
context.startActivity(pickerIntent)
} else {
- Toast.makeText(context, "当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token", Toast.LENGTH_LONG).show()
+ Toast.makeText(context, context.getString(R.string.toast_picker_unsupported), Toast.LENGTH_LONG).show()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
index fa6489b..530ac1b 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
@@ -10,6 +10,8 @@ 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.domain.usecase.RefreshBalanceUseCase
+import com.rainy.token.R
+import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -127,10 +129,10 @@ class DashboardViewModel @Inject constructor(
val cards = ServiceType.entries.map { type ->
val result = results[type]
val error = result?.exceptionOrNull()
- val errMsg = error
+ val errorUi = error
?.takeUnless { it is RepositoryError.CredentialChanged }
- ?.message
- buildCard(localStates.getValue(type), lastFetchError = errMsg)
+ ?.let { errorToUiText(it) }
+ buildCard(localStates.getValue(type), lastFetchError = errorUi)
}
_uiState.update { it.copy(refreshing = false, cards = cards) }
// 刷新成功后更新桌面小组件
@@ -145,7 +147,7 @@ class DashboardViewModel @Inject constructor(
private fun buildCard(
local: CredentialRepository.LocalState,
- lastFetchError: String?
+ lastFetchError: UiText?
): DashboardCardUi = DashboardCardUi(
service = local.status.service,
credentialState = local.status.state,
@@ -181,16 +183,37 @@ data class DashboardCardUi(
val credentialState: CredentialStatus.State,
val credentialFingerprint: String?,
val cachedBalance: CachedBalance?,
- val lastFetchError: String?
+ val lastFetchError: UiText?
) {
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
-
- /** 卡片顶部状态徽章。 */
- val statusBadge: String get() = when {
- credentialState == CredentialStatus.State.NOT_CONFIGURED -> "未配置"
- lastFetchError != null -> "刷新失败"
- cachedBalance == null -> "未获取"
- else -> "正常"
- }
+}
+
+/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
+private fun errorToUiText(error: Throwable): UiText = when (error) {
+ is RepositoryError.InvalidCredential ->
+ UiText.Resource(R.string.error_credential_invalid_reconfigure)
+ is RepositoryError.RateLimited -> UiText.Resource(
+ R.string.error_rate_limited_retry,
+ listOf(
+ error.retryAfterSeconds?.let {
+ UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it))
+ } ?: ""
+ )
+ )
+ is RepositoryError.Network -> UiText.Resource(R.string.error_network_check)
+ is RepositoryError.ServerError ->
+ UiText.Resource(R.string.error_server_http, listOf(error.code))
+ is RepositoryError.ParseError -> when (error.reason) {
+ RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
+ RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
+ RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
+ RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
+ RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
+ RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
+ }
+ // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案
+ is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
+ else -> error.message?.let { UiText.Dynamic(it) }
+ ?: UiText.Resource(R.string.common_unknown)
}
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
index b8b69ae..b29099e 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt
@@ -19,11 +19,16 @@ 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 androidx.compose.ui.res.stringResource
+import androidx.annotation.StringRes
+import com.rainy.token.R
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.components.DurationText
+import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.components.formatAmount
import com.rainy.token.ui.components.formatResetInSec
import com.rainy.token.ui.components.normalizeWindowLabel
@@ -47,7 +52,7 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
color = inkMuted()
)
Text(
- text = "点击配置凭据",
+ text = stringResource(R.string.service_click_to_configure),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -59,7 +64,7 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
color = inkMuted()
)
Text(
- text = "下拉刷新",
+ text = stringResource(R.string.service_pull_to_refresh),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -102,14 +107,14 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
}
if (!balance.isAvailable) {
Text(
- text = "服务当前不可用",
+ text = stringResource(R.string.service_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
balance.monthlySpent?.let { spent ->
Text(
- text = "本月已用 ${formatAmount(spent)}${balance.unit}",
+ text = stringResource(R.string.service_monthly_used, formatAmount(spent), balance.unit),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -136,7 +141,7 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "5h 用量",
+ text = stringResource(R.string.window_5h_usage),
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
@@ -147,9 +152,9 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
@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())
+ Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()),
+ Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()),
+ Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull())
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
windows.forEach { (label, pct, resetSec) ->
@@ -181,7 +186,7 @@ internal fun CommandCodeGoMainBalance(balance: ServiceBalance) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "剩余",
+ text = stringResource(R.string.service_remaining),
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
@@ -189,7 +194,7 @@ internal fun CommandCodeGoMainBalance(balance: ServiceBalance) {
if (total != null && total > 0) {
val used = total - balance.amount
Text(
- text = " · 已用 ${formatAmount(used)} / 共 ${formatAmount(total)}",
+ text = stringResource(R.string.service_used_total, formatAmount(used), formatAmount(total)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
@@ -206,9 +211,9 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) {
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())
+ Triple(stringResource(R.string.window_5h), calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()),
+ Triple(stringResource(R.string.window_weekly), calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()),
+ Triple(stringResource(R.string.window_monthly), calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull())
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
windows.forEach { (label, pct, resetSec) ->
@@ -226,7 +231,12 @@ 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() } }
} ?: "—"
- val primaryLabel = formatCodexPrimaryLabel(balance.extras["primary.label"])
+ val primaryLabel = formatCodexPrimaryLabel(
+ balance.extras["primary.label"],
+ weeklyLabel = stringResource(R.string.window_every_week),
+ monthlyLabel = stringResource(R.string.window_every_month),
+ usageLabel = stringResource(R.string.window_usage)
+ )
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
@@ -243,14 +253,14 @@ internal fun CodexMainBalance(balance: ServiceBalance) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "$primaryLabel 已用",
+ text = stringResource(R.string.service_used_label, primaryLabel),
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "· $plan",
+ text = stringResource(R.string.service_plan_suffix, plan),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
@@ -258,14 +268,22 @@ internal fun CodexMainBalance(balance: ServiceBalance) {
}
}
-/** 把 Codex API 返回的 primary label 转成中文短标签 */
-internal fun formatCodexPrimaryLabel(raw: String?): String = when (raw?.lowercase()) {
+/**
+ * 把 Codex API 返回的 primary label 转成本地化短标签。
+ * 默认中文标签保持测试兼容;UI 调用方传入 stringResource 结果。
+ */
+internal fun formatCodexPrimaryLabel(
+ raw: String?,
+ weeklyLabel: String = "每周",
+ monthlyLabel: String = "每月",
+ usageLabel: String = "用量"
+): String = when (raw?.lowercase()) {
"5h" -> "5h"
- "7d", "每周" -> "每周"
- "30d", "每月" -> "每月"
- "usage" -> "用量"
+ "7d", "每周", weeklyLabel -> weeklyLabel
+ "30d", "每月", monthlyLabel -> monthlyLabel
+ "usage" -> usageLabel
null -> "5h"
- else -> raw
+ else -> raw ?: "5h"
}
@Composable
@@ -276,14 +294,21 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
.distinct()
.maxOrNull()?.plus(1) ?: 0
+ val weeklyLabel = stringResource(R.string.window_every_week)
+ val monthlyLabel = stringResource(R.string.window_every_month)
val windows = (0 until windowCount).map { i ->
- val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage")
+ val label = normalizeWindowLabel(
+ extras["window_$i.label"] ?: "Usage",
+ weeklyLabel = weeklyLabel,
+ monthlyLabel = monthlyLabel
+ )
val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
Triple(label, remainingPct, resetAt)
}
// 判断是否有 5h 窗口(用于保留空槽位)
+ val fiveHourLabel = stringResource(R.string.window_5h)
val has5h = windows.any { it.first.contains("5") && it.first.contains("小时") }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -292,7 +317,7 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
} else {
// 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充)
if (!has5h) {
- CompactUsageRowEmpty(label = "5 小时", resetInSec = null)
+ CompactUsageRowEmpty(label = fiveHourLabel, resetInSec = null)
}
windows.forEach { (label, remainingPct, resetAt) ->
if (remainingPct != null) {
@@ -327,14 +352,14 @@ internal fun OllamaMainBalance(balance: ServiceBalance) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "5h 已用",
+ text = stringResource(R.string.service_5h_used),
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "· $plan",
+ text = stringResource(R.string.service_plan_suffix, plan),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
@@ -349,25 +374,26 @@ internal fun OllamaUsageWindows(balance: ServiceBalance) {
val weeklyPct = extras["weekly.pct"]?.toFloatOrNull()
val sessionResetAt = extras["session.resetAt"]?.toLongOrNull()
val weeklyResetAt = extras["weekly.resetAt"]?.toLongOrNull()
+ val weeklyLabel = stringResource(R.string.window_every_week)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (sessionPct != null) {
CompactUsageRow(
- label = "5h",
+ label = stringResource(R.string.window_5h_short),
pct = sessionPct.toInt().coerceIn(0, 100),
resetInSec = sessionResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
)
} else {
- CompactUsageRowEmpty(label = "5h", resetInSec = null)
+ CompactUsageRowEmpty(label = stringResource(R.string.window_5h_short), resetInSec = null)
}
if (weeklyPct != null) {
CompactUsageRow(
- label = "每周",
+ label = weeklyLabel,
pct = weeklyPct.toInt().coerceIn(0, 100),
resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
)
} else {
- CompactUsageRowEmpty(label = "每周", resetInSec = null)
+ CompactUsageRowEmpty(label = weeklyLabel, resetInSec = null)
}
}
}
@@ -376,6 +402,7 @@ internal fun OllamaUsageWindows(balance: ServiceBalance) {
@Composable
internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
+ val durationText = rememberDurationText()
Column {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -407,7 +434,7 @@ internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
if (resetInSec != null && resetInSec > 0) {
Spacer(modifier = Modifier.height(2.dp))
Text(
- text = "${formatResetInSec(resetInSec)}后重置",
+ text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -417,6 +444,7 @@ internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
@Composable
internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
+ val durationText = rememberDurationText()
val pctValue = pct.coerceIn(0, 100).toFloat()
Column {
Row(
@@ -458,7 +486,7 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
if (resetInSec != null && resetInSec > 0) {
Spacer(modifier = Modifier.height(2.dp))
Text(
- text = "${formatResetInSec(resetInSec)}后重置",
+ text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -466,45 +494,68 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
}
}
+/** 当前语言环境的时长标签("天 / 小时 / 分")。 */
+@Composable
+private fun rememberDurationText(): DurationText = DurationText(
+ day = stringResource(R.string.format_day),
+ hour = stringResource(R.string.format_hour),
+ minute = stringResource(R.string.format_minute)
+)
+
// ── Dashboard utility functions ──
internal fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when {
credentialState == CredentialStatus.State.NOT_CONFIGURED ->
- StatusStyle("未配置", StatusLevel.WARNING)
+ StatusStyle(R.string.status_not_configured, StatusLevel.WARNING)
lastFetchError != null ->
- StatusStyle("刷新失败", StatusLevel.ERROR)
+ StatusStyle(R.string.status_refresh_failed, StatusLevel.ERROR)
credentialState == CredentialStatus.State.EXPIRED ->
- StatusStyle("已过期", StatusLevel.ERROR)
+ StatusStyle(R.string.status_expired, StatusLevel.ERROR)
credentialState == CredentialStatus.State.WARNING ->
- StatusStyle("需重登", StatusLevel.WARNING)
+ StatusStyle(R.string.status_relogin, StatusLevel.WARNING)
cachedBalance == null ->
- StatusStyle("待获取", StatusLevel.INFO)
+ StatusStyle(R.string.status_waiting, StatusLevel.INFO)
else ->
- StatusStyle("正常", StatusLevel.OK)
+ StatusStyle(R.string.status_normal, 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 配额"
+/** 服务副标题(资源 ID,由 UI 层解析)。 */
+@StringRes
+internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) {
+ ServiceType.DEEPSEEK -> R.string.service_desc_deepseek
+ ServiceType.OPENCODE_GO -> R.string.service_desc_opencode_go
+ ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go
+ ServiceType.CODEX -> R.string.service_desc_codex
+ ServiceType.OLLAMA -> R.string.service_desc_ollama
}
-internal fun footerText(card: DashboardCardUi): String {
- if (card.lastFetchError != null) {
- val msg = card.lastFetchError.take(60)
- return "⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}"
+/**
+ * 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。
+ * 错误信息由 ViewModel 按 RepositoryError 类型映射为本地化资源。
+ */
+
+/** 截断 UiText 到最长 60 字符(仅对 Dynamic 原始文本生效;Resource 长度由资源控制)。 */
+private fun truncateUiText(text: UiText): UiText = when (text) {
+ is UiText.Dynamic -> UiText.Dynamic(text.value.take(60) + if (text.value.length > 60) "…" else "")
+ else -> text
+}
+
+internal fun footerText(card: DashboardCardUi): UiText {
+ val error = card.lastFetchError
+ if (error != null) {
+ // 截断保护:错误信息最长展示 60 字符(以 UiText 形式保留,UI 层按当前语言解析)
+ return UiText.Resource(R.string.dashboard_error_prefix, listOf(truncateUiText(error)))
}
- val fetchedAt = card.cachedBalance?.fetchedAt ?: return "从未获取"
+ val fetchedAt = card.cachedBalance?.fetchedAt
+ ?: return UiText.Resource(R.string.footer_never_fetched)
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 更新"
+ diffMin < 1 -> UiText.Resource(R.string.footer_just_updated)
+ diffMin < 60 -> UiText.Resource(R.string.footer_minutes_ago, listOf(diffMin))
+ diffMin < 1440 -> UiText.Resource(R.string.footer_hours_ago, listOf(diffMin / 60))
+ else -> UiText.Resource(R.string.footer_updated_at, listOf(timeStr))
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt
index 9119ebb..a1c92b8 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt
@@ -53,12 +53,15 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.data.local.ChartBucket
import com.rainy.token.data.local.ChartGranularity
import com.rainy.token.ui.theme.InkMuted
@@ -100,6 +103,7 @@ fun UsageChartScreen(
viewModel: UsageChartViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
var granularityExpanded by remember { mutableStateOf(false) }
var modelExpanded by remember { mutableStateOf(false) }
var showCustomDayPicker by remember { mutableStateOf(false) }
@@ -115,9 +119,9 @@ fun UsageChartScreen(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
- title = { Text("统计图表") },
+ title = { Text(stringResource(R.string.title_chart)) },
navigationIcon = {
- IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") }
+ IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) }
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
@@ -125,7 +129,7 @@ fun UsageChartScreen(
) { innerPadding ->
if (state.loading) {
Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
- Text("加载中…", color = InkMuted)
+ Text(stringResource(R.string.common_loading), color = InkMuted)
}
} else {
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
@@ -146,13 +150,13 @@ fun UsageChartScreen(
// 粒度下拉
Box {
TextButton(onClick = { granularityExpanded = true }) {
- Text(state.granularity.label, color = StrawberryPink)
+ Text(stringResource(state.granularity.labelRes), color = StrawberryPink)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
ChartGranularity.entries.forEach { g ->
DropdownMenuItem(
- text = { Text(g.label) },
+ text = { Text(stringResource(g.labelRes)) },
onClick = {
granularityExpanded = false
when (g) {
@@ -169,15 +173,15 @@ fun UsageChartScreen(
Spacer(Modifier.weight(1f))
// 模型多选下拉
Box {
- val modelLabel = if (state.selectedModels.isEmpty()) "全部模型"
- else "${state.selectedModels.size} 个模型"
+ val modelLabel = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models)
+ else stringResource(R.string.model_count, state.selectedModels.size)
TextButton(onClick = { modelExpanded = true }) {
Text(modelLabel, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelExpanded, { modelExpanded = false }) {
DropdownMenuItem(
- text = { Text("全部模型", fontWeight = FontWeight.Bold) },
+ text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
onClick = { modelExpanded = false; viewModel.selectAllModels() }
)
state.allModels.forEach { model ->
@@ -210,7 +214,7 @@ fun UsageChartScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.weight(1f)) {
- ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
+ ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
@@ -235,12 +239,12 @@ fun UsageChartScreen(
}
}
Box(modifier = Modifier.weight(1f)) {
- ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
+ ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
LineChart(
buckets = state.buckets,
valueSelector = { it.totalRequests.toFloat() },
lineColor = StrawberryPink,
- formatValue = { "${it.toInt()}次" },
+ formatValue = { context.getString(R.string.count_times, it.toInt()) },
granularity = state.granularity,
useUtc8 = state.useUtc8
)
@@ -251,7 +255,7 @@ fun UsageChartScreen(
// 第三张图表独立一行
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
- ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
+ ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
@@ -262,7 +266,7 @@ fun UsageChartScreen(
bucket.cacheHitTokens.toDouble() to tokenColors[0]
)
},
- stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
+ stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
@@ -271,9 +275,9 @@ fun UsageChartScreen(
}
ChartLegend(
items = listOf(
- "输入(未命中)" to tokenColors[1],
- "命中缓存" to tokenColors[0],
- "输出" to tokenColors[2]
+ stringResource(R.string.chart_stack_input) to tokenColors[1],
+ stringResource(R.string.chart_stack_cache) to tokenColors[0],
+ stringResource(R.string.chart_stack_output) to tokenColors[2]
)
)
}
@@ -283,7 +287,7 @@ fun UsageChartScreen(
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard(
- title = "消耗金额 (USD)",
+ title = stringResource(R.string.chart_cost),
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
onSummaryClick = { showCostDetail = true }
) {
@@ -314,15 +318,15 @@ fun UsageChartScreen(
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard(
- title = "API 请求次数",
- summary = "${reqTotal}次",
+ title = stringResource(R.string.chart_requests),
+ summary = stringResource(R.string.count_times, reqTotal),
onSummaryClick = { showReqDetail = true }
) {
LineChart(
buckets = state.buckets,
valueSelector = { it.totalRequests.toFloat() },
lineColor = StrawberryPink,
- formatValue = { "${it.toInt()}次" },
+ formatValue = { context.getString(R.string.count_times, it.toInt()) },
granularity = state.granularity,
useUtc8 = state.useUtc8
)
@@ -332,7 +336,7 @@ fun UsageChartScreen(
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard(
- title = "Token 消耗",
+ title = stringResource(R.string.chart_tokens),
summary = formatTokenComma(tokTotal),
onSummaryClick = { showTokenDetail = true }
) {
@@ -346,7 +350,7 @@ fun UsageChartScreen(
bucket.cacheHitTokens.toDouble() to tokenColors[0]
)
},
- stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
+ stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
@@ -355,9 +359,9 @@ fun UsageChartScreen(
}
ChartLegend(
items = listOf(
- "输入(未命中)" to tokenColors[1],
- "命中缓存" to tokenColors[0],
- "输出" to tokenColors[2]
+ stringResource(R.string.chart_stack_input) to tokenColors[1],
+ stringResource(R.string.chart_stack_cache) to tokenColors[0],
+ stringResource(R.string.chart_stack_output) to tokenColors[2]
)
)
}
@@ -371,7 +375,7 @@ fun UsageChartScreen(
// ─── 自定义日期选择器 ───
if (showCustomDayPicker) {
DateTimePickerDialog(
- title = "选择日期",
+ title = stringResource(R.string.date_select_day),
onConfirm = { ms ->
viewModel.setCustomDay(ms.toUtcLocalDate())
showCustomDayPicker = false
@@ -381,7 +385,7 @@ fun UsageChartScreen(
}
if (showCustomMonthPicker) {
DateTimePickerDialog(
- title = "选择月份(任意一天)",
+ title = stringResource(R.string.date_select_month),
onConfirm = { ms ->
viewModel.setCustomMonth(ms.toUtcLocalDate())
showCustomMonthPicker = false
@@ -391,7 +395,7 @@ fun UsageChartScreen(
}
if (showCustomRangeStart) {
DateTimePickerDialog(
- title = "开始日期",
+ title = stringResource(R.string.date_pick_start),
onConfirm = { ms ->
customRangeStartDate = ms.toUtcLocalDate()
showCustomRangeStart = false
@@ -402,7 +406,7 @@ fun UsageChartScreen(
}
if (showCustomRangeEnd) {
DateTimePickerDialog(
- title = "结束日期",
+ title = stringResource(R.string.date_pick_end),
onConfirm = { ms ->
val startDate = customRangeStartDate ?: return@DateTimePickerDialog
val endDate = ms.toUtcLocalDate()
@@ -420,7 +424,7 @@ fun UsageChartScreen(
if (showCostDetail) {
AlertDialog(
onDismissRequest = { showCostDetail = false },
- title = { Text("消费明细", fontWeight = FontWeight.Bold) },
+ title = { Text(stringResource(R.string.chart_detail_cost), fontWeight = FontWeight.Bold) },
text = {
Column {
models.forEach { model ->
@@ -435,13 +439,13 @@ fun UsageChartScreen(
}
}
},
- confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text("关闭") } }
+ confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text(stringResource(R.string.action_close)) } }
)
}
if (showReqDetail) {
AlertDialog(
onDismissRequest = { showReqDetail = false },
- title = { Text("调用次数明细", fontWeight = FontWeight.Bold) },
+ title = { Text(stringResource(R.string.chart_detail_requests), fontWeight = FontWeight.Bold) },
text = {
Column {
models.forEach { model ->
@@ -449,13 +453,13 @@ fun UsageChartScreen(
if (total > 0) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(model, style = MaterialTheme.typography.bodySmall)
- Text("${total}次", color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
+ Text(stringResource(R.string.count_times, total), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
}
}
}
}
},
- confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text("关闭") } }
+ confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text(stringResource(R.string.action_close)) } }
)
}
if (showTokenDetail) {
@@ -464,24 +468,24 @@ fun UsageChartScreen(
val outTotal = state.buckets.sumOf { it.outputTokens }
AlertDialog(
onDismissRequest = { showTokenDetail = false },
- title = { Text("Token 明细", fontWeight = FontWeight.Bold) },
+ title = { Text(stringResource(R.string.chart_detail_tokens), fontWeight = FontWeight.Bold) },
text = {
Column {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
- Text("命中缓存", style = MaterialTheme.typography.bodySmall)
+ Text(stringResource(R.string.chart_stack_cache), style = MaterialTheme.typography.bodySmall)
Text(formatTokenComma(hitTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
}
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
- Text("输入(未命中)", style = MaterialTheme.typography.bodySmall)
+ Text(stringResource(R.string.chart_stack_input), style = MaterialTheme.typography.bodySmall)
Text(formatTokenComma(missTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
}
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
- Text("输出", style = MaterialTheme.typography.bodySmall)
+ Text(stringResource(R.string.chart_stack_output), style = MaterialTheme.typography.bodySmall)
Text(formatTokenComma(outTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
}
}
},
- confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text("关闭") } }
+ confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text(stringResource(R.string.action_close)) } }
)
}
}
@@ -544,7 +548,7 @@ internal fun StackedBarChart(
useUtc8: Boolean = false
) {
if (buckets.isEmpty()) {
- Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
+ Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
return
}
var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
@@ -639,7 +643,7 @@ internal fun StackedBarChart(
) {
Column(Modifier.padding(8.dp)) {
Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
- Text("合计: ${formatValue(total)}", style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
+ Text(stringResource(R.string.chart_total_label, formatValue(total)), style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
val labels = stackLabels(bucket)
val items = stackSelector(bucket).withIndex()
val display = if (tooltipReversed) items.toList().reversed() else items.toList()
@@ -674,7 +678,7 @@ internal fun LineChart(
useUtc8: Boolean = false
) {
if (buckets.isEmpty()) {
- Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
+ Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
return
}
var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt
index ffae0e9..93b1f5f 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt
@@ -46,12 +46,14 @@ 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.data.local.UsageRecord
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
@@ -93,8 +95,8 @@ fun UsageDataScreen(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
- title = { Text("详细数据") },
- navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } },
+ title = { Text(stringResource(R.string.title_raw_data)) },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
@@ -105,7 +107,7 @@ fun UsageDataScreen(
// 时间筛选
Box {
TextButton(onClick = { timeMenuExpanded = true }) {
- Text(state.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
+ Text(stringResource(state.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(timeMenuExpanded, { timeMenuExpanded = false }) {
@@ -114,17 +116,17 @@ fun UsageDataScreen(
TimeFilter.Today, TimeFilter.Yesterday,
TimeFilter.Last7Days, TimeFilter.Last30Days, TimeFilter.ThisMonth
).forEach { f ->
- DropdownMenuItem(text = { Text(f.label) }, onClick = {
+ DropdownMenuItem(text = { Text(stringResource(f.labelRes)) }, onClick = {
timeMenuExpanded = false; viewModel.setTimeFilter(f)
})
}
- DropdownMenuItem(text = { Text("自定义天") }, onClick = {
+ DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_day)) }, onClick = {
timeMenuExpanded = false; showCustomDayPicker = true
})
- DropdownMenuItem(text = { Text("自定义月") }, onClick = {
+ DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_month)) }, onClick = {
timeMenuExpanded = false; showCustomMonthPicker = true
})
- DropdownMenuItem(text = { Text("自定义时间") }, onClick = {
+ DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = {
timeMenuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L))
})
}
@@ -133,13 +135,13 @@ fun UsageDataScreen(
Spacer(Modifier.weight(1f))
// 模型筛选
Box {
- val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个"
+ val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count_short, state.selectedModels.size)
TextButton(onClick = { modelMenuExpanded = true }) {
Text(label, color = StrawberryPink, fontSize = 13.sp)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelMenuExpanded, { modelMenuExpanded = false }) {
- DropdownMenuItem(text = { Text("全部模型", fontWeight = FontWeight.Bold) },
+ DropdownMenuItem(text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
onClick = { modelMenuExpanded = false; viewModel.selectAllModels() })
state.allModels.forEach { m ->
DropdownMenuItem(text = {
@@ -158,12 +160,12 @@ fun UsageDataScreen(
CustomTimeRangeRow(customStartMs, customEndMs,
{ showStartPicker = true }, { showEndPicker = true },
{ viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) })
- Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted())
+ Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
// ─── 记录数 + 分页 ───
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) {
- Text("${state.totalRecords} 条记录", style = MaterialTheme.typography.bodySmall, color = inkMuted())
+ Text(stringResource(R.string.usage_record_count, state.totalRecords), style = MaterialTheme.typography.bodySmall, color = inkMuted())
if (state.totalPages > 1) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { viewModel.prevPage() }, enabled = state.currentPage > 1) {
@@ -188,7 +190,7 @@ fun UsageDataScreen(
val p = pageInput.toIntOrNull()
if (p != null) { viewModel.goToPage(p); pageInput = "" }
}) {
- Text("跳转", color = StrawberryPink, fontSize = 12.sp)
+ Text(stringResource(R.string.action_jump), color = StrawberryPink, fontSize = 12.sp)
}
}
}
@@ -198,22 +200,22 @@ fun UsageDataScreen(
Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
- HeaderCell("时间", Modifier.weight(1.5f), false)
- HeaderCell("模型", Modifier.weight(1.8f), false)
- HeaderCell("输入(缓存)", Modifier.weight(1.5f), false)
- HeaderCell("输出", Modifier.weight(1.2f), false)
- HeaderCell("成本", Modifier.weight(1.0f), false)
+ HeaderCell(stringResource(R.string.header_time), Modifier.weight(1.5f), false)
+ HeaderCell(stringResource(R.string.header_model), Modifier.weight(1.8f), false)
+ HeaderCell(stringResource(R.string.header_input_cache), Modifier.weight(1.5f), false)
+ HeaderCell(stringResource(R.string.header_output), Modifier.weight(1.2f), false)
+ HeaderCell(stringResource(R.string.header_cost), Modifier.weight(1.0f), false)
}
}
// ─── 记录列表 ───
if (state.loading) {
Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
- Text("加载中…", color = inkMuted())
+ Text(stringResource(R.string.common_loading), color = inkMuted())
}
} else if (state.records.isEmpty()) {
Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
- Text("暂无数据", color = inkMuted())
+ Text(stringResource(R.string.common_no_data), color = inkMuted())
}
} else {
LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) {
@@ -226,18 +228,18 @@ fun UsageDataScreen(
}
// 日期选择器
- if (showCustomDayPicker) DateTimePickerDialog("选择日期", { ms ->
+ if (showCustomDayPicker) DateTimePickerDialog(stringResource(R.string.date_select_day), { ms ->
val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1)); showCustomDayPicker = false
}, { showCustomDayPicker = false })
- if (showCustomMonthPicker) DateTimePickerDialog("选择月份(任意一天)", { ms ->
+ if (showCustomMonthPicker) DateTimePickerDialog(stringResource(R.string.date_select_month), { ms ->
val utc = ZoneOffset.UTC; val ld = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate()
val msStart = ld.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
val msEnd = ld.withDayOfMonth(ld.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1
viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd)); showCustomMonthPicker = false
}, { showCustomMonthPicker = false })
- if (showStartPicker) DateTimePickerDialog("开始时间", { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
- if (showEndPicker) DateTimePickerDialog("结束时间", { ms ->
+ if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
+ if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { ms ->
viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, ms)); showEndPicker = false
}, { showEndPicker = false })
@@ -245,26 +247,26 @@ fun UsageDataScreen(
rawRecord?.let { record ->
AlertDialog(
onDismissRequest = { rawRecord = null },
- title = { Text("原始数据", fontWeight = FontWeight.Bold) },
+ title = { Text(stringResource(R.string.raw_record_title), fontWeight = FontWeight.Bold) },
text = {
Column(Modifier.horizontalScroll(rememberScrollState())) {
RawField("ID", record.id)
- RawField("时间", formatUtcTime(record.timeCreated))
- RawField("模型", record.model)
+ RawField(stringResource(R.string.header_time), formatUtcTime(record.timeCreated))
+ RawField(stringResource(R.string.header_model), record.model)
RawField("Provider", record.provider)
- RawField("输入 Token", "%,d".format(Locale.US, record.inputTokens))
- RawField("输出 Token", "%,d".format(Locale.US, record.outputTokens))
- RawField("推理 Token", "%,d".format(Locale.US, record.reasoningTokens))
- RawField("缓存读取", "%,d".format(Locale.US, record.cacheReadTokens))
- RawField("缓存写入(5m)", "%,d".format(Locale.US, record.cacheWrite5mTokens))
- RawField("缓存写入(1h)", "%,d".format(Locale.US, record.cacheWrite1hTokens))
- RawField("成本(USD)", "$${String.format(Locale.US, "%.6f", record.costUsd)}")
- RawField("成本(Raw)", "${record.cost}")
+ RawField(stringResource(R.string.usage_input_tokens), "%,d".format(Locale.US, record.inputTokens))
+ RawField(stringResource(R.string.usage_output_tokens), "%,d".format(Locale.US, record.outputTokens))
+ RawField(stringResource(R.string.usage_reasoning_tokens), "%,d".format(Locale.US, record.reasoningTokens))
+ RawField(stringResource(R.string.stat_cache_read), "%,d".format(Locale.US, record.cacheReadTokens))
+ RawField(stringResource(R.string.stat_cache_write_5m), "%,d".format(Locale.US, record.cacheWrite5mTokens))
+ RawField(stringResource(R.string.stat_cache_write_1h), "%,d".format(Locale.US, record.cacheWrite1hTokens))
+ RawField(stringResource(R.string.raw_cost_usd), "$${String.format(Locale.US, "%.6f", record.costUsd)}")
+ RawField(stringResource(R.string.raw_cost_raw), "${record.cost}")
RawField("KeyId", record.keyId)
RawField("SessionId", record.sessionId)
}
},
- confirmButton = { TextButton(onClick = { rawRecord = null }) { Text("关闭") } }
+ confirmButton = { TextButton(onClick = { rawRecord = null }) { Text(stringResource(R.string.action_close)) } }
)
}
}
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt
index 3cf3526..df2a2cd 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt
@@ -50,11 +50,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.data.local.ChartGranularity
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
@@ -80,6 +83,7 @@ fun UsageDetailScreen(
clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮
) {
val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
// OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load)
LaunchedEffect(Unit) {
@@ -106,16 +110,16 @@ fun UsageDetailScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
- title = { Text("用量详情") },
- navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } },
+ title = { Text(stringResource(R.string.title_usage_detail)) },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } },
actions = {
if (clearViewModel != null) {
TextButton(onClick = { showClearDialog = true }) {
- Text("清除", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
+ Text(stringResource(R.string.action_clear), color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
}
TextButton(onClick = onOpenData) {
- Text("详细数据", color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
+ Text(stringResource(R.string.action_view_raw_data), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
@@ -124,7 +128,7 @@ fun UsageDetailScreen(
) { innerPadding ->
if (state.loading) {
Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
- Text("加载中…", color = InkMuted)
+ Text(stringResource(R.string.common_loading), color = InkMuted)
}
} else {
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
@@ -139,12 +143,12 @@ fun UsageDetailScreen(
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
Box {
TextButton(onClick = { granularityExpanded = true }) {
- Text(state.granularity.label, color = StrawberryPink)
+ Text(stringResource(state.granularity.labelRes), color = StrawberryPink)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
ChartGranularity.entries.forEach { g ->
- DropdownMenuItem(text = { Text(g.label) }, onClick = {
+ DropdownMenuItem(text = { Text(stringResource(g.labelRes)) }, onClick = {
granularityExpanded = false
when (g) {
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
@@ -160,7 +164,7 @@ fun UsageDetailScreen(
viewModel.toggleUtc8()
scope.launch {
snackbarHostState.showSnackbar(
- if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示",
+ if (state.useUtc8) context.getString(R.string.msg_utc8_on) else context.getString(R.string.msg_utc0_on),
duration = SnackbarDuration.Short
)
}
@@ -172,7 +176,7 @@ fun UsageDetailScreen(
fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal
)
Text(
- " 点击切换",
+ stringResource(R.string.usage_click_to_switch),
style = MaterialTheme.typography.bodySmall,
color = InkMuted.copy(alpha = 0.5f),
fontWeight = FontWeight.Normal
@@ -180,14 +184,14 @@ fun UsageDetailScreen(
}
Spacer(Modifier.weight(1f))
Box {
- val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
+ val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count, state.selectedModels.size)
TextButton(onClick = { modelExpanded = true }) {
Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelExpanded, { modelExpanded = false }) {
DropdownMenuItem(
- text = { Text("全部模型", fontWeight = FontWeight.Bold) },
+ text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
onClick = { modelExpanded = false; viewModel.selectAllModels() })
state.allModels.forEach { model ->
DropdownMenuItem(text = {
@@ -213,7 +217,7 @@ fun UsageDetailScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.weight(1f)) {
- ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
+ ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(state.buckets,
{ it.totalCost.toDouble() / 100_000_000.0 },
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
@@ -228,33 +232,33 @@ fun UsageDetailScreen(
}
}
Box(modifier = Modifier.weight(1f)) {
- ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
- LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8)
+ ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
+ LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8)
}
}
}
}
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
- ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
+ ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(state.buckets,
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
- { listOf("输出", "输入(未命中)", "命中缓存") },
+ { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
- ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
+ ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2]))
}
} else {
// 窄面板:三张图表纵向堆叠
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
- ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
+ ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(state.buckets,
{ it.totalCost.toDouble() / 100_000_000.0 },
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
@@ -270,29 +274,29 @@ fun UsageDetailScreen(
}
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
- ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
- LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8)
+ ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
+ LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8)
}
}
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
- ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
+ ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(state.buckets,
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
- { listOf("输出", "输入(未命中)", "命中缓存") },
+ { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
- ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
+ ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2]))
}
}
item {
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
- Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold)
+ Text("📋 " + stringResource(R.string.title_overview), color = StrawberryPink, fontWeight = FontWeight.Bold)
}
}
item { Spacer(Modifier.height(32.dp)) }
@@ -300,15 +304,15 @@ fun UsageDetailScreen(
}
}
// 自定义日期选择器
- if (showCustomDayPicker) DateOnlyPickerDialog("选择日期", { date ->
+ if (showCustomDayPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_day), { date ->
viewModel.setCustomDay(date)
showCustomDayPicker = false
}, { showCustomDayPicker = false })
- if (showCustomMonthPicker) DateOnlyPickerDialog("选择月份(任意一天)", { date ->
+ if (showCustomMonthPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_month), { date ->
viewModel.setCustomMonth(date)
showCustomMonthPicker = false
}, { showCustomMonthPicker = false })
- if (showCustomRangeStart) DateOnlyPickerDialog("开始日期", { date ->
+ if (showCustomRangeStart) DateOnlyPickerDialog(stringResource(R.string.date_pick_start), { date ->
customRangeStartDate = date
showCustomRangeStart = false
showCustomRangeEnd = true
@@ -316,7 +320,7 @@ fun UsageDetailScreen(
if (showCustomRangeEnd) {
val startDate = customRangeStartDate
DateOnlyPickerDialog(
- title = "结束日期",
+ title = stringResource(R.string.date_pick_end),
onConfirm = { endDate ->
if (startDate != null && !endDate.isBefore(startDate)) {
viewModel.setCustomRange(startDate, endDate)
@@ -328,27 +332,27 @@ fun UsageDetailScreen(
)
}
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
- if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = false }) {
+ if (showCostDetail) ChartDetailDialog(stringResource(R.string.chart_detail_cost), { showCostDetail = false }) {
models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.cost ?:0L }; if (t>0) DetailRow(model, "$${String.format(Locale.US, "%.4f", t/100_000_000.0)}") }
}
- if (showReqDetail) ChartDetailDialog("调用次数明细", { showReqDetail = false }) {
- models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.requests ?:0 }; if (t>0) DetailRow(model, "${t}次") }
+ if (showReqDetail) ChartDetailDialog(stringResource(R.string.chart_detail_requests), { showReqDetail = false }) {
+ models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.requests ?: 0 }; if (t>0) DetailRow(model, stringResource(R.string.count_times, t)) }
}
- if (showTokenDetail) ChartDetailDialog("Token 明细", { showTokenDetail = false }) {
- DetailRow("命中缓存", formatTokenComma(state.buckets.sumOf { it.cacheHitTokens }))
- DetailRow("输入(未命中)", formatTokenComma(state.buckets.sumOf { it.inputTokens }))
- DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens }))
+ if (showTokenDetail) ChartDetailDialog(stringResource(R.string.chart_detail_tokens), { showTokenDetail = false }) {
+ DetailRow(stringResource(R.string.chart_stack_cache), formatTokenComma(state.buckets.sumOf { it.cacheHitTokens }))
+ DetailRow(stringResource(R.string.chart_stack_input), formatTokenComma(state.buckets.sumOf { it.inputTokens }))
+ DetailRow(stringResource(R.string.chart_stack_output), formatTokenComma(state.buckets.sumOf { it.outputTokens }))
}
if (showClearDialog && clearViewModel != null) {
val cd = clearCountdown
androidx.compose.material3.AlertDialog(
onDismissRequest = { showClearDialog = false; clearCountdown = 0 },
- title = { Text("⚠️ 清除数据", fontWeight = FontWeight.Bold) },
+ title = { Text("⚠️ " + stringResource(R.string.dialog_clear_title), fontWeight = FontWeight.Bold) },
text = {
Column {
- Text("此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。")
+ Text(stringResource(R.string.dialog_clear_body))
Spacer(Modifier.height(12.dp))
- Text("是否继续?", fontWeight = FontWeight.SemiBold)
+ Text(stringResource(R.string.dialog_clear_confirm_question), fontWeight = FontWeight.SemiBold)
}
},
confirmButton = {
@@ -362,14 +366,14 @@ fun UsageDetailScreen(
enabled = cd == 0
) {
Text(
- if (cd > 0) "确认(${cd}s)" else "确认清除",
+ if (cd > 0) stringResource(R.string.action_confirm_countdown, cd) else stringResource(R.string.action_confirm_clear),
color = if (cd == 0) MaterialTheme.colorScheme.error else InkMuted
)
}
},
dismissButton = {
TextButton(onClick = { showClearDialog = false; clearCountdown = 0 }) {
- Text("取消")
+ Text(stringResource(R.string.action_cancel))
}
}
)
@@ -391,7 +395,7 @@ private fun ChartDetailDialog(title: String, onDismiss: () -> Unit, content: @Co
onDismissRequest = onDismiss,
title = { Text(title, fontWeight = FontWeight.Bold) },
text = { androidx.compose.foundation.layout.Column { content() } },
- confirmButton = { TextButton(onClick = onDismiss) { Text("关闭") } })
+ confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_close)) } })
}
@Composable private fun DetailRow(label: String, value: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), Arrangement.SpaceBetween) {
@@ -421,14 +425,14 @@ internal fun CustomTimeRangeRow(
) {
TextButton(onClick = onPickStart, modifier = Modifier.weight(1f)) {
Text(
- text = if (startMs > 0) "从 ${fmt.format(Date(startMs))}" else "开始时间",
+ text = if (startMs > 0) stringResource(R.string.range_from, fmt.format(Date(startMs))) else stringResource(R.string.date_start),
style = MaterialTheme.typography.bodySmall,
color = if (startMs > 0) StrawberryPink else InkMuted
)
}
TextButton(onClick = onPickEnd, modifier = Modifier.weight(1f)) {
Text(
- text = if (endMs > 0) "至 ${fmt.format(Date(endMs))}" else "结束时间",
+ text = if (endMs > 0) stringResource(R.string.range_to, fmt.format(Date(endMs))) else stringResource(R.string.date_end),
style = MaterialTheme.typography.bodySmall,
color = if (endMs > 0) StrawberryPink else InkMuted
)
@@ -436,7 +440,7 @@ internal fun CustomTimeRangeRow(
}
if (startMs > 0 && endMs > 0) {
TextButton(onClick = onApply) {
- Text("应用自定义范围", color = StrawberryPink, fontWeight = FontWeight.Bold)
+ Text(stringResource(R.string.action_apply_custom_range), color = StrawberryPink, fontWeight = FontWeight.Bold)
}
}
}
@@ -471,10 +475,10 @@ private fun DateOnlyPickerDialog(
},
enabled = dateState.selectedDateMillis != null
) {
- Text("确定")
+ Text(stringResource(R.string.action_confirm))
}
},
- dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
) {
Column {
Text(
@@ -485,7 +489,7 @@ private fun DateOnlyPickerDialog(
)
if (minDate != null) {
Text(
- text = "结束日期不能早于开始日期 ${minDate.formatDateLabel()}",
+ text = stringResource(R.string.date_end_before_start, minDate.formatDateLabel()),
modifier = Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
@@ -525,20 +529,20 @@ internal fun DateTimePickerDialog(
onConfirm(epochMs)
}
}) {
- Text("确定")
+ Text(stringResource(R.string.action_confirm))
}
},
- dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
) {
Column {
DatePicker(state = dateState)
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "时间 ${
- String.format("%02d", timeState.hour)
- }:${
+ text = stringResource(
+ R.string.time_hm,
+ String.format("%02d", timeState.hour),
String.format("%02d", timeState.minute)
- }",
+ ),
modifier = Modifier.padding(horizontal = 24.dp),
style = MaterialTheme.typography.titleMedium
)
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt
index 0753e6a..463adda 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt
@@ -40,10 +40,13 @@ 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.platform.LocalConfiguration
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.data.local.ModelStats
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.theme.inkMuted
@@ -80,9 +83,9 @@ fun UsageOverviewScreen(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
- title = { Text("总统计") },
+ title = { Text(stringResource(R.string.title_overview)) },
navigationIcon = {
- IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") }
+ IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) }
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
@@ -96,7 +99,7 @@ fun UsageOverviewScreen(
item {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { menuExpanded = true }) {
- Text(uiState.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold)
+ Text(stringResource(uiState.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
Text("UTC+0", style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(start = 4.dp))
@@ -106,12 +109,12 @@ fun UsageOverviewScreen(
TimeFilter.Last7Days, TimeFilter.Last30Days, TimeFilter.ThisMonth
).forEach { filter ->
DropdownMenuItem(
- text = { Text(filter.label) },
+ text = { Text(stringResource(filter.labelRes)) },
onClick = { menuExpanded = false; viewModel.setTimeFilter(filter) }
)
}
DropdownMenuItem(
- text = { Text("自定义") },
+ text = { Text(stringResource(R.string.time_custom)) },
onClick = { menuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) }
)
}
@@ -122,7 +125,7 @@ fun UsageOverviewScreen(
CustomTimeRangeRow(customStartMs, customEndMs,
{ showStartPicker = true }, { showEndPicker = true },
{ viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) })
- Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted())
+ Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
// 总览
@@ -134,7 +137,7 @@ fun UsageOverviewScreen(
}
// 按模型
if (uiState.modelStats.isNotEmpty()) {
- item { SectionHeader("按模型统计") }
+ item { SectionHeader(stringResource(R.string.section_by_model)) }
items(uiState.modelStats, key = { it.model }) { ModelDetailRow(it) }
}
// 按天
@@ -144,14 +147,14 @@ fun UsageOverviewScreen(
val shown = uiState.dailyStats.drop(startIdx).take(DAILY_PAGE_SIZE)
item {
Row(verticalAlignment = Alignment.CenterVertically) {
- SectionHeader("按天统计(UTC+0)"); Spacer(Modifier.weight(1f))
+ SectionHeader(stringResource(R.string.section_by_day)); Spacer(Modifier.weight(1f))
Box {
TextButton(onClick = { modelMenuExpanded = true }) {
- Text(uiState.modelFilter ?: "全部模型", color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
+ Text(uiState.modelFilter ?: stringResource(R.string.common_all_models), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelMenuExpanded, { modelMenuExpanded = false }) {
- DropdownMenuItem(text = { Text("全部模型", fontWeight = FontWeight.Bold) },
+ DropdownMenuItem(text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
onClick = { modelMenuExpanded = false; viewModel.setModelFilter(null) })
uiState.modelStats.forEach { stat ->
DropdownMenuItem(text = { Text(stat.model) },
@@ -166,18 +169,18 @@ fun UsageOverviewScreen(
item {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
TextButton(onClick = { viewModel.prevDailyPage() }, enabled = uiState.dailyPage > 1) {
- Text("◀ 上一页", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) }
+ Text(stringResource(R.string.action_prev_page), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) }
Text("${uiState.dailyPage} / $totalPages", style = MaterialTheme.typography.bodySmall, color = inkMuted())
TextButton(onClick = { viewModel.nextDailyPage() }, enabled = uiState.dailyPage < totalPages) {
- Text("下一页 ▶", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) }
+ Text(stringResource(R.string.action_next_page), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) }
}
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
- if (showStartPicker) DateTimePickerDialog("开始时间", { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
- if (showEndPicker) DateTimePickerDialog("结束时间", { customEndMs = it; showEndPicker = false }, { showEndPicker = false })
+ if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
+ if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { customEndMs = it; showEndPicker = false }, { showEndPicker = false })
}
}
@@ -193,24 +196,24 @@ fun UsageOverviewScreen(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
- StatItem("输入 Token", formatTokenCount(inputTotal))
- StatItem("输出 Token", formatTokenCount(overview.outputTokens))
- StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
- StatItem("总计", formatTokenCount(overview.totalTokens))
- StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
- StatItem("记录数", "${overview.totalCount}")
+ StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
+ StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens))
+ StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens))
+ StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens))
+ StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
+ StatItem(stringResource(R.string.usage_records), "${overview.totalCount}")
}
} else {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
- StatItem("输入 Token", formatTokenCount(inputTotal))
- StatItem("输出 Token", formatTokenCount(overview.outputTokens))
- StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
+ StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
+ StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens))
+ StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens))
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
- StatItem("总计", formatTokenCount(overview.totalTokens))
- StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
- StatItem("记录数", "${overview.totalCount}")
+ StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens))
+ StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
+ StatItem(stringResource(R.string.usage_records), "${overview.totalCount}")
}
}
}
@@ -220,15 +223,15 @@ fun UsageOverviewScreen(
@Composable private fun CacheBreakdownCard(overview: OverviewStats) {
Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(16.dp)) {
- Text("缓存 Token 细分", style = MaterialTheme.typography.labelMedium, color = inkMuted())
+ Text(stringResource(R.string.usage_cache_breakdown), style = MaterialTheme.typography.labelMedium, color = inkMuted())
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
- StatItem("缓存读取", formatTokenCount(overview.cacheReadTokens))
- StatItem("缓存写入", formatTokenCount(overview.cacheWriteTokens))
+ StatItem(stringResource(R.string.stat_cache_read), formatTokenCount(overview.cacheReadTokens))
+ StatItem(stringResource(R.string.stat_cache_write), formatTokenCount(overview.cacheWriteTokens))
}
if (overview.cacheReadTokens > 0) {
Spacer(Modifier.height(4.dp))
- Text("※ 缓存读取已计入「输入 Token」", style = MaterialTheme.typography.bodySmall, color = inkMuted())
+ Text(stringResource(R.string.usage_cache_read_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
}
@@ -243,7 +246,7 @@ fun UsageOverviewScreen(
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stat.model, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
- Text("${stat.count} 次调用", style = MaterialTheme.typography.bodySmall, color = inkMuted())
+ Text(stringResource(R.string.usage_calls, stat.count), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
Column(horizontalAlignment = Alignment.End) {
Text(formatTokenCount(stat.totalTokens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = StrawberryPink)
@@ -254,13 +257,18 @@ fun UsageOverviewScreen(
}
@Composable private fun DailyDetailRow(day: com.rainy.token.data.local.DailyStats) {
- val utcFmt = remember { SimpleDateFormat("MM月dd日 EEEE", Locale.CHINA).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } }
+ val datePattern = stringResource(R.string.date_format_md)
+ // 从当前资源 configuration 取 locale(跟随应用内语言切换),
+ // 不能依赖 Locale.getDefault():LocaleManager 只覆写 activity Configuration,不改变 JVM 默认 locale
+ val configuration = LocalConfiguration.current
+ val activeLocale = remember(configuration) { configuration.locales[0] }
+ val utcFmt = remember(datePattern, activeLocale) { SimpleDateFormat(datePattern, activeLocale).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } }
Card(Modifier.fillMaxWidth(), RoundedCornerShape(12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(utcFmt.format(Date(day.dayTs)), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End) {
Text(formatTokenCount(day.totalTokens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = StrawberryPink)
- Text("${day.count} 次 · $${String.format(Locale.US, "%.4f", day.totalCost / 100_000_000.0)}", style = MaterialTheme.typography.bodySmall, color = inkMuted())
+ Text(stringResource(R.string.daily_count_cost, day.count, String.format(Locale.US, "%.4f", day.totalCost / 100_000_000.0)), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
}
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt
index fb266f8..cd088fc 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt
@@ -24,11 +24,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.data.local.OverviewStats
+import com.rainy.token.ui.components.asString
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.util.Locale
@@ -67,12 +70,12 @@ fun UsageStatsCard(
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
- text = "OpenCode 用量",
+ text = stringResource(R.string.usage_title_opencode),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
- text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步",
+ text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
@@ -87,7 +90,7 @@ fun UsageStatsCard(
IconButton(onClick = { viewModel.sync() }) {
Icon(
imageVector = Icons.Filled.Refresh,
- contentDescription = "同步用量数据",
+ contentDescription = stringResource(R.string.action_sync_usage),
tint = StrawberryPink
)
}
@@ -100,13 +103,13 @@ fun UsageStatsCard(
// 输入 Token = input + cacheRead
val inputTotal = overview.inputTokens + overview.cacheReadTokens
- MetricRow("输入 Token", formatTokenCount(inputTotal))
+ MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
// 缓存子行
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
val parts = mutableListOf()
- if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}")
- if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}")
+ if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens)))
+ if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens)))
Text(
text = parts.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@@ -119,8 +122,8 @@ fun UsageStatsCard(
// 输出 Token + 总花费 同一排
Row(Modifier.fillMaxWidth()) {
- MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f))
- MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
+ MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f))
+ MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
}
}
@@ -128,7 +131,7 @@ fun UsageStatsCard(
if (uiState.overview == null && !uiState.syncing) {
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步",
+ text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
@@ -138,7 +141,7 @@ fun UsageStatsCard(
if (uiState.overview != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onOpenDetail) {
- Text("查看详情", color = StrawberryPink)
+ Text(stringResource(R.string.action_view_detail), color = StrawberryPink)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
@@ -153,7 +156,7 @@ fun UsageStatsCard(
if (uiState.lastSyncResult > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "✓ 新增 ${uiState.lastSyncResult} 条",
+ text = stringResource(R.string.usage_new_records, uiState.lastSyncResult),
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink
)
@@ -161,7 +164,7 @@ fun UsageStatsCard(
uiState.lastSyncError?.let { err ->
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "✗ $err",
+ text = "✗ ${err.asString()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt
index a179941..aae4e15 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt
@@ -1,16 +1,21 @@
package com.rainy.token.ui.dashboard
+import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.rainy.token.R
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.data.local.ModelStats
import com.rainy.token.data.local.DailyStats
import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.repository.CredentialRepository
+import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase
+import com.rainy.token.domain.usecase.SyncError
import com.rainy.token.domain.usecase.SyncUsageUseCase
+import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -26,17 +31,17 @@ import javax.inject.Provider
internal const val DAILY_PAGE_SIZE = 5
-/** 时间筛选条件 */
-sealed class TimeFilter(val label: String) {
- data object All : TimeFilter("全部")
- data object Last5h : TimeFilter("最近5小时")
- data object Last24h : TimeFilter("最近24小时")
- data object Today : TimeFilter("今天")
- data object Yesterday : TimeFilter("昨天")
- data object Last7Days : TimeFilter("最近7天")
- data object Last30Days : TimeFilter("最近30天")
- data object ThisMonth : TimeFilter("当月")
- data class Custom(val from: Long, val to: Long) : TimeFilter("自定义")
+/** 时间筛选条件(label 用字符串资源 ID,UI 层按当前语言解析) */
+sealed class TimeFilter(@StringRes val labelRes: Int) {
+ data object All : TimeFilter(R.string.time_all)
+ data object Last5h : TimeFilter(R.string.time_last_5h)
+ data object Last24h : TimeFilter(R.string.time_last_24h)
+ data object Today : TimeFilter(R.string.time_today)
+ data object Yesterday : TimeFilter(R.string.time_yesterday)
+ data object Last7Days : TimeFilter(R.string.time_last_7d)
+ data object Last30Days : TimeFilter(R.string.time_last_30d)
+ data object ThisMonth : TimeFilter(R.string.time_this_month)
+ data class Custom(val from: Long, val to: Long) : TimeFilter(R.string.time_custom)
/** 计算筛选的起止 epoch 毫秒(from 含,to 含)。
* 返回 Pair(null, null) 表示不限制。 */
@@ -201,7 +206,7 @@ class UsageViewModel @Inject constructor(
it.copy(
syncing = false,
lastSyncResult = result.getOrNull()?.inserted ?: 0,
- lastSyncError = result.exceptionOrNull()?.message
+ lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }
)
}
}
@@ -222,7 +227,7 @@ class UsageViewModel @Inject constructor(
it.copy(
syncing = false,
lastSyncResult = result.getOrNull()?.inserted ?: 0,
- lastSyncError = result.exceptionOrNull()?.message
+ lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }
)
}
}
@@ -241,7 +246,7 @@ data class UsageUiState(
val dailyStats: List = emptyList(),
val recordCount: Int = 0,
val lastSyncResult: Int = 0,
- val lastSyncError: String? = null,
+ val lastSyncError: UiText? = null,
val timeFilter: TimeFilter = TimeFilter.All,
val dailyPage: Int = 1,
val modelFilter: String? = null // null = 全部模型
@@ -253,4 +258,35 @@ private data class LoadResult(
val modelStats: List,
val dailyStats: List,
val totalCount: Int
-)
\ No newline at end of file
+)
+
+/** 把同步错误映射为本地化文案:SyncError / RepositoryError 全部走资源,不透传中文 message。 */
+private fun syncErrorToUiText(error: Throwable): UiText = when (error) {
+ is SyncError.PartialSync ->
+ UiText.Resource(R.string.sync_partial, listOf(error.inserted, error.errors.size))
+ is RepositoryError.InvalidCredential ->
+ UiText.Resource(R.string.error_credential_invalid_reconfigure)
+ is RepositoryError.CredentialChanged ->
+ UiText.Resource(R.string.error_credential_changed_reload)
+ is RepositoryError.RateLimited -> UiText.Resource(
+ R.string.error_rate_limited_retry,
+ listOf(
+ error.retryAfterSeconds?.let {
+ UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it))
+ } ?: ""
+ )
+ )
+ is RepositoryError.Network -> UiText.Resource(R.string.error_network_check)
+ is RepositoryError.ServerError ->
+ UiText.Resource(R.string.error_server_http, listOf(error.code))
+ is RepositoryError.ParseError -> when (error.reason) {
+ RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
+ RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
+ RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
+ RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
+ RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
+ RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
+ }
+ is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
+ else -> UiText.Resource(R.string.common_unknown)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt
index b323d67..a9ee908 100644
--- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt
@@ -52,13 +52,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.domain.model.ServiceBalance
+import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.FetchMethod
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
@@ -66,6 +69,8 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip
import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.StatusStyle
+import com.rainy.token.ui.components.asString
+import com.rainy.token.ui.components.DurationText
import com.rainy.token.ui.components.formatAmount
import com.rainy.token.ui.components.formatResetInSec
import com.rainy.token.ui.theme.inkMuted
@@ -135,7 +140,7 @@ fun ServiceDetailScreen(
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
- contentDescription = "返回",
+ contentDescription = stringResource(R.string.action_back),
tint = StrawberryPink
)
}
@@ -177,7 +182,7 @@ fun ServiceDetailScreen(
// 错误信息(如有)
(uiState.state as? State.Error)?.let { err ->
- item { ErrorCard(message = err.message) }
+ item { ErrorCard(message = err.message.asString()) }
}
// 凭据状态条
@@ -213,15 +218,18 @@ fun ServiceDetailScreen(
// 响应弹窗(成功和失败都弹)
when (triggerState) {
is TriggerState.Success -> ResponseDialog(
- title = "✓ 用量已激活",
- responseBody = (triggerState as TriggerState.Success).responseBody,
+ title = stringResource(R.string.trigger_success_title),
+ responseBody = buildTriggerSummaryText((triggerState as TriggerState.Success).summary),
isError = false,
onDismiss = { viewModel.dismissTrigger() }
)
is TriggerState.Error -> ResponseDialog(
- title = "请求结果",
- responseBody = (triggerState as TriggerState.Error).let {
- "错误: ${it.message}" + (it.responseBody?.let { body -> "\n\n响应内容:\n$body" } ?: "")
+ title = stringResource(R.string.trigger_result_title),
+ responseBody = (triggerState as TriggerState.Error).let {
+ stringResource(R.string.trigger_error_prefix, it.message.asString()) +
+ (it.responseBody?.let { body ->
+ "\n\n" + stringResource(R.string.trigger_response_body, body)
+ } ?: "")
},
isError = true,
onDismiss = { viewModel.dismissTrigger() }
@@ -259,7 +267,10 @@ private fun CommandCodeGoUsageCard(state: State) {
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
- text = "用量窗口${if (planName.isNotBlank()) " · $planName" else ""}",
+ text = stringResource(
+ R.string.usage_window_title,
+ if (planName.isNotBlank()) " · $planName" else ""
+ ),
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
@@ -271,7 +282,7 @@ private fun CommandCodeGoUsageCard(state: State) {
if (fiveHourUsed != null && fiveHourCap != null && fiveHourCap > 0) {
val pct = ((fiveHourUsed / fiveHourCap) * 100).toFloat().coerceIn(0f, 100f)
UsageWindowRow(
- label = "5 小时滚动",
+ label = stringResource(R.string.window_5h_rolling),
pct = pct,
resetInSec = extras["fiveHour.resetInSec"]?.toLongOrNull()
)
@@ -286,7 +297,7 @@ private fun CommandCodeGoUsageCard(state: State) {
if (weeklyUsed != null && weeklyCap != null && weeklyCap > 0) {
val pct = ((weeklyUsed / weeklyCap) * 100).toFloat().coerceIn(0f, 100f)
UsageWindowRow(
- label = "本周",
+ label = stringResource(R.string.window_weekly),
pct = pct,
resetInSec = extras["weekly.resetInSec"]?.toLongOrNull()
)
@@ -300,19 +311,23 @@ private fun CommandCodeGoUsageCard(state: State) {
val used = monthlyTotal - monthlyRemaining
val pct = ((used / monthlyTotal) * 100).toFloat().coerceIn(0f, 100f)
UsageWindowRow(
- label = "本月",
+ label = stringResource(R.string.window_monthly),
pct = pct,
resetInSec = extras["billingPeriodEnd"]?.let { parseIsoDuration(it) }
)
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "已用 \$${formatAmount(used)} / 共 \$${formatAmount(monthlyTotal)}",
+ text = stringResource(
+ R.string.usage_spent_total,
+ formatAmount(used),
+ formatAmount(monthlyTotal)
+ ),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
} else {
Text(
- text = "剩余 \$${formatAmount(monthlyRemaining)}",
+ text = stringResource(R.string.usage_remaining, formatAmount(monthlyRemaining)),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
@@ -323,7 +338,7 @@ private fun CommandCodeGoUsageCard(state: State) {
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
- BreakdownRow("额外充值", purchased, "$")
+ BreakdownRow(stringResource(R.string.usage_extra_credits), purchased, "$")
}
}
}
@@ -365,13 +380,13 @@ private fun OpenCodeGoWindowsCard(state: State) {
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
- text = "用量窗口",
+ text = stringResource(R.string.usage_window_title_plain),
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
Spacer(modifier = Modifier.height(12.dp))
UsageWindowRow(
- label = "5 小时滚动",
+ label = stringResource(R.string.window_5h_rolling),
pct = extras["rolling.pct"]?.toFloatOrNull(),
resetInSec = extras["rolling.resetInSec"]?.toLongOrNull()
)
@@ -379,7 +394,7 @@ private fun OpenCodeGoWindowsCard(state: State) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(14.dp))
UsageWindowRow(
- label = "本周",
+ label = stringResource(R.string.window_weekly),
pct = extras["weekly.pct"]?.toFloatOrNull(),
resetInSec = extras["weekly.resetInSec"]?.toLongOrNull()
)
@@ -387,7 +402,7 @@ private fun OpenCodeGoWindowsCard(state: State) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(14.dp))
UsageWindowRow(
- label = "本月",
+ label = stringResource(R.string.window_monthly),
pct = extras["monthly.pct"]?.toFloatOrNull(),
resetInSec = extras["monthly.resetInSec"]?.toLongOrNull()
)
@@ -397,6 +412,11 @@ private fun OpenCodeGoWindowsCard(state: State) {
@Composable
private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) {
+ val durationText = DurationText(
+ day = stringResource(R.string.format_day),
+ hour = stringResource(R.string.format_hour),
+ minute = stringResource(R.string.format_minute)
+ )
val pctValue = (pct ?: 0f).coerceIn(0f, 100f)
Column {
Row(
@@ -440,7 +460,7 @@ private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decima
if (resetInSec != null && resetInSec > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "${formatResetInSec(resetInSec)}后重置",
+ text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -468,7 +488,10 @@ private fun CodexUsageCard(state: State) {
if (windows.isEmpty()) return
// 判断是否有 5h 窗口
- val has5h = windows.any { it.label.contains("5") && it.label.contains("小时") }
+ val has5h = windows.any {
+ it.rawLabel.equals("5h", ignoreCase = true) ||
+ (it.rawLabel.contains("5") && it.rawLabel.contains("小时"))
+ }
Card(
modifier = Modifier.fillMaxWidth(),
@@ -479,7 +502,7 @@ private fun CodexUsageCard(state: State) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
- text = "用量窗口",
+ text = stringResource(R.string.usage_window_title_plain),
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
@@ -498,7 +521,7 @@ private fun CodexUsageCard(state: State) {
// 没有 5h 窗口时,在顶部插入空槽位
if (!has5h) {
UsageWindowRow(
- label = "5 小时",
+ label = stringResource(R.string.window_5h),
pct = null,
resetInSec = null,
decimals = 2
@@ -518,7 +541,7 @@ private fun CodexUsageCard(state: State) {
(it - System.currentTimeMillis()) / 1000
}?.takeIf { it > 0 }
UsageWindowRow(
- label = window.label,
+ label = codexWindowLabel(window.rawLabel),
pct = window.usedPct,
resetInSec = resetInSec,
decimals = 2
@@ -529,7 +552,7 @@ private fun CodexUsageCard(state: State) {
}
private data class CodexWindow(
- val label: String,
+ val rawLabel: String,
val usedPct: Float,
val resetAt: Long?
)
@@ -542,16 +565,17 @@ private fun extractCodexWindows(extras: Map): List
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(formatCodexWindowLabel(rawLabel), usedPct, resetAt))
+ result.add(CodexWindow(rawLabel, usedPct, resetAt))
i++
}
return result
}
-private fun formatCodexWindowLabel(raw: String): String = when (raw.lowercase()) {
- "5h" -> "5 小时"
- "7d", "每周" -> "本周"
- "30d", "每月" -> "本月"
+@Composable
+private fun codexWindowLabel(raw: String): String = when (raw.lowercase()) {
+ "5h" -> stringResource(R.string.window_5h)
+ "7d", "每周" -> stringResource(R.string.window_weekly)
+ "30d", "每月" -> stringResource(R.string.window_monthly)
else -> raw
}
@@ -581,7 +605,7 @@ private fun OllamaUsageCard(state: State) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
- text = "用量窗口",
+ text = stringResource(R.string.usage_window_title_plain),
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
@@ -595,7 +619,7 @@ private fun OllamaUsageCard(state: State) {
}
Spacer(modifier = Modifier.height(12.dp))
UsageWindowRow(
- label = "5 小时",
+ label = stringResource(R.string.window_5h),
pct = extras["session.pct"]?.toFloatOrNull(),
resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 },
decimals = 2
@@ -608,7 +632,7 @@ private fun OllamaUsageCard(state: State) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(14.dp))
UsageWindowRow(
- label = "每周",
+ label = stringResource(R.string.window_every_week),
pct = extras["weekly.pct"]?.toFloatOrNull(),
resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 },
decimals = 2
@@ -645,7 +669,7 @@ private fun parseModelRequests(raw: String?): List> {
private fun ModelRequestList(models: List>) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
- text = "模型调用次数",
+ text = stringResource(R.string.msg_model_call_count),
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
@@ -702,7 +726,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
BalanceBigNumber(state.data)
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "缓存数据 · ${formatTime(state.lastFetchedAt)}",
+ text = stringResource(R.string.msg_stale_cache, formatTime(state.lastFetchedAt)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
@@ -711,7 +735,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
BalanceBigNumber(state.cached)
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "显示的是上次成功获取的余额",
+ text = stringResource(R.string.msg_showing_last_balance),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
@@ -756,7 +780,7 @@ private fun BalanceBigNumber(balance: ServiceBalance?) {
if (!balance.isAvailable) {
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "服务当前不可用",
+ text = stringResource(R.string.service_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
@@ -764,7 +788,7 @@ private fun BalanceBigNumber(balance: ServiceBalance?) {
balance.monthlySpent?.let { spent ->
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "本月消费 ${formatAmount(spent)}${balance.unit}",
+ text = stringResource(R.string.msg_monthly_spent, formatAmount(spent), balance.unit),
style = MaterialTheme.typography.bodyMedium,
color = inkMuted()
)
@@ -795,16 +819,16 @@ private fun DeepSeekBreakdownCard(state: State) {
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
- text = "余额构成",
+ text = stringResource(R.string.msg_balance_breakdown),
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
Spacer(modifier = Modifier.height(12.dp))
- BreakdownRow("赠送余额", granted, balance!!.unit)
+ BreakdownRow(stringResource(R.string.msg_granted_balance), granted, balance!!.unit)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
- BreakdownRow("自费充值", toppedUp, balance.unit)
+ BreakdownRow(stringResource(R.string.msg_topped_up_balance), toppedUp, balance.unit)
}
}
}
@@ -850,7 +874,7 @@ private fun ErrorCard(message: String) {
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
- text = "刷新失败",
+ text = stringResource(R.string.status_refresh_failed),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.error
@@ -868,9 +892,9 @@ private fun ErrorCard(message: String) {
@Composable
private fun CredentialStatusRow(hasCredential: Boolean, isManualMode: Boolean) {
val style = when {
- isManualMode -> StatusStyle("手动输入模式", StatusLevel.INFO)
- hasCredential -> StatusStyle("凭据已配置", StatusLevel.OK)
- else -> StatusStyle("未配置凭据", StatusLevel.ERROR)
+ isManualMode -> StatusStyle(R.string.status_manual_mode, StatusLevel.INFO)
+ hasCredential -> StatusStyle(R.string.status_credential_configured, StatusLevel.OK)
+ else -> StatusStyle(R.string.status_not_configured_cred, StatusLevel.ERROR)
}
StatusChip(style = style)
}
@@ -908,7 +932,11 @@ private fun ActionButtons(
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
- Text("加载模型列表...", style = MaterialTheme.typography.bodySmall, color = inkMuted())
+ Text(
+ stringResource(R.string.msg_loading_models),
+ style = MaterialTheme.typography.bodySmall,
+ color = inkMuted()
+ )
}
} else if (models.isNotEmpty()) {
Row(
@@ -916,7 +944,7 @@ private fun ActionButtons(
verticalAlignment = Alignment.CenterVertically
) {
Text(
- text = "选择模型",
+ text = stringResource(R.string.msg_select_model),
style = MaterialTheme.typography.labelMedium,
color = inkMuted()
)
@@ -927,7 +955,7 @@ private fun ActionButtons(
) {
Icon(
imageVector = Icons.Filled.Refresh,
- contentDescription = "刷新模型列表",
+ contentDescription = stringResource(R.string.action_refresh_models),
tint = StrawberryPink,
modifier = Modifier.size(18.dp)
)
@@ -959,7 +987,7 @@ private fun ActionButtons(
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
- Text("激活中...")
+ Text(stringResource(R.string.msg_triggering))
}
}
is TriggerState.Error -> {
@@ -971,10 +999,10 @@ private fun ActionButtons(
contentColor = MaterialTheme.colorScheme.error
)
) {
- Text("重试激活用量")
+ Text(stringResource(R.string.action_retry_trigger))
}
Text(
- text = triggerState.message,
+ text = triggerState.message.asString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 4.dp)
@@ -986,7 +1014,7 @@ private fun ActionButtons(
modifier = Modifier.fillMaxWidth(),
enabled = state !is State.Loading && selectedModel != null
) {
- Text("⚡ 一键激活用量")
+ Text(stringResource(R.string.action_trigger_usage))
}
}
is TriggerState.Success -> {
@@ -1008,13 +1036,13 @@ private fun ActionButtons(
) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
- Text("刷新余额")
+ Text(stringResource(R.string.action_refresh_balance))
}
OutlinedButton(
onClick = onConfigureCredential,
modifier = Modifier.fillMaxWidth()
) {
- Text("重新登录")
+ Text(stringResource(R.string.action_relogin))
}
} else if (!hasCredential) {
Button(
@@ -1025,26 +1053,27 @@ private fun ActionButtons(
contentColor = Color.White
)
) {
- Text("配置凭据")
+ Text(stringResource(R.string.action_configure_credential))
}
}
}
}
+@Composable
private fun mainCardLabel(service: ServiceType): String = when (service) {
- ServiceType.DEEPSEEK -> "当前余额"
- ServiceType.OPENCODE_GO -> "5h 实时用量"
- ServiceType.COMMANDCODE_GO -> "月度余额"
- ServiceType.CODEX -> "用量"
- ServiceType.OLLAMA -> "Session 用量"
+ ServiceType.DEEPSEEK -> stringResource(R.string.main_card_current_balance)
+ ServiceType.OPENCODE_GO -> stringResource(R.string.main_card_5h_usage)
+ ServiceType.COMMANDCODE_GO -> stringResource(R.string.main_card_monthly_balance)
+ ServiceType.CODEX -> stringResource(R.string.window_usage)
+ ServiceType.OLLAMA -> stringResource(R.string.main_card_session_usage)
}
private fun stateToChip(state: State): StatusStyle = when (state) {
- is State.Loading -> StatusStyle("加载中", StatusLevel.INFO)
- is State.Fresh -> StatusStyle("最新", StatusLevel.OK)
- is State.Stale -> StatusStyle("缓存", StatusLevel.WARNING)
- is State.Error -> StatusStyle("失败", StatusLevel.ERROR)
- is State.ManualModeHint -> StatusStyle("待输入", StatusLevel.WARNING)
+ is State.Loading -> StatusStyle(R.string.status_loading, StatusLevel.INFO)
+ is State.Fresh -> StatusStyle(R.string.status_fresh, StatusLevel.OK)
+ is State.Stale -> StatusStyle(R.string.status_stale, StatusLevel.WARNING)
+ is State.Error -> StatusStyle(R.string.status_failed, StatusLevel.ERROR)
+ is State.ManualModeHint -> StatusStyle(R.string.status_pending_input, StatusLevel.WARNING)
}
private fun formatTime(epochMillis: Long): String {
@@ -1062,7 +1091,7 @@ private fun ModelDropdown(
onSelect: (String) -> Unit
) {
var expanded by remember { androidx.compose.runtime.mutableStateOf(false) }
- val display = selectedModel ?: "请选择模型"
+ val display = selectedModel ?: stringResource(R.string.msg_please_select_model)
ExposedDropdownMenuBox(
expanded = expanded,
@@ -1094,6 +1123,43 @@ private fun ModelDropdown(
// ── Codex 一键激活用量:响应弹窗 ──
+/** 把结构化的激活摘要组装为本地化文本(Repository 不再拼接展示文案)。 */
+@Composable
+private fun buildTriggerSummaryText(summary: TriggerSummary): String = buildString {
+ appendLine(stringResource(R.string.trigger_success_header))
+ appendLine(stringResource(R.string.trigger_success_model, summary.model))
+ summary.responseId?.let { appendLine(stringResource(R.string.trigger_success_response_id, it)) }
+ appendLine()
+ appendLine(stringResource(R.string.trigger_reply_header))
+ if (summary.parseFailed) {
+ appendLine(stringResource(R.string.trigger_parse_failed))
+ } else {
+ appendLine(
+ summary.reply?.ifEmpty { stringResource(R.string.trigger_reply_empty) }
+ ?: stringResource(R.string.trigger_reply_missing)
+ )
+ }
+ if (summary.inputTokens != null || summary.outputTokens != null) {
+ appendLine()
+ appendLine(
+ if (summary.totalTokens != null) {
+ stringResource(
+ R.string.trigger_usage_prompt_completion_total,
+ summary.inputTokens ?: "—",
+ summary.outputTokens ?: "—",
+ summary.totalTokens ?: "—"
+ )
+ } else {
+ stringResource(
+ R.string.trigger_usage_input_output,
+ summary.inputTokens ?: "—",
+ summary.outputTokens ?: "—"
+ )
+ }
+ )
+ }
+}.trim()
+
@Composable
private fun ResponseDialog(
title: String,
@@ -1110,7 +1176,11 @@ private fun ResponseDialog(
text = {
Column {
Text(
- text = if (isError) "错误详情:" else "API 响应内容:",
+ text = if (isError) {
+ stringResource(R.string.trigger_error_detail)
+ } else {
+ stringResource(R.string.trigger_api_response)
+ },
style = MaterialTheme.typography.labelMedium,
color = if (isError) MaterialTheme.colorScheme.error else inkMuted()
)
@@ -1134,7 +1204,7 @@ private fun ResponseDialog(
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("API Response", responseBody))
}) {
- Text("复制")
+ Text(stringResource(R.string.action_copy))
}
Button(
onClick = onDismiss,
@@ -1143,7 +1213,7 @@ private fun ResponseDialog(
contentColor = Color.White
)
) {
- Text("关闭")
+ Text(stringResource(R.string.action_close))
}
}
}
diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
index 2aacd16..2c6ac2a 100644
--- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
@@ -8,11 +8,15 @@ import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.TriggerError
+import com.rainy.token.data.repository.TriggerErrorReason
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
+import com.rainy.token.domain.model.TriggerSummary
+import com.rainy.token.R
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
+import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
@@ -150,7 +154,7 @@ class ServiceDetailViewModel @Inject constructor(
cached = local.cachedBalance,
state = State.Error(
local.cachedBalance?.balance,
- "凭据未配置",
+ UiText.Resource(R.string.error_credential_not_configured),
RepositoryError.InvalidCredential()
)
)
@@ -303,7 +307,10 @@ class ServiceDetailViewModel @Inject constructor(
val service = _serviceType.value ?: return
val serviceGen = serviceGeneration
val model = _selectedModel.value ?: run {
- _triggerState.value = TriggerState.Error("请先选择模型", null)
+ _triggerState.value = TriggerState.Error(
+ UiText.Resource(R.string.error_select_model_first),
+ null
+ )
return
}
viewModelScope.launch {
@@ -319,39 +326,49 @@ class ServiceDetailViewModel @Inject constructor(
if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch
result
- .onSuccess { responseBody ->
- _triggerState.value = TriggerState.Success(responseBody)
+ .onSuccess { summary ->
+ _triggerState.value = TriggerState.Success(summary)
kotlinx.coroutines.delay(2000)
if (serviceGen == serviceGeneration && _serviceType.value == service) {
refresh()
}
}
.onFailure { error ->
- val message: String
+ val message: UiText
val responseBody: String?
when (error) {
is TriggerError -> {
- message = error.summary
+ // reason 结构化:token 刷新失败显示本地化文案(summary 中文提示只进日志);
+ // 其余(HTTP 错误)summary 为 ASCII 可直接展示
+ message = if (error.reason == TriggerErrorReason.TOKEN_REFRESH) {
+ UiText.Resource(R.string.error_token_refresh_generic)
+ } else {
+ UiText.Dynamic(error.summary)
+ }
responseBody = error.responseBody.ifBlank { null }
}
is RepositoryError.InvalidCredential -> {
- message = error.message ?: "凭据无效,请重新登录"
+ message = UiText.Resource(R.string.error_credential_invalid_relogin)
responseBody = null
}
is RepositoryError.CredentialChanged -> {
- message = "凭据已变更,请重新操作"
+ message = UiText.Resource(R.string.error_credential_changed_retry)
responseBody = null
}
is RepositoryError.Network -> {
- message = "网络异常"
+ message = UiText.Resource(R.string.error_network_generic)
responseBody = null
}
else -> {
- message = error.message ?: "未知错误"
+ message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) {
+ UiText.Resource(R.string.error_trigger_not_supported)
+ } else {
+ errorMessage(error)
+ }
responseBody = null
}
}
@@ -418,13 +435,17 @@ class ServiceDetailViewModel @Inject constructor(
isManual && cached != null -> State.Stale(cached.balance, cached.fetchedAt)
isManual -> State.ManualModeHint
status.state == CredentialStatus.State.NOT_CONFIGURED ->
- State.Error(null, "凭据未配置", RepositoryError.InvalidCredential())
+ State.Error(
+ null,
+ UiText.Resource(R.string.error_credential_not_configured),
+ RepositoryError.InvalidCredential()
+ )
cached != null && status.state == CredentialStatus.State.OK ->
State.Stale(cached.balance, cached.fetchedAt)
cached != null ->
State.Error(
cached.balance,
- "凭据未配置或已过期",
+ UiText.Resource(R.string.error_credential_not_configured_expired),
RepositoryError.InvalidCredential()
)
else -> State.Loading
@@ -455,7 +476,7 @@ class ServiceDetailViewModel @Inject constructor(
val mergedState: State = when {
!hasCredential -> State.Error(
mergedCache?.balance,
- "凭据未配置",
+ UiText.Resource(R.string.error_credential_not_configured),
RepositoryError.InvalidCredential()
)
current.state is State.Loading -> current.state
@@ -498,15 +519,34 @@ class ServiceDetailViewModel @Inject constructor(
serviceGen != serviceGeneration ||
_serviceType.value != type
- private fun errorMessage(error: Throwable): String = when (error) {
- is RepositoryError.InvalidCredential -> "凭据无效,请在设置中重新配置"
- is RepositoryError.CredentialChanged -> "凭据已变更,正在重新加载"
- is RepositoryError.RateLimited ->
- "请求过于频繁${error.retryAfterSeconds?.let { ",请 ${it} 秒后重试" } ?: ""}"
- is RepositoryError.Network -> "网络异常,请检查网络"
- is RepositoryError.ServerError -> "服务端异常 (HTTP ${error.code})"
- is RepositoryError.ParseError -> "数据解析失败: ${error.message}"
- else -> error.message ?: "未知错误"
+ private fun errorMessage(error: Throwable): UiText = when (error) {
+ is RepositoryError.InvalidCredential ->
+ UiText.Resource(R.string.error_credential_invalid_reconfigure)
+ is RepositoryError.CredentialChanged ->
+ UiText.Resource(R.string.error_credential_changed_reload)
+ is RepositoryError.RateLimited -> UiText.Resource(
+ R.string.error_rate_limited_retry,
+ listOf(
+ error.retryAfterSeconds?.let {
+ UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it))
+ } ?: ""
+ )
+ )
+ is RepositoryError.Network -> UiText.Resource(R.string.error_network_check)
+ is RepositoryError.ServerError ->
+ UiText.Resource(R.string.error_server_http, listOf(error.code))
+ is RepositoryError.ParseError -> when (error.reason) {
+ RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
+ RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
+ RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
+ RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
+ RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
+ RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
+ }
+ // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案
+ is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
+ else -> error.message?.let { UiText.Dynamic(it) }
+ ?: UiText.Resource(R.string.common_unknown)
}
private fun prefs(service: ServiceType): android.content.SharedPreferences =
@@ -539,7 +579,7 @@ sealed class State {
data class Stale(val data: ServiceBalance, val lastFetchedAt: Long) : State()
data class Error(
val cached: ServiceBalance?,
- val message: String,
+ val message: UiText,
val error: Throwable
) : State()
data object ManualModeHint : State()
@@ -554,6 +594,6 @@ data class ServiceDetailUiState(
sealed class TriggerState {
data object Idle : TriggerState()
data object Loading : TriggerState()
- data class Success(val responseBody: String) : TriggerState()
- data class Error(val message: String, val responseBody: String?) : TriggerState()
+ data class Success(val summary: TriggerSummary) : TriggerState()
+ data class Error(val message: UiText, val responseBody: String?) : TriggerState()
}
diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt
index 141693f..3fef1a8 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt
@@ -41,11 +41,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
+import com.rainy.token.ui.components.resolve
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.domain.service.ServiceType
/**
@@ -77,7 +80,7 @@ fun CredentialEditScreen(
LaunchedEffect(uiState.message) {
uiState.message?.let { msg ->
- snackbarHostState.showSnackbar(msg)
+ snackbarHostState.showSnackbar(msg.resolve(context))
viewModel.clearMessage()
}
}
@@ -88,13 +91,13 @@ fun CredentialEditScreen(
title = { Text(service.displayName) },
navigationIcon = {
IconButton(onClick = onBack) {
- Icon(Icons.Filled.ArrowBack, contentDescription = "返回")
+ Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
if (uiState.hasExisting) {
IconButton(onClick = { viewModel.deleteCredential() }) {
- Icon(Icons.Filled.Delete, contentDescription = "删除")
+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
}
}
}
@@ -193,7 +196,7 @@ fun CredentialEditScreen(
)
if (uiState.hasExisting) {
Text(
- text = "✓ 已配置登录态(${uiState.cookieCount} 个 Cookie)",
+ text = stringResource(R.string.credential_configured_cookies, uiState.cookieCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -207,19 +210,19 @@ fun CredentialEditScreen(
if (showCookieHelp) {
AlertDialog(
onDismissRequest = { showCookieHelp = false },
- title = { Text("如何获取 Cookie?") },
+ title = { Text(stringResource(R.string.help_cookie_title)) },
text = {
Column {
- Text("1. 在桌面 Chrome 打开下方登录入口")
- Text("2. 用 GitHub / Google 登录(不会被拦截)")
- Text("3. 登录后按 F12 → Application → Cookies")
- Text("4. 复制该站点下的所有 Cookie 粘贴到上面输入框")
- Text("格式:name1=value1; name2=value2")
+ Text(stringResource(R.string.help_cookie_1))
+ Text(stringResource(R.string.help_cookie_2))
+ Text(stringResource(R.string.help_cookie_3))
+ Text(stringResource(R.string.help_cookie_4))
+ Text(stringResource(R.string.help_cookie_5))
}
},
confirmButton = {
TextButton(onClick = { showCookieHelp = false }) {
- Text("我知道了")
+ Text(stringResource(R.string.action_got_it))
}
}
)
@@ -228,23 +231,23 @@ fun CredentialEditScreen(
if (showGoHelp) {
AlertDialog(
onDismissRequest = { showGoHelp = false },
- title = { Text("如何获取 OpenCode Go 凭据?") },
+ title = { Text(stringResource(R.string.help_ocgo_title)) },
text = {
Column {
- Text("1. 桌面 Chrome 打开 opencode.ai/auth")
- Text("2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)")
- Text("3. 登录后跳转到 dashboard,URL 形如:")
- Text(" https://opencode.ai/workspace/{id}/go")
- Text("4. 复制 URL 中的 {id} 段 → workspaceId")
- Text("5. F12 → Application → Cookies")
- Text("6. 找到域名 opencode.ai 下名为 auth 的 Cookie")
- Text("7. 复制它的 Value 字段 → auth cookie")
- Text("注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串")
+ Text(stringResource(R.string.help_ocgo_1))
+ Text(stringResource(R.string.help_ocgo_2))
+ Text(stringResource(R.string.help_ocgo_3))
+ Text(stringResource(R.string.help_ocgo_4))
+ Text(stringResource(R.string.help_ocgo_5))
+ Text(stringResource(R.string.help_ocgo_6))
+ Text(stringResource(R.string.help_ocgo_7))
+ Text(stringResource(R.string.help_ocgo_8))
+ Text(stringResource(R.string.help_ocgo_note))
}
},
confirmButton = {
TextButton(onClick = { showGoHelp = false }) {
- Text("我知道了")
+ Text(stringResource(R.string.action_got_it))
}
}
)
@@ -253,25 +256,25 @@ fun CredentialEditScreen(
if (showOpenCcgoHelp) {
AlertDialog(
onDismissRequest = { showOpenCcgoHelp = false },
- title = { Text("如何获取 CommandCode Go 凭据?") },
+ title = { Text(stringResource(R.string.help_ccgo_title)) },
text = {
Column {
- Text("1. 打开 https://commandcode.ai 并登录 GitHub")
+ Text(stringResource(R.string.help_ccgo_1))
Text("")
- Text("▸ 获取 API Key(必填):")
- Text(" 右上角头像 → Settings → API Keys → 创建一个 Key")
+ Text(stringResource(R.string.help_ccgo_2))
+ Text(stringResource(R.string.help_ccgo_3))
Text("")
- Text("▸ 获取全部 Cookie(用于用量记录):")
- Text(" F12 → Application → Cookies → commandcode.ai")
- Text(" 选中所有 Cookie,复制整个 Cookie 字符串")
- Text(" 粘贴到下方输入框即可,无需逐个挑选")
+ Text(stringResource(R.string.help_ccgo_4))
+ Text(stringResource(R.string.help_ccgo_5))
+ Text(stringResource(R.string.help_ccgo_6))
+ Text(stringResource(R.string.help_ccgo_7))
Text("")
- Text("提示:不填 Cookie 也能正常查看余额,只是用量记录为空。")
+ Text(stringResource(R.string.help_ccgo_note))
}
},
confirmButton = {
TextButton(onClick = { showOpenCcgoHelp = false }) {
- Text("我知道了")
+ Text(stringResource(R.string.action_got_it))
}
}
)
@@ -280,32 +283,32 @@ fun CredentialEditScreen(
if (showCodexHelp) {
AlertDialog(
onDismissRequest = { showCodexHelp = false },
- title = { Text("如何获取 Codex auth.json?") },
+ title = { Text(stringResource(R.string.help_codex_title)) },
text = {
Column {
- Text("1. 在终端运行 codex-oauth-proxy 或")
- Text(" codex-proxy,完成 ChatGPT 登录")
+ Text(stringResource(R.string.help_codex_1))
+ Text(stringResource(R.string.help_codex_2))
Text("")
- Text("2. 凭证文件保存在:")
- Text(" ~/.config/codex-oauth-proxy/auth.json")
+ Text(stringResource(R.string.help_codex_3))
+ Text(stringResource(R.string.help_codex_4))
Text("")
- Text("3. 用文本编辑器打开该文件,")
- Text(" 复制全部内容粘贴到上方输入框")
+ Text(stringResource(R.string.help_codex_5))
+ Text(stringResource(R.string.help_codex_6))
Text("")
- Text(" JSON 格式示例:")
- Text(" {\"tokens\": {")
- Text(" \"access_token\": \"...\",")
- Text(" \"refresh_token\": \"...\",")
- Text(" \"expiresAt\": 1783727108252")
- Text(" }}")
+ Text(stringResource(R.string.help_codex_7))
+ Text(stringResource(R.string.help_codex_8))
+ Text(stringResource(R.string.help_codex_9))
+ Text(stringResource(R.string.help_codex_10))
+ Text(stringResource(R.string.help_codex_11))
+ Text(stringResource(R.string.help_codex_12))
Text("")
- Text("提示:导入后 APP 会自动刷新 token,")
- Text("不需要手动更新。")
+ Text(stringResource(R.string.help_codex_note))
+ Text(stringResource(R.string.help_codex_note2))
}
},
confirmButton = {
TextButton(onClick = { showCodexHelp = false }) {
- Text("我知道了")
+ Text(stringResource(R.string.action_got_it))
}
}
)
@@ -326,9 +329,9 @@ private fun OllamaCookieForm(
onApiKeyChange: (String) -> Unit,
onSaveApiKey: () -> Unit
) {
- Text(text = "Ollama Pro 凭据", style = MaterialTheme.typography.titleMedium)
+ Text(text = stringResource(R.string.credential_title_ollama), style = MaterialTheme.typography.titleMedium)
Text(
- text = "需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。",
+ text = stringResource(R.string.credential_hint_ollama),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
@@ -351,34 +354,34 @@ private fun OllamaCookieForm(
modifier = Modifier.weight(1f),
enabled = cookie.isNotBlank()
) {
- Text(text = if (hasExisting) "更新凭据" else "保存凭据")
+ Text(text = if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save))
}
OutlinedButton(
onClick = onTestAndSave,
modifier = Modifier.weight(1f),
enabled = cookie.isNotBlank()
) {
- Text(text = "测试并保存")
+ Text(text = stringResource(R.string.action_test_and_save))
}
}
OutlinedButton(
onClick = onOpenLoginUrl,
modifier = Modifier.fillMaxWidth()
) {
- Text(text = "在浏览器中打开 Ollama Settings")
+ Text(text = stringResource(R.string.action_open_ollama_settings))
}
TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) {
- Text(text = "复制登录 URL 到剪贴板")
+ Text(text = stringResource(R.string.action_copy_login_url))
}
// ── 一键激活用量 API Key ──
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
- text = "一键激活用量 API Key(可选)",
+ text = stringResource(R.string.activate_api_key_optional),
style = MaterialTheme.typography.titleSmall
)
Text(
- text = "填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。",
+ text = stringResource(R.string.hint_ollama_trigger_key),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
@@ -386,7 +389,7 @@ private fun OllamaCookieForm(
value = triggerApiKey,
onValueChange = onApiKeyChange,
label = { Text("API Key") },
- placeholder = { Text("ollama-xxx 或 sk-xxx") },
+ placeholder = { Text(stringResource(R.string.placeholder_ollama_api_key)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
@@ -397,7 +400,7 @@ private fun OllamaCookieForm(
modifier = Modifier.fillMaxWidth(),
enabled = triggerApiKey.isNotBlank()
) {
- Text("保存 API Key")
+ Text(stringResource(R.string.action_save_api_key))
}
}
@@ -419,28 +422,28 @@ private fun OpenCodeGoForm(
onApiKeyChange: (String) -> Unit,
onSaveApiKey: () -> Unit
) {
- Text(text = "OpenCode Go 凭据", style = MaterialTheme.typography.titleMedium)
+ Text(text = stringResource(R.string.credential_title_opencode_go), style = MaterialTheme.typography.titleMedium)
Text(
- text = "需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。",
+ text = stringResource(R.string.credential_hint_opencode_go),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) {
- Text(text = "从剪贴板自动导入")
+ Text(text = stringResource(R.string.action_import_from_clipboard))
}
OutlinedTextField(
value = workspaceId,
onValueChange = onWorkspaceIdChange,
label = { Text("Workspace ID") },
- placeholder = { Text("dashboard URL 中的 {id} 段") },
+ placeholder = { Text(stringResource(R.string.placeholder_workspace_id)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = authCookie,
onValueChange = onAuthCookieChange,
- label = { Text("auth Cookie 值") },
- placeholder = { Text("F12 → Cookies → opencode.ai → auth 的 Value") },
+ label = { Text(stringResource(R.string.field_auth_cookie_value)) },
+ placeholder = { Text(stringResource(R.string.placeholder_auth_cookie)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
@@ -451,36 +454,36 @@ private fun OpenCodeGoForm(
modifier = Modifier.fillMaxWidth(),
enabled = authCookie.isNotBlank() && workspaceId.isNotBlank()
) {
- Text(text = if (hasExisting) "更新凭据" else "保存凭据")
+ Text(text = if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save))
}
OutlinedButton(
onClick = onTestAndSave,
modifier = Modifier.fillMaxWidth(),
enabled = authCookie.isNotBlank() && workspaceId.isNotBlank()
) {
- Text(text = "测试并保存")
+ Text(text = stringResource(R.string.action_test_and_save))
}
OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) {
- Text(text = "如何获取这两个值?")
+ Text(text = stringResource(R.string.action_how_get_two_values))
}
OutlinedButton(
onClick = onOpenLoginUrl,
modifier = Modifier.fillMaxWidth()
) {
- Text(text = "在浏览器中打开登录入口")
+ Text(text = stringResource(R.string.action_open_login_entry))
}
TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) {
- Text(text = "复制登录 URL 到剪贴板")
+ Text(text = stringResource(R.string.action_copy_login_url))
}
// ── 一键激活用量 API Key ──
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
- text = "一键激活用量 API Key(可选)",
+ text = stringResource(R.string.activate_api_key_optional),
style = MaterialTheme.typography.titleSmall
)
Text(
- text = "填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。",
+ text = stringResource(R.string.hint_opencode_trigger_key),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
@@ -499,7 +502,7 @@ private fun OpenCodeGoForm(
modifier = Modifier.fillMaxWidth(),
enabled = triggerApiKey.isNotBlank()
) {
- Text("保存 API Key")
+ Text(stringResource(R.string.action_save_api_key))
}
}
@@ -514,9 +517,9 @@ private fun CommandCodeGoForm(
onTestAndSave: () -> Unit,
onShowHelp: () -> Unit
) {
- Text(text = "CommandCode Go 凭据", style = MaterialTheme.typography.titleMedium)
+ Text(text = stringResource(R.string.credential_title_ccgo), style = MaterialTheme.typography.titleMedium)
Text(
- text = "需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。",
+ text = stringResource(R.string.credential_hint_ccgo),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
@@ -524,7 +527,7 @@ private fun CommandCodeGoForm(
value = apiKey,
onValueChange = onApiKeyChange,
label = { Text("API Key") },
- placeholder = { Text("从 Settings → API Keys 生成") },
+ placeholder = { Text(stringResource(R.string.placeholder_api_key_from_settings)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password),
@@ -533,23 +536,23 @@ private fun CommandCodeGoForm(
OutlinedTextField(
value = cookieInput,
onValueChange = onCookieChange,
- label = { Text("Session Cookie(可选)") },
- placeholder = { Text("F12 → Cookies → commandcode.ai → 全选复制粘贴即可") },
+ label = { Text(stringResource(R.string.field_session_cookie_optional)) },
+ placeholder = { Text(stringResource(R.string.placeholder_session_cookie)) },
modifier = Modifier.fillMaxWidth(),
minLines = 2
)
Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) {
- Text(if (hasExisting) "更新凭据" else "保存凭据")
+ Text(if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save))
}
OutlinedButton(
onClick = onTestAndSave,
modifier = Modifier.fillMaxWidth(),
enabled = apiKey.isNotBlank()
) {
- Text(text = "测试并保存")
+ Text(text = stringResource(R.string.action_test_and_save))
}
OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) {
- Text(text = "如何获取 API Key 和 Cookie?")
+ Text(text = stringResource(R.string.action_how_get_api_key_cookie))
}
}
@@ -561,9 +564,9 @@ private fun ApiKeyForm(
onSave: () -> Unit,
onTestAndSave: () -> Unit
) {
- Text(text = "请输入 API Key", style = MaterialTheme.typography.titleMedium)
+ Text(text = stringResource(R.string.api_key_form_title), style = MaterialTheme.typography.titleMedium)
Text(
- text = "凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。",
+ text = stringResource(R.string.api_key_form_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
@@ -579,14 +582,14 @@ private fun ApiKeyForm(
modifier = Modifier.fillMaxWidth()
)
Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) {
- Text(if (hasExisting) "更新" else "保存")
+ Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
}
OutlinedButton(
onClick = onTestAndSave,
modifier = Modifier.fillMaxWidth(),
enabled = apiKey.isNotBlank()
) {
- Text(text = "测试并保存")
+ Text(text = stringResource(R.string.action_test_and_save))
}
}
@@ -599,17 +602,17 @@ private fun CodexAuthJsonForm(
onShowHelp: () -> Unit,
onStartOAuth: () -> Unit = {}
) {
- Text(text = "Codex / ChatGPT Plus 凭据", style = MaterialTheme.typography.titleMedium)
+ Text(text = stringResource(R.string.credential_title_codex), style = MaterialTheme.typography.titleMedium)
Text(
- text = "推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。",
+ text = stringResource(R.string.credential_hint_codex),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Button(onClick = onStartOAuth, modifier = Modifier.fillMaxWidth()) {
- Text("🔐 OAuth 登录(推荐)")
+ Text(stringResource(R.string.action_oauth_login))
}
Text(
- text = "── 或手动导入 ──",
+ text = stringResource(R.string.or_manual_import),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
@@ -618,16 +621,16 @@ private fun CodexAuthJsonForm(
OutlinedTextField(
value = authJson,
onValueChange = onAuthJsonChange,
- label = { Text("auth.json 完整内容") },
- placeholder = { Text("粘贴 {\"tokens\": {...}} 完整 JSON") },
+ label = { Text(stringResource(R.string.field_auth_json)) },
+ placeholder = { Text(stringResource(R.string.placeholder_auth_json)) },
modifier = Modifier.fillMaxWidth(),
minLines = 6
)
Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) {
- Text(if (hasExisting) "更新凭据" else "导入并保存")
+ Text(if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_import_save))
}
OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) {
- Text(text = "如何获取 auth.json?")
+ Text(text = stringResource(R.string.action_how_get_auth_json))
}
}
@@ -640,16 +643,16 @@ private fun ManualCookieForm(
onOpenLoginUrl: () -> Unit,
onShowHelp: () -> Unit
) {
- Text(text = "粘贴登录态", style = MaterialTheme.typography.titleMedium)
+ Text(text = stringResource(R.string.manual_cookie_title), style = MaterialTheme.typography.titleMedium)
Text(
- text = "Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。",
+ text = stringResource(R.string.manual_cookie_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = cookieValue,
onValueChange = onCookieChange,
- label = { Text("Cookie 字符串") },
+ label = { Text(stringResource(R.string.field_cookie_string)) },
placeholder = { Text("name1=value1; name2=value2; ...") },
modifier = Modifier
.fillMaxWidth()
@@ -657,19 +660,19 @@ private fun ManualCookieForm(
minLines = 4
)
Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) {
- Text(text = "保存 Cookie")
+ Text(text = stringResource(R.string.action_save_cookie))
}
OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) {
- Text(text = "如何获取 Cookie?")
+ Text(text = stringResource(R.string.help_cookie_title))
}
OutlinedButton(
onClick = onOpenLoginUrl,
modifier = Modifier.fillMaxWidth()
) {
- Text(text = "在浏览器中打开登录入口")
+ Text(text = stringResource(R.string.action_open_login_entry))
}
TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) {
- Text(text = "复制登录 URL 到剪贴板")
+ Text(text = stringResource(R.string.action_copy_login_url))
}
}
diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
index ea50b05..4e7fad4 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
@@ -4,6 +4,7 @@ import android.content.ClipboardManager
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.rainy.token.R
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.CookieEntry
@@ -12,6 +13,7 @@ import com.rainy.token.domain.service.FetchMethod
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
+import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import javax.inject.Provider
@@ -117,7 +119,7 @@ class CredentialEditViewModel @Inject constructor(
val current = _uiState.value
val trimmedKey = current.apiKey.trim()
if (trimmedKey.isBlank()) {
- _uiState.update { it.copy(message = "API Key 不能为空") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
}
viewModelScope.launch {
@@ -131,7 +133,7 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update {
it.copy(
apiKey = trimmedKey,
- message = "已保存",
+ message = UiText.Resource(R.string.msg_saved),
hasExisting = true
)
}
@@ -142,7 +144,7 @@ class CredentialEditViewModel @Inject constructor(
private suspend fun testAndRollback(
type: ServiceType,
saveAndPrep: suspend () -> Pair Result>,
- formatSuccess: (com.rainy.token.domain.model.ServiceBalance) -> String,
+ formatSuccess: (com.rainy.token.domain.model.ServiceBalance) -> UiText,
rollbackOnFailure: Boolean
) {
val (previous, testBlock) = saveAndPrep()
@@ -158,23 +160,45 @@ class CredentialEditViewModel @Inject constructor(
false
}
val err = result.exceptionOrNull()
- val reason = when (err) {
- is RepositoryError.InvalidCredential -> "服务拒绝该凭据 (401/403)"
- is RepositoryError.CredentialChanged -> "测试期间凭据已变更"
- is RepositoryError.RateLimited -> "请求过于频繁 (429)"
- is RepositoryError.ServerError -> "服务端错误 (${err.code})"
- is RepositoryError.Network -> "网络错误:${err.cause?.message ?: "未知"}"
- else -> err?.message ?: "未知错误"
+ val reason: UiText = when (err) {
+ is RepositoryError.InvalidCredential ->
+ UiText.Resource(R.string.error_credential_rejected)
+ is RepositoryError.CredentialChanged ->
+ UiText.Resource(R.string.error_credential_changed_test)
+ is RepositoryError.RateLimited ->
+ UiText.Resource(R.string.error_rate_limited)
+ is RepositoryError.ServerError ->
+ UiText.Resource(R.string.error_server, listOf(err.code))
+ is RepositoryError.Network ->
+ UiText.Resource(
+ R.string.error_network,
+ listOf(
+ err.cause?.message?.let { UiText.Dynamic(it) }
+ ?: UiText.Resource(R.string.common_unknown)
+ )
+ )
+ is RepositoryError.ParseError -> when (err.reason) {
+ RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
+ RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
+ RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
+ RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
+ RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
+ RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
+ }
+ // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案
+ is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
+ else -> err?.message?.let { UiText.Dynamic(it) }
+ ?: UiText.Resource(R.string.common_unknown)
}
val hasExisting = credentialRepository.get(type) != null
- val rollbackNote = when {
+ val rollbackNote: Any = when {
!rollbackOnFailure -> ""
- rolledBack -> ",已恢复原凭据"
- else -> ",检测到凭据已变化,未执行回滚"
+ rolledBack -> UiText.Resource(R.string.error_rollback_done)
+ else -> UiText.Resource(R.string.error_rollback_skipped)
}
_uiState.update {
it.copy(
- message = "测试失败。$reason$rollbackNote",
+ message = UiText.Resource(R.string.error_test_failed, listOf(reason, rollbackNote)),
hasExisting = hasExisting
)
}
@@ -184,13 +208,13 @@ class CredentialEditViewModel @Inject constructor(
fun testAndSaveApiKey() {
val type = serviceType ?: return
if (type != ServiceType.DEEPSEEK && type != ServiceType.COMMANDCODE_GO && type != ServiceType.CODEX) {
- _uiState.update { it.copy(message = "暂不支持测试此服务") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_test_not_supported)) }
return
}
val current = _uiState.value
val trimmedKey = current.apiKey.trim()
if (trimmedKey.isBlank()) {
- _uiState.update { it.copy(message = "API Key 不能为空") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
}
viewModelScope.launch {
@@ -205,30 +229,41 @@ class CredentialEditViewModel @Inject constructor(
testAndRollback(
type = type,
saveAndPrep = { existing to { refreshBalanceUseCaseProvider.get().invoke(type) } },
- formatSuccess = { bal -> "连接成功!余额: ${bal.amount} ${bal.unit}" },
+ formatSuccess = { bal ->
+ UiText.Resource(
+ R.string.msg_connect_success_balance,
+ listOf(bal.amount.toString(), bal.unit)
+ )
+ },
rollbackOnFailure = false
)
}
}
/** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */
- private fun maskedKeyPreview(key: String): String {
- if (key.length <= 8) return "*** (长度 ${key.length}) ***"
+ private fun maskedKeyPreview(key: String): UiText {
+ if (key.length <= 8) {
+ return UiText.Resource(R.string.msg_key_length, listOf(key.length))
+ }
val head = key.take(4)
val tail = key.takeLast(4)
- return "$head***$tail (长度 ${key.length})"
+ return UiText.Resource(R.string.msg_key_masked, listOf(head, tail, key.length))
}
fun saveOpenCodeGoSession() {
val type = serviceType ?: return
val current = _uiState.value
if (current.authCookie.isBlank() || current.workspaceId.isBlank()) {
- _uiState.update { it.copy(message = "auth cookie 和 workspaceId 都需要填写") }
+ _uiState.update {
+ it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace))
+ }
return
}
viewModelScope.launch {
doSaveOpenCodeGo(current.workspaceId.trim(), current.authCookie.trim())
- _uiState.update { it.copy(message = "已保存凭据", hasExisting = true) }
+ _uiState.update {
+ it.copy(message = UiText.Resource(R.string.msg_credentials_saved), hasExisting = true)
+ }
}
}
@@ -236,7 +271,9 @@ class CredentialEditViewModel @Inject constructor(
val type = serviceType ?: return
val current = _uiState.value
if (current.authCookie.isBlank() || current.workspaceId.isBlank()) {
- _uiState.update { it.copy(message = "auth cookie 和 workspaceId 都需要填写") }
+ _uiState.update {
+ it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace))
+ }
return
}
viewModelScope.launch {
@@ -245,7 +282,7 @@ class CredentialEditViewModel @Inject constructor(
testAndRollback(
type = type,
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } },
- formatSuccess = { "连接成功,凭据已保存" },
+ formatSuccess = { UiText.Resource(R.string.msg_connect_success_saved) },
rollbackOnFailure = true
)
}
@@ -269,12 +306,12 @@ class CredentialEditViewModel @Inject constructor(
val type = serviceType ?: return
val current = _uiState.value
if (current.cookieInput.isBlank()) {
- _uiState.update { it.copy(message = "Cookie 不能为空") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) }
return
}
val cookies = parseCookieString(current.cookieInput)
if (cookies.isEmpty()) {
- _uiState.update { it.copy(message = "Cookie 格式错误,应为 name1=value1; name2=value2") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_format)) }
return
}
viewModelScope.launch {
@@ -289,7 +326,7 @@ class CredentialEditViewModel @Inject constructor(
credentialRepository.save(updated)
_uiState.update {
it.copy(
- message = "已保存 ${cookies.size} 个 Cookie",
+ message = UiText.Resource(R.string.msg_cookies_saved, listOf(cookies.size)),
hasExisting = true,
cookieCount = cookies.size
)
@@ -306,7 +343,7 @@ class CredentialEditViewModel @Inject constructor(
val current = _uiState.value
val text = current.codexAuthJson.trim()
if (text.isBlank()) {
- _uiState.update { it.copy(message = "请粘贴 auth.json 内容") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_auth_json_empty)) }
return
}
viewModelScope.launch {
@@ -324,7 +361,9 @@ class CredentialEditViewModel @Inject constructor(
?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
- _uiState.update { it.copy(message = "auth.json 缺少 access_token 或 refresh_token") }
+ _uiState.update {
+ it.copy(message = UiText.Resource(R.string.error_auth_json_missing_tokens))
+ }
return@launch
}
@@ -339,12 +378,22 @@ class CredentialEditViewModel @Inject constructor(
credentialRepository.save(newCred)
_uiState.update {
it.copy(
- message = "已保存 Codex 凭据,token 到期后会自动刷新",
+ message = UiText.Resource(R.string.msg_codex_saved),
hasExisting = true
)
}
} catch (e: Exception) {
- _uiState.update { it.copy(message = "解析失败:${e.message ?: "JSON 格式错误"}") }
+ _uiState.update {
+ it.copy(
+ message = UiText.Resource(
+ R.string.error_auth_json_parse,
+ listOf(
+ e.message?.let { UiText.Dynamic(it) }
+ ?: UiText.Resource(R.string.error_json_format)
+ )
+ )
+ )
+ }
}
}
}
@@ -354,7 +403,7 @@ class CredentialEditViewModel @Inject constructor(
val current = _uiState.value
val trimmedKey = current.apiKey.trim()
if (trimmedKey.isBlank()) {
- _uiState.update { it.copy(message = "API Key 不能为空") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
}
val cookies = if (current.cookieInput.isNotBlank()) {
@@ -378,7 +427,7 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update {
it.copy(
apiKey = trimmedKey,
- message = "已保存凭据",
+ message = UiText.Resource(R.string.msg_credentials_saved),
hasExisting = true,
cookieCount = cookies.size
)
@@ -391,7 +440,7 @@ class CredentialEditViewModel @Inject constructor(
val current = _uiState.value
val trimmedKey = current.apiKey.trim()
if (trimmedKey.isBlank()) {
- _uiState.update { it.copy(message = "API Key 不能为空") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
}
viewModelScope.launch {
@@ -416,7 +465,10 @@ class CredentialEditViewModel @Inject constructor(
type = type,
saveAndPrep = { existing to { refreshBalanceUseCaseProvider.get().invoke(type) } },
formatSuccess = { bal ->
- "连接成功!余额: \$${String.format(java.util.Locale.US, "%.2f", bal.amount)}"
+ UiText.Resource(
+ R.string.msg_connect_success_dollar,
+ listOf(String.format(java.util.Locale.US, "%.2f", bal.amount))
+ )
},
rollbackOnFailure = false
)
@@ -448,7 +500,7 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update {
it.copy(
triggerApiKey = trimmedKey,
- message = "API Key 已保存",
+ message = UiText.Resource(R.string.msg_api_key_saved),
hasExisting = true
)
}
@@ -459,12 +511,14 @@ class CredentialEditViewModel @Inject constructor(
val type = serviceType ?: return
val current = _uiState.value
if (current.ollamaCookie.isBlank()) {
- _uiState.update { it.copy(message = "Cookie 不能为空") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) }
return
}
viewModelScope.launch {
doSaveOllama(current.ollamaCookie.trim())
- _uiState.update { it.copy(message = "已保存凭据", hasExisting = true) }
+ _uiState.update {
+ it.copy(message = UiText.Resource(R.string.msg_credentials_saved), hasExisting = true)
+ }
}
}
@@ -472,7 +526,7 @@ class CredentialEditViewModel @Inject constructor(
val type = serviceType ?: return
val current = _uiState.value
if (current.ollamaCookie.isBlank()) {
- _uiState.update { it.copy(message = "Cookie 不能为空") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) }
return
}
viewModelScope.launch {
@@ -482,7 +536,10 @@ class CredentialEditViewModel @Inject constructor(
type = type,
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } },
formatSuccess = { bal ->
- "连接成功!Session: ${bal.amount}% · ${bal.extras["plan"] ?: "—"}"
+ UiText.Resource(
+ R.string.msg_connect_success_session,
+ listOf(bal.amount.toString(), bal.extras["plan"] ?: "—")
+ )
},
rollbackOnFailure = true
)
@@ -508,7 +565,7 @@ class CredentialEditViewModel @Inject constructor(
credentialRepository.remove(type)
_uiState.update {
it.copy(
- message = "凭据已删除",
+ message = UiText.Resource(R.string.msg_credential_deleted),
hasExisting = false,
apiKey = "",
cookieInput = "",
@@ -530,7 +587,7 @@ class CredentialEditViewModel @Inject constructor(
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
val text = cm.primaryClip?.getItemAt(0)?.text?.toString()
if (text.isNullOrBlank()) {
- _uiState.update { it.copy(message = "剪贴板为空") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_clipboard_empty)) }
return
}
val ws = extractWorkspaceId(text)
@@ -543,13 +600,13 @@ class CredentialEditViewModel @Inject constructor(
}
when {
ws != null && auth != null ->
- _uiState.update { it.copy(message = "已识别 workspaceId 和 auth cookie") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_ws_auth)) }
ws != null ->
- _uiState.update { it.copy(message = "已识别 workspaceId,请再粘贴 auth cookie") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_ws_only)) }
auth != null ->
- _uiState.update { it.copy(message = "已识别 auth cookie,请再粘贴 workspaceId") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_auth_only)) }
else ->
- _uiState.update { it.copy(message = "未识别到有效凭据") }
+ _uiState.update { it.copy(message = UiText.Resource(R.string.error_clipboard_none)) }
}
}
@@ -594,5 +651,5 @@ data class CredentialEditUiState(
val ollamaCookie: String = "",
/** OCGO / Ollama 的一键激活用量 API Key */
val triggerApiKey: String = "",
- val message: String? = null
+ val message: UiText? = null
)
diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt
index f0e8039..da5d181 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt
@@ -1,5 +1,7 @@
package com.rainy.token.ui.settings
+import android.app.Activity
+import android.os.Build
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -17,6 +19,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -26,13 +29,19 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -45,9 +54,11 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip
import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.StatusStyle
+import com.rainy.token.R
import com.rainy.token.ui.components.AppTips
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
+import com.rainy.token.util.LocaleManager
/**
* 设置页(雨晴风格重做版)。
@@ -72,6 +83,7 @@ fun SettingsScreen(
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold(
containerColor = Color.Transparent,
@@ -79,7 +91,7 @@ fun SettingsScreen(
TopAppBar(
title = {
Text(
- "设置",
+ stringResource(R.string.title_settings),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
@@ -88,7 +100,7 @@ fun SettingsScreen(
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
- contentDescription = "返回",
+ contentDescription = stringResource(R.string.action_back),
tint = StrawberryPink
)
}
@@ -118,7 +130,7 @@ fun SettingsScreen(
) {
item {
Text(
- text = "凭据管理",
+ text = stringResource(R.string.title_credentials),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = InkMuted,
@@ -131,6 +143,9 @@ fun SettingsScreen(
onClick = { onEditCredential(status.service) }
)
}
+ item {
+ LanguageCard(onClick = { showLanguageDialog = true })
+ }
item {
TipsCard(onClick = { onOpenTips() })
}
@@ -144,6 +159,105 @@ fun SettingsScreen(
}
}
}
+
+ if (showLanguageDialog) {
+ LanguageDialog(onDismiss = { showLanguageDialog = false })
+ }
+}
+
+@Composable
+private fun LanguageCard(onClick: () -> Unit) {
+ val context = LocalContext.current
+ val current = LocaleManager.getLocaleCode(context)
+ val labelRes = when (current) {
+ "zh" -> R.string.app_locale_zh
+ "zh-Hant" -> R.string.app_locale_zh_hant
+ "en" -> R.string.app_locale_en
+ else -> R.string.app_locale_follow_system
+ }
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() },
+ shape = RoundedCornerShape(20.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "🌐",
+ style = MaterialTheme.typography.titleLarge
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.title_language),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = stringResource(labelRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = InkMuted,
+ modifier = Modifier.padding(top = 2.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun LanguageDialog(onDismiss: () -> Unit) {
+ val context = LocalContext.current
+ val current = LocaleManager.getLocaleCode(context)
+ val options: List> = listOf(
+ null to R.string.app_locale_follow_system,
+ "zh" to R.string.app_locale_zh,
+ "zh-Hant" to R.string.app_locale_zh_hant,
+ "en" to R.string.app_locale_en
+ )
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.dialog_language_title)) },
+ text = {
+ Column {
+ options.forEach { (code, labelRes) ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ LocaleManager.saveLocale(context, code)
+ onDismiss()
+ // Android 13+:framework 设置语言后系统自动重建 Activity;
+ // 旧系统才需要手动 recreate 让资源按新语言重新解析
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ (context as? Activity)?.recreate()
+ }
+ }
+ .padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(labelRes),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f)
+ )
+ if (current == code) {
+ Text("✓", color = StrawberryPink, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ }
+ )
}
@Composable
@@ -190,13 +304,13 @@ private fun AboutCard() {
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
- text = "关于 RainyToken",
+ text = stringResource(R.string.title_about),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Text(
- text = "统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。",
+ text = stringResource(R.string.about_description),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
@@ -204,18 +318,19 @@ private fun AboutCard() {
}
}
+@Composable
private fun stateLabel(state: CredentialStatus.State): String = when (state) {
- CredentialStatus.State.NOT_CONFIGURED -> "未配置 · 点击添加"
- CredentialStatus.State.OK -> "已配置 · 凭据有效"
- CredentialStatus.State.EXPIRED -> "已过期 · 点击重新配置"
- CredentialStatus.State.WARNING -> "需要重新验证"
+ CredentialStatus.State.NOT_CONFIGURED -> stringResource(R.string.status_not_configured_add)
+ CredentialStatus.State.OK -> stringResource(R.string.status_ok_valid)
+ CredentialStatus.State.EXPIRED -> stringResource(R.string.status_expired_reconfigure)
+ CredentialStatus.State.WARNING -> stringResource(R.string.status_need_reverify)
}
private fun stateToChip(state: CredentialStatus.State): StatusStyle = when (state) {
- CredentialStatus.State.NOT_CONFIGURED -> StatusStyle("未配置", StatusLevel.WARNING)
- CredentialStatus.State.OK -> StatusStyle("已配置", StatusLevel.OK)
- CredentialStatus.State.EXPIRED -> StatusStyle("已过期", StatusLevel.ERROR)
- CredentialStatus.State.WARNING -> StatusStyle("需重登", StatusLevel.WARNING)
+ CredentialStatus.State.NOT_CONFIGURED -> StatusStyle(R.string.status_not_configured, StatusLevel.WARNING)
+ CredentialStatus.State.OK -> StatusStyle(R.string.status_configured, StatusLevel.OK)
+ CredentialStatus.State.EXPIRED -> StatusStyle(R.string.status_expired, StatusLevel.ERROR)
+ CredentialStatus.State.WARNING -> StatusStyle(R.string.status_relogin, StatusLevel.WARNING)
}
@Composable
@@ -239,13 +354,13 @@ private fun TipsCard(onClick: () -> Unit) {
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
- text = "使用小技巧",
+ text = stringResource(R.string.title_tips),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = StrawberryPink
)
Text(
- text = "查看全部 ${AppTips.tips.size} 条隐藏操作",
+ text = stringResource(R.string.tips_view_all, AppTips.tips.size),
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
@@ -276,13 +391,13 @@ private fun DebugLogCard(onClick: () -> Unit) {
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
- text = "调试日志",
+ text = stringResource(R.string.title_debug_log),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = InkMuted
)
Text(
- text = "查看 token 刷新、网络请求等调试记录",
+ text = stringResource(R.string.debug_log_desc),
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt
index ac36eae..2d9d026 100644
--- a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt
@@ -43,11 +43,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
+import com.rainy.token.ui.components.UiText
+import com.rainy.token.ui.components.asString
import com.rainy.token.ui.theme.StrawberryPink
/**
@@ -77,10 +81,10 @@ fun CodexOAuthScreen(
Scaffold(
topBar = {
TopAppBar(
- title = { Text("Codex OAuth 登录") },
+ title = { Text(stringResource(R.string.oauth_screen_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
- Icon(Icons.Filled.ArrowBack, contentDescription = "返回")
+ Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
@@ -100,7 +104,7 @@ fun CodexOAuthScreen(
) {
CircularProgressIndicator(color = StrawberryPink)
Text(
- "正在交换 Token...",
+ stringResource(R.string.oauth_exchanging),
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyLarge
)
@@ -116,12 +120,12 @@ fun CodexOAuthScreen(
verticalArrangement = Arrangement.Center
) {
Text(
- uiState.error!!,
+ uiState.error!!.asString(),
style = MaterialTheme.typography.bodyLarge,
color = androidx.compose.ui.graphics.Color(0xFFE91E63)
)
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
- Text("重试", color = StrawberryPink)
+ Text(stringResource(R.string.action_retry), color = StrawberryPink)
}
}
}
@@ -180,7 +184,7 @@ fun CodexOAuthScreen(
) {
CircularProgressIndicator(color = StrawberryPink)
Text(
- "正在准备授权...",
+ stringResource(R.string.oauth_preparing),
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyLarge
)
@@ -197,7 +201,7 @@ fun CodexOAuthScreen(
@Composable
private fun HeadlessOAuthContent(
authUrl: String,
- error: String?,
+ error: UiText?,
onCopyUrl: () -> Unit,
onOpenInBrowser: () -> Unit,
onSubmit: (String) -> Unit,
@@ -213,16 +217,12 @@ private fun HeadlessOAuthContent(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
- text = "无头模式登录",
+ text = stringResource(R.string.oauth_headless_title),
style = MaterialTheme.typography.titleMedium,
color = StrawberryPink
)
Text(
- text = "1. 点击「在浏览器中打开」或复制下方链接到浏览器\n" +
- "2. 在 OpenAI 页面完成登录和授权\n" +
- "3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n" +
- "4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n" +
- "5. 点击「提交回调 URL」",
+ text = stringResource(R.string.oauth_steps),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
@@ -231,7 +231,7 @@ private fun HeadlessOAuthContent(
OutlinedTextField(
value = authUrl,
onValueChange = {},
- label = { Text("授权链接") },
+ label = { Text(stringResource(R.string.oauth_auth_url_label)) },
readOnly = true,
minLines = 3,
maxLines = 5,
@@ -246,18 +246,18 @@ private fun HeadlessOAuthContent(
onClick = onOpenInBrowser,
modifier = Modifier.weight(1f)
) {
- Text("在浏览器中打开")
+ Text(stringResource(R.string.action_open_in_browser))
}
OutlinedButton(
onClick = onCopyUrl,
modifier = Modifier.weight(1f)
) {
- Text("复制链接")
+ Text(stringResource(R.string.action_copy_link))
}
}
Text(
- text = "── 登录后粘贴回调 URL ──",
+ text = stringResource(R.string.oauth_paste_callback_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
@@ -267,8 +267,8 @@ private fun HeadlessOAuthContent(
OutlinedTextField(
value = callbackUrl,
onValueChange = { callbackUrl = it },
- label = { Text("回调 URL") },
- placeholder = { Text("http://localhost:1455/auth/callback?code=...") },
+ label = { Text(stringResource(R.string.oauth_callback_url_label)) },
+ placeholder = { Text(stringResource(R.string.oauth_callback_url_placeholder)) },
minLines = 2,
maxLines = 6,
modifier = Modifier.fillMaxWidth()
@@ -279,18 +279,18 @@ private fun HeadlessOAuthContent(
modifier = Modifier.fillMaxWidth(),
enabled = callbackUrl.isNotBlank()
) {
- Text("提交回调 URL")
+ Text(stringResource(R.string.action_submit_callback))
}
if (error != null) {
Text(
- text = error,
+ text = error.asString(),
style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color(0xFFE91E63),
modifier = Modifier.padding(top = 4.dp)
)
TextButton(onClick = onRetry) {
- Text("重新生成授权链接", color = StrawberryPink)
+ Text(stringResource(R.string.action_regenerate_link), color = StrawberryPink)
}
}
}
diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt
index a89b11f..9d42c3a 100644
--- a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt
@@ -2,11 +2,13 @@ package com.rainy.token.ui.webview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CodexOAuthHelper
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
+import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -80,7 +82,14 @@ class CodexOAuthViewModel @Inject constructor(
*/
private fun handleCallbackUrl(url: String) {
if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) {
- _uiState.update { it.copy(error = "URL 应以 ${CodexOAuthHelper.CALLBACK_PREFIX} 开头") }
+ _uiState.update {
+ it.copy(
+ error = UiText.Resource(
+ R.string.error_oauth_url_prefix,
+ listOf(CodexOAuthHelper.CALLBACK_PREFIX)
+ )
+ )
+ }
return
}
@@ -92,19 +101,21 @@ class CodexOAuthViewModel @Inject constructor(
if (error != null) {
val errorDesc = uri.getQueryParameter("error_description") ?: error
DebugLog.e(TAG, "OAuth 授权失败: $errorDesc")
- _uiState.update { it.copy(error = "授权失败: $errorDesc") }
+ _uiState.update {
+ it.copy(error = UiText.Resource(R.string.error_oauth_auth_failed, listOf(errorDesc)))
+ }
return
}
if (receivedState != _uiState.value.expectedState) {
DebugLog.e(TAG, "OAuth state 不匹配(CSRF 防护)")
- _uiState.update { it.copy(error = "State 不匹配,请重试") }
+ _uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_state_mismatch)) }
return
}
if (code.isNullOrBlank()) {
DebugLog.e(TAG, "OAuth 回调缺少 code 参数")
- _uiState.update { it.copy(error = "回调缺少授权码") }
+ _uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_missing_code)) }
return
}
@@ -118,7 +129,12 @@ class CodexOAuthViewModel @Inject constructor(
if (result == null) {
DebugLog.e(TAG, "token 交换失败")
- _uiState.update { it.copy(exchanging = false, error = "Token 交换失败,请查看调试日志") }
+ _uiState.update {
+ it.copy(
+ exchanging = false,
+ error = UiText.Resource(R.string.error_oauth_token_exchange)
+ )
+ }
return@launch
}
@@ -149,5 +165,5 @@ data class CodexOAuthUiState(
val expectedState: String? = null,
val exchanging: Boolean = false,
val loginSucceeded: Boolean = false,
- val error: String? = null
+ val error: UiText? = null
)
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt
index 5151fcd..c2ad9fb 100644
--- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt
@@ -27,11 +27,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.rainy.token.R
import com.rainy.token.domain.service.ServiceType
+import com.rainy.token.ui.components.asString
/**
* 通用 WebView 登录容器。计划 4.1:
@@ -65,10 +68,10 @@ fun WebViewLoginScreen(
Scaffold(
topBar = {
TopAppBar(
- title = { Text("登录 ${service.displayName}") },
+ title = { Text(stringResource(R.string.title_webview_login, service.displayName)) },
navigationIcon = {
IconButton(onClick = onBack) {
- Icon(Icons.Filled.ArrowBack, contentDescription = "返回")
+ Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
@@ -113,7 +116,7 @@ fun WebViewLoginScreen(
.fillMaxSize()
.padding(16.dp)) {
Text(
- text = "未配置登录 URL",
+ text = stringResource(R.string.error_login_url_missing),
style = MaterialTheme.typography.bodyLarge
)
}
@@ -125,21 +128,20 @@ fun WebViewLoginScreen(
if (uiState.pendingManualConfirm && !uiState.loginSucceeded) {
AlertDialog(
onDismissRequest = { viewModel.dismissManualPrompt() },
- title = { Text("完成登录了吗?") },
+ title = { Text(stringResource(R.string.dialog_login_done_title)) },
text = {
Text(
- uiState.error ?: "如果你已经在页面上完成登录但页面没有跳转," +
- "可点击下方按钮让我抓取 Cookie 并保存登录态。"
+ uiState.error?.asString() ?: stringResource(R.string.dialog_login_done_body)
)
},
confirmButton = {
Button(onClick = { viewModel.confirmLoginManually() }) {
- Text("我已登录,保存")
+ Text(stringResource(R.string.action_login_save))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissManualPrompt() }) {
- Text("继续等待")
+ Text(stringResource(R.string.action_keep_waiting))
}
}
)
diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt
index 8cb01e8..780f21d 100644
--- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt
@@ -1,10 +1,12 @@
package com.rainy.token.ui.webview
import androidx.lifecycle.ViewModel
+import com.rainy.token.R
import com.rainy.token.data.repository.WebViewSessionSaver
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
+import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -75,7 +77,7 @@ class WebViewLoginViewModel @Inject constructor(
} else {
_uiState.update {
it.copy(
- error = "未抓到 Cookie,请确认已登录",
+ error = UiText.Resource(R.string.error_cookie_not_found),
pendingManualConfirm = true
)
}
@@ -96,5 +98,5 @@ data class WebViewLoginUiState(
val loginSucceeded: Boolean = false,
val savedSession: Credential.SessionCredential? = null,
val pendingManualConfirm: Boolean = false,
- val error: String? = null
+ val error: UiText? = null
)
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt
index 7c172c6..83932a5 100644
--- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt
+++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt
@@ -15,6 +15,7 @@ import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.cache.balanceCacheDataStore
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.normalizeWindowLabel
+import com.rainy.token.util.LocaleManager
import kotlinx.coroutines.runBlocking
import java.text.SimpleDateFormat
import java.util.Date
@@ -57,9 +58,20 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
) {
var selectedHasCachedData = false
+ // 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定,
+ // 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言
+ val localized = LocaleManager.wrapContext(context)
+
for (widgetId in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
+ // 署名动态设置:与行标签同源(跟随应用内语言),
+ // 避免 XML 静态文本由宿主进程按系统语言渲染造成中英混搭
+ views.setTextViewText(R.id.widget_brand, localized.getString(R.string.widget_brand))
+
+ // DeepSeek 余额标签同样动态设置(静态 XML 文本由 Launcher 按系统语言解析)
+ views.setTextViewText(R.id.widget_ds_label, localized.getString(R.string.widget_deepseek_balance))
+
// 点击左上角品牌 → 打开 APP;点击其它内容区域 → 切换服务;刷新按钮单独刷新。
val openAppIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -93,7 +105,10 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
val cache = BalanceCache(dataStore)
val selectedService = currentDisplayService(context)
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
- views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度")
+ views.setTextViewText(
+ R.id.widget_service_title,
+ localized.getString(R.string.widget_service_quota, selectedService.displayName)
+ )
views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService))
// Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的
if (selectedService == ServiceType.OLLAMA) {
@@ -106,15 +121,21 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
val cached = cache.get(selectedService)
if (cached != null) {
selectedHasCachedData = true
- populateServiceRows(views, selectedService, cached.balance)
+ populateServiceRows(views, localized, selectedService, cached.balance)
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
- val timeText = "更新 " + sdf.format(Date(cached.fetchedAt))
- views.setTextViewText(R.id.widget_updated, timeText)
+ val timeText = sdf.format(Date(cached.fetchedAt))
+ views.setTextViewText(
+ R.id.widget_updated,
+ localized.getString(R.string.widget_updated_at, timeText)
+ )
} else {
- setEmptyState(views)
+ setEmptyState(views, localized)
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
- views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度")
+ views.setTextViewText(
+ R.id.widget_service_title,
+ localized.getString(R.string.widget_service_quota, selectedService.displayName)
+ )
}
// DeepSeek 余额
@@ -127,7 +148,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
views.setTextViewText(R.id.widget_ds_amount, "—")
}
} catch (_: Exception) {
- setEmptyState(views)
+ setEmptyState(views, localized)
}
}
@@ -143,13 +164,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
private fun populateServiceRows(
views: RemoteViews,
+ context: Context,
service: ServiceType,
balance: com.rainy.token.domain.model.ServiceBalance
) {
val extras = balance.extras
when (service) {
ServiceType.OPENCODE_GO -> {
- setRowLabel(views, "5h", "本周", "本月")
+ setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly))
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
pct = extras["rolling.pct"]?.toIntOrNull(),
resetSec = extras["rolling.resetInSec"]?.toLongOrNull())
@@ -165,7 +187,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
if (used == null || cap == null || cap <= 0) return null
return ((used / cap) * 100).toInt().coerceIn(0, 100)
}
- setRowLabel(views, "5h", "本周", "本月")
+ setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly))
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
pct = calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()),
resetSec = extras["fiveHour.resetInSec"]?.toLongOrNull())
@@ -180,8 +202,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
val windowCount = extras.keys
.mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() }
.distinct().maxOrNull()?.plus(1) ?: 0
+ val weeklyLabel = context.getString(R.string.window_every_week)
+ val monthlyLabel = context.getString(R.string.window_every_month)
val windows = (0 until windowCount).map { index ->
- val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage")
+ val label = normalizeWindowLabel(
+ extras["window_$index.label"] ?: "Usage",
+ weeklyLabel = weeklyLabel,
+ monthlyLabel = monthlyLabel
+ )
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 })
@@ -195,8 +223,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
windows.firstOrNull { it.first.contains("5") } ?: windows.getOrNull(0) ?: Triple("5h", null, null)
}
val otherWindows = windows.filter { it != row1 }
- val row2 = otherWindows.getOrNull(0) ?: Triple("每周", null, null)
- val row3 = otherWindows.getOrNull(1) ?: Triple("每月", null, null)
+ 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)
listOf(
Triple(R.id.row1_pct, R.id.row1_bar, R.id.row1_reset),
@@ -207,9 +235,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
populateRow(views, ids.first, ids.second, ids.third, pct = window.second, resetSec = window.third)
}
}
- ServiceType.DEEPSEEK -> setEmptyState(views)
+ ServiceType.DEEPSEEK -> setEmptyState(views, context)
ServiceType.OLLAMA -> {
- setRowLabel(views, "5h", "每周", "")
+ setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_every_week), "")
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
pct = extras["session.pct"]?.toFloatOrNull()?.toInt(),
resetSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
@@ -223,8 +251,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
views.setTextViewText(R.id.row3_reset, "")
// Plan 信息合并到标题行
val plan = extras["plan"] ?: ""
- val titleText = if (plan.isNotEmpty()) "${service.displayName} · $plan"
- else "${service.displayName} · 额度"
+ val titleText = if (plan.isNotEmpty()) {
+ context.getString(R.string.widget_quota_with_plan, service.displayName, plan)
+ } else {
+ context.getString(R.string.widget_service_quota, service.displayName)
+ }
views.setTextViewText(R.id.widget_service_title, titleText)
}
}
@@ -269,8 +300,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color))
}
- private fun setEmptyState(views: RemoteViews) {
- views.setTextViewText(R.id.widget_updated, "暂无数据")
+ private fun setEmptyState(views: RemoteViews, context: Context) {
+ views.setTextViewText(R.id.widget_updated, context.getString(R.string.widget_no_data))
for (pctId in listOf(R.id.row1_pct, R.id.row2_pct, R.id.row3_pct)) {
views.setTextViewText(pctId, "—")
}
@@ -352,12 +383,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
}
fun showRefreshing(context: Context) {
+ // 与 onUpdate 同理:用最新语言偏好包装,避免进程启动后切换语言不生效
+ val localized = LocaleManager.wrapContext(context)
val appWidgetManager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
val ids = appWidgetManager.getAppWidgetIds(component)
ids.forEach { id ->
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
- views.setTextViewText(R.id.widget_updated, "刷新中…")
+ views.setTextViewText(R.id.widget_updated, localized.getString(R.string.widget_refreshing))
appWidgetManager.partiallyUpdateAppWidget(id, views)
}
}
diff --git a/app/src/main/java/com/rainy/token/util/LocaleManager.kt b/app/src/main/java/com/rainy/token/util/LocaleManager.kt
new file mode 100644
index 0000000..1d6258b
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/util/LocaleManager.kt
@@ -0,0 +1,98 @@
+package com.rainy.token.util
+
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Build
+import android.os.LocaleList
+import java.util.Locale
+
+/**
+ * 应用内语言偏好管理(自建轻量方案,不引入 appcompat 依赖)。
+ *
+ * 语言值约定:
+ * - `null`(未设置)= 跟随系统
+ * - `"zh"` = 简体中文(匹配 values-b+zh+Hans,BCP-47 写法,覆盖中国大陆等简体地区)
+ * - `"zh-Hant"` = 繁体中文(匹配 values-b+zh+Hant,BCP-47 写法,覆盖台/港/澳所有繁体地区)
+ * - `"en"` = English(匹配默认 values/ 英文资源)
+ *
+ * 注:AGP 9 资源合并器不接受 values-zh-Hans/values-zh-Hant 目录名,故用等价的
+ * BCP-47 写法 values-b+zh+Hans / values-b+zh+Hant;编译产物同为
+ * language=zh + script=Hans/Hant 配置。
+ *
+ * 在 [Application] 与 [MainActivity] 的 attachBaseContext 中调用 [wrapContext]
+ * 应用偏好;切换语言后由设置页保存偏好并 recreate Activity 触发重新解析。
+ *
+ * Android 13+(TIRAMISU)与系统"应用语言"页同步:读写统一走 framework 的
+ * [android.app.LocaleManager],私有 SharedPreferences 仅在 API < 33 或
+ * framework 未设置时作为兜底——避免应用内偏好与系统级设置互相覆盖造成双源冲突。
+ */
+object LocaleManager {
+
+ private const val PREFS_NAME = "locale_prefs"
+ private const val KEY_LOCALE = "locale"
+
+ /** 当前生效的语言代码;null = 跟随系统 */
+ fun getLocaleCode(context: Context): String? {
+ // Android 13+:framework 是唯一权威源(系统"应用语言"页与应用内选择共用)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val locales = context.getSystemService(android.app.LocaleManager::class.java)?.applicationLocales
+ if (locales != null && !locales.isEmpty) {
+ for (i in 0 until locales.size()) {
+ val l = locales.get(i)
+ when {
+ l.language == "zh" && l.script != "Hant" -> return "zh"
+ l.language == "zh" && l.script == "Hant" -> return "zh-Hant"
+ l.language == "en" -> return "en"
+ }
+ }
+ return null // 系统选了不支持的第三方语言 → 回退默认英文资源
+ }
+ // framework 未设置(跟随系统):一次性迁移旧 SharedPreferences 偏好到
+ // framework 后清除,避免升级设备上过期偏好复活(framework 才是唯一权威源)
+ migrateLegacyPrefs(context)
+ return null
+ }
+ // API < 33:回退私有偏好
+ val code = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(KEY_LOCALE, null) ?: return null
+ return code.takeIf { it == "zh" || it == "zh-Hant" || it == "en" }
+ }
+
+ /** 把 API < 33 时代写入的私有偏好一次性迁移到 framework 并清除 */
+ private fun migrateLegacyPrefs(context: Context) {
+ // 方法体内守卫:lint 据此识别 API33 调用(@RequiresApi 注解在本环境解析异常)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val code = prefs.getString(KEY_LOCALE, null) ?: return
+ context.getSystemService(android.app.LocaleManager::class.java)
+ ?.setApplicationLocales(LocaleList.forLanguageTags(code))
+ prefs.edit().remove(KEY_LOCALE).apply()
+ }
+
+ /** 保存语言偏好;传 null 表示跟随系统 */
+ fun saveLocale(context: Context, code: String?) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ // Android 13+:写入系统"应用语言",framework 生效并自动重建 Activity
+ context.getSystemService(android.app.LocaleManager::class.java)
+ ?.setApplicationLocales(LocaleList.forLanguageTags(code ?: ""))
+ return
+ }
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_LOCALE, code)
+ .apply()
+ }
+
+ /** 把 base context 包装上应用语言配置;未设置偏好时原样返回 */
+ fun wrapContext(base: Context): Context {
+ val code = getLocaleCode(base) ?: return base
+ val locale = when (code) {
+ "zh" -> Locale.SIMPLIFIED_CHINESE
+ "zh-Hant" -> Locale.forLanguageTag("zh-Hant")
+ else -> Locale.ENGLISH
+ }
+ val config = Configuration(base.resources.configuration)
+ config.setLocales(android.os.LocaleList(locale))
+ return base.createConfigurationContext(config)
+ }
+}
diff --git a/app/src/main/res/layout-night/widget_opencode_go.xml b/app/src/main/res/layout-night/widget_opencode_go.xml
index ded7774..a686d54 100644
--- a/app/src/main/res/layout-night/widget_opencode_go.xml
+++ b/app/src/main/res/layout-night/widget_opencode_go.xml
@@ -64,6 +64,8 @@
android:id="@+id/widget_updated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:maxLines="1"
+ android:ellipsize="end"
android:text="--"
android:textColor="#D4C4CC"
android:textSize="10sp" />
@@ -96,7 +98,7 @@
android:id="@+id/widget_service_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="OpenCode Go · 用量"
+ android:text="@string/widget_title_opencode"
android:textColor="#C4A0AB"
android:textSize="11sp" />
@@ -109,7 +111,8 @@
android:gravity="center_vertical"
android:layout_marginBottom="3dp">
+ android:layout_width="44dp" android:layout_height="wrap_content"
+ android:maxLines="1" android:ellipsize="end"
+ android:text="@string/window_weekly" android:textColor="#C4A0AB" android:textSize="11sp" />
+ android:layout_width="44dp" android:layout_height="wrap_content"
+ android:maxLines="1" android:ellipsize="end"
+ android:text="@string/window_monthly" android:textColor="#C4A0AB" android:textSize="11sp" />
diff --git a/app/src/main/res/layout/widget_opencode_go.xml b/app/src/main/res/layout/widget_opencode_go.xml
index b2e8810..f22080c 100644
--- a/app/src/main/res/layout/widget_opencode_go.xml
+++ b/app/src/main/res/layout/widget_opencode_go.xml
@@ -66,6 +66,8 @@
android:id="@+id/widget_updated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:maxLines="1"
+ android:ellipsize="end"
android:text="--"
android:textColor="#C4A0AB"
android:textSize="10sp" />
@@ -102,7 +104,7 @@
android:id="@+id/widget_service_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="OpenCode Go · 用量"
+ android:text="@string/widget_title_opencode"
android:textColor="#8A7A82"
android:textSize="11sp" />
@@ -116,7 +118,8 @@
android:layout_marginBottom="3dp">
+ android:layout_width="44dp" android:layout_height="wrap_content"
+ android:maxLines="1" android:ellipsize="end"
+ android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
+ android:layout_width="44dp" android:layout_height="wrap_content"
+ android:maxLines="1" android:ellipsize="end"
+ android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
@@ -247,9 +253,10 @@
android:indeterminate="false" />
diff --git a/app/src/main/res/layout/widget_opencode_go_preview.xml b/app/src/main/res/layout/widget_opencode_go_preview.xml
index c6927ff..a33984a 100644
--- a/app/src/main/res/layout/widget_opencode_go_preview.xml
+++ b/app/src/main/res/layout/widget_opencode_go_preview.xml
@@ -41,7 +41,7 @@
@@ -63,7 +63,7 @@
@@ -77,7 +77,8 @@
android:layout_marginBottom="6dp">
+ android:layout_width="44dp" android:layout_height="wrap_content"
+ android:maxLines="1" android:ellipsize="end"
+ android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
+ android:layout_width="44dp" android:layout_height="wrap_content"
+ android:maxLines="1" android:ellipsize="end"
+ android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties
new file mode 100644
index 0000000..6005758
--- /dev/null
+++ b/app/src/main/res/resources.properties
@@ -0,0 +1,5 @@
+# 默认(无 qualifier)资源对应的语言:英文(国际回退语言)
+# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-b+zh+Hans / values-b+zh+Hant
+# AGP 据此 + 中文资源目录生成 locale config,Android 13+ 系统设置中
+# 会显示 RainyToken 的独立语言选项(English / 简体中文 / 繁體中文)
+unqualifiedResLocale=en
diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml
new file mode 100644
index 0000000..8579044
--- /dev/null
+++ b/app/src/main/res/values-b+zh+Hans/strings.xml
@@ -0,0 +1,495 @@
+
+
+
+ AI用量面板
+ OpenCode Go 用量 + DeepSeek 余额,自动刷新
+ 雨晴喵
+ 雨晴Token
+
+
+
+
+ 返回
+ 取消
+ 确认添加
+ 我知道了
+ 刷新
+ 设置
+ 删除
+ 清除
+ 保存
+ 跳转
+ ◀ 上一页
+ 下一页 ▶
+ 添加小组件到桌面
+ 语言
+ 选择语言
+ 跟随系统
+ 简体中文
+ 繁體中文
+ English
+ 查看详情
+ 查看用量详情
+ 详细数据
+ 同步用量数据
+ 我已登录,保存
+ 继续等待
+
+
+ 加载中…
+ 未知错误
+ 全部模型
+
+
+ 👈 选择服务查看详情
+ 点击左侧卡片即可
+ 设置
+ 凭据管理
+ 关于 RainyToken
+ 使用小技巧
+ 调试日志
+ 用量详情
+ 总统计
+ 详细数据
+ 统计图表
+
+
+ 未配置
+ 已配置
+ 已过期
+ 需重登
+ 刷新失败
+ 未获取
+ 待获取
+ 正常
+ 未配置 · 点击添加
+ 已配置 · 凭据有效
+ 已过期 · 点击重新配置
+ 需要重新验证
+ 手动输入模式
+ 凭据已配置
+ 未配置凭据
+ 加载中
+ 最新
+ 缓存
+ 失败
+ 待输入
+
+
+ AI 余额一览
+ 从未获取
+ 刚刚更新
+ %1$d 分钟前更新
+ %1$d 小时前更新
+ %1$s 更新
+ 💡 长按卡片可拖拽排序
+ ⚠ %1$s
+
+ 本地加密存储,不上传任何数据
+ 添加桌面小组件?
+ 确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。
+ 桌面快捷方式权限已被拒绝,请在系统设置中允许后重试
+ 桌面未接受添加请求,请检查启动器权限
+ 当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token
+
+
+ 点击配置凭据
+ 下拉刷新
+ 服务当前不可用
+ 本月已用 %1$s%2$s
+ 剩余
+ · 已用 %1$s / 共 %2$s
+ %1$s 已用
+ 5h 已用
+ · %1$s
+ %1$s 后重置
+ REST API · ¥
+ WebView 抓取 · 5h 配额
+ JSON API · $
+ ChatGPT Plus · Codex 额度
+ Cookie 抓取 · Cloud 配额
+
+
+ 天
+ 小时
+ 分
+
+
+ 5 小时
+ 5h
+ 5h 用量
+ 5 小时滚动
+ 本周
+ 本月
+ 每月
+ 用量
+ 用量窗口%1$s
+ 用量窗口
+
+
+ OpenCode 用量
+ CommandCode 用量
+ %1$d 条记录
+ 点击同步
+ 输入 Token
+ 输出 Token
+ 推理 Token
+ 总花费
+ 缓存读取 %1$s
+ 缓存写入 %1$s
+ 暂无数据,点击 🔄 同步
+ ✓ 新增 %1$d 条
+ 部分同步完成:插入 %1$d条,%2$d页失败
+ 点击切换
+ 所有时间按 UTC+0 计算
+ 已切换到 UTC+8,图表按北京时间显示
+ 已切换到 UTC+0,图表按世界协调时显示
+ 已用 $%1$s / 共 $%2$s
+ 剩余 $%1$s
+ 额外充值
+ 按模型统计
+ 按天统计(UTC+0)
+ %1$d 个模型
+ %1$d 个
+ 开始时间
+ 结束时间
+
+
+ 消耗金额 (USD)
+ API 请求次数
+ Token 消耗
+ 输出
+ 输入(未命中)
+ 命中缓存
+
+
+ 时间
+ 模型
+ 输入(缓存)
+ 输出
+ 成本
+
+
+ 全部
+ 最近5小时
+ 最近12小时
+ 最近24小时
+ 今天
+ 昨天
+ 最近7天
+ 最近30天
+ 当月
+ 自定义
+ 自定义天
+ 自定义月
+ 自定义时间
+
+
+ 统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。
+ 查看全部 %1$d 条隐藏操作
+ 查看 token 刷新、网络请求等调试记录
+
+
+ ✓ 已配置登录态(%1$d 个 Cookie)
+ 如何获取 Cookie?
+ 1. 在桌面 Chrome 打开下方登录入口
+ 2. 用 GitHub / Google 登录(不会被拦截)
+ 3. 登录后按 F12 → Application → Cookies
+ 4. 复制该站点下的所有 Cookie 粘贴到上面输入框
+ 格式:name1=value1; name2=value2
+ 如何获取 OpenCode Go 凭据?
+ 1. 桌面 Chrome 打开 opencode.ai/auth
+ 2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)
+ 3. 登录后跳转到 dashboard,URL 形如:
+ https://opencode.ai/workspace/{id}/go
+ 4. 复制 URL 中的 {id} 段 → workspaceId
+ 5. F12 → Application → Cookies
+ 6. 找到域名 opencode.ai 下名为 auth 的 Cookie
+ 7. 复制它的 Value 字段 → auth cookie
+ 注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串
+ 如何获取 CommandCode Go 凭据?
+ 1. 打开 https://commandcode.ai 并登录 GitHub
+ ▸ 获取 API Key(必填):
+ 右上角头像 → Settings → API Keys → 创建一个 Key
+ ▸ 获取全部 Cookie(用于用量记录):
+ F12 → Application → Cookies → commandcode.ai
+ 选中所有 Cookie,复制整个 Cookie 字符串
+ 粘贴到下方输入框即可,无需逐个挑选
+ 提示:不填 Cookie 也能正常查看余额,只是用量记录为空。
+ 如何获取 Codex auth.json?
+ 1. 在终端运行 codex-oauth-proxy 或
+ codex-proxy,完成 ChatGPT 登录
+ 2. 凭证文件保存在:
+
+
+ 已保存
+ 已保存凭据
+ 连接成功!余额: %1$s %2$s
+ 连接成功,凭据已保存
+ API Key 不能为空
+ JSON 格式错误
+ 服务拒绝该凭据 (401/403)
+ 测试期间凭据已变更
+ 请求过于频繁 (429)
+ 服务端错误 (%1$d)
+ 网络错误:%1$s
+ ,已恢复原凭据
+ ,检测到凭据已变化,未执行回滚
+ 测试失败。%1$s%2$s
+ 暂不支持测试此服务
+ auth cookie 和 workspaceId 都需要填写
+ Cookie 不能为空
+ 凭据未配置
+ 凭据未配置或已过期
+ 凭据无效,请在设置中重新配置
+ 凭据无效,请重新登录
+ 凭据已变更,正在重新加载
+ 凭据已变更,请重新操作
+ 网络异常,请检查网络
+ 网络异常
+ 服务端异常 (HTTP %1$d)
+ 请求过于频繁%1$s
+ ,请 %1$d 秒后重试
+ 不支持激活用量
+ 请先选择模型
+ 未抓到 Cookie,请确认已登录
+ 未配置登录 URL
+ URL 应以 %1$s 开头
+ 授权失败: %1$s
+ State 不匹配,请重试
+ 回调缺少授权码
+ Token 交换失败,请查看调试日志
+
+
+ ✓ 用量已激活
+ ✓ 激活成功
+ 请求结果
+ 错误: %1$s
+ 响应内容:\n%1$s
+ 模型: %1$s
+ 响应 ID: %1$s
+ 回复:
+ (空)
+ (无回复内容)
+ (响应解析失败,但请求已发出)
+ 用量: prompt=%1$s completion=%2$s total=%3$s tokens
+ 用量: input=%1$s output=%2$s tokens
+ token 刷新失败,请重新登录或重新导入 auth.json
+
+
+ 登录 %1$s
+ 完成登录了吗?
+ 如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。
+
+
+ 暂无日志记录\n刷新 Codex 用量后这里会出现调试信息
+
+
+ OpenCode Go · 用量
+ DeepSeek 余额
+ 自动刷新
+ %1$s · 额度
+ %1$s · %2$s
+ 更新 %1$s
+ 暂无数据
+ 刷新中…
+
+
+ 长按卡片拖拽排序
+ 长按首页卡片可以拖拽排序
+ 在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。
+ 小组件点左上角进 APP
+ 小组件左上角标识可打开 APP
+ 桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。
+ 图表点击查看详情
+ 点击图表柱子可查看时段详情
+ 在用量图表上点击或滑动手指,可查看对应时段的详细数值。
+ 图表标题可点击
+ 点击图表标题弹出按模型明细
+ 图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。
+ 切换图表时区
+ 用量详情页可切换 UTC+0 / UTC+8
+ 用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。
+ CCGO 清除并重新同步
+ CCGO 详情页可清除缓存重新同步
+ CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。
+ 查看用量详情
+ 服务卡片底部可进入用量图表
+ 有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。
+ 原始数据浏览
+ 用量详情页右上角可查看原始记录
+ 用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。
+ 下拉刷新联动用量
+ 下拉刷新会同步余额和用量
+ 首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。
+ 凭据获取教程
+ 凭据页底部有获取教程
+ 凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。
+ 剪贴板自动导入
+ Codex 支持从剪贴板一键导入
+ Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。
+ 详细数据页码跳转
+ 原始数据页可输入页码直跳
+ 原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。
+ 小组件自动刷新
+ 划到小组件会自动刷新余额
+ 桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。
+
+
+ %1$d次
+ 选择日期
+ 选择月份(任意一天)
+ 开始日期
+ 结束日期
+ 消费明细
+ 调用次数明细
+ Token 明细
+ 清除数据
+ 此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。
+ 是否继续?
+ 确认(%1$ds)
+ 确认清除
+ 关闭
+ 从 %1$s
+ 至 %1$s
+ 应用自定义范围
+ 确定
+ 结束日期不能早于开始日期 %1$s
+ 时间 %1$s:%2$s
+ 暂无数据
+ 合计: %1$s
+ 总计
+ 记录数
+ 缓存读取
+ 缓存写入
+ 缓存 Token 细分
+ ※ 缓存读取已计入「输入 Token」
+ %1$d 次调用
+ MM月dd日 EEEE
+ %1$d 次 · $%2$s
+ 原始数据
+ 缓存写入(5m)
+ 缓存写入(1h)
+ 成本(USD)
+ 成本(Raw)
+
+
+ ~/.config/codex-oauth-proxy/auth.json
+ 3. 用文本编辑器打开该文件,
+ 复制全部内容粘贴到上方输入框
+ JSON 格式示例:
+ {"tokens": {
+ "access_token": "...",
+ "refresh_token": "...",
+ "expiresAt": 1783727108252
+ }}
+ 提示:导入后 APP 会自动刷新 token,
+ 不需要手动更新。
+ Ollama Pro 凭据
+ 需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。
+ 更新凭据
+ 保存凭据
+ 测试并保存
+ 在浏览器中打开 Ollama Settings
+ 复制登录 URL 到剪贴板
+ 一键激活用量 API Key(可选)
+ 填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。
+ ollama-xxx 或 sk-xxx
+ 保存 API Key
+ OpenCode Go 凭据
+ 需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。
+ 从剪贴板自动导入
+ dashboard URL 中的 {id} 段
+ auth Cookie 值
+ F12 → Cookies → opencode.ai → auth 的 Value
+ 如何获取这两个值?
+ 在浏览器中打开登录入口
+ 填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。
+ CommandCode Go 凭据
+ 需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。
+ 从 Settings → API Keys 生成
+ Session Cookie(可选)
+ F12 → Cookies → commandcode.ai → 全选复制粘贴即可
+ 如何获取 API Key 和 Cookie?
+ 请输入 API Key
+ 凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。
+ 更新
+ Codex / ChatGPT Plus 凭据
+ 推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。
+ 🔐 OAuth 登录(推荐)
+ ── 或手动导入 ──
+ auth.json 完整内容
+ 粘贴 {"tokens": {...}} 完整 JSON
+ 导入并保存
+ 如何获取 auth.json?
+ 粘贴登录态
+ Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。
+ Cookie 字符串
+ 保存 Cookie
+
+
+ 响应体为空
+ 响应根节点不是 JSON 对象
+ 未找到用量窗口数据
+ 未找到模型列表
+ 模型列表为空
+ 响应格式异常
+ 每周
+ 模型调用次数
+ 缓存数据 · %1$s
+ 显示的是上次成功获取的余额
+ 本月消费 %1$s%2$s
+ 余额构成
+ 赠送余额
+ 自费充值
+ 加载模型列表...
+ 选择模型
+ 刷新模型列表
+ 激活中...
+ 重试激活用量
+ ⚡ 一键激活用量
+ 刷新余额
+ 重新登录
+ 配置凭据
+ 当前余额
+ 5h 实时用量
+ 月度余额
+ Session 用量
+ 请选择模型
+ 错误详情:
+ API 响应内容:
+ 复制
+ *** (长度 %1$d) ***
+ %1$s***%2$s (长度 %3$d)
+ Cookie 格式错误,应为 name1=value1; name2=value2
+ 已保存 %1$d 个 Cookie
+ 请粘贴 auth.json 内容
+ auth.json 缺少 access_token 或 refresh_token
+ 已保存 Codex 凭据,token 到期后会自动刷新
+ 解析失败:%1$s
+ 连接成功!余额: $%1$s
+ API Key 已保存
+ 连接成功!Session: %1$s%% · %2$s
+ 凭据已删除
+ 剪贴板为空
+ 已识别 workspaceId 和 auth cookie
+ 已识别 workspaceId,请再粘贴 auth cookie
+ 已识别 auth cookie,请再粘贴 workspaceId
+ 未识别到有效凭据
+
+
+ Codex OAuth 登录
+ 正在交换 Token...
+ 重试
+ 无头模式登录
+ 1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」
+ 授权链接
+ 在浏览器中打开
+ 复制链接
+ ── 登录后粘贴回调 URL ──
+ 回调 URL
+ http://localhost:1455/auth/callback?code=...
+ 提交回调 URL
+ 重新生成授权链接
+ 正在准备授权...
+
\ No newline at end of file
diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml
new file mode 100644
index 0000000..fcdde38
--- /dev/null
+++ b/app/src/main/res/values-b+zh+Hant/strings.xml
@@ -0,0 +1,495 @@
+
+
+
+ AI用量面板
+ OpenCode Go 用量 + DeepSeek 餘額,自動重新整理
+ 雨晴喵
+ 雨晴Token
+
+
+
+
+ 返回
+ 取消
+ 確認新增
+ 我知道了
+ 重新整理
+ 設定
+ 刪除
+ 清除
+ 儲存
+ 跳轉
+ ◀ 上一頁
+ 下一頁 ▶
+ 新增小工具到桌面
+ 語言
+ 選擇語言
+ 跟隨系統
+ 簡體中文
+ 繁體中文
+ English
+ 檢視詳情
+ 檢視用量詳情
+ 詳細數據
+ 同步用量數據
+ 我已登入,儲存
+ 繼續等待
+
+
+ 載入中…
+ 未知錯誤
+ 全部模型
+
+
+ 👈 選擇服務檢視詳情
+ 點選左側卡片即可
+ 設定
+ 憑據管理
+ 關於 RainyToken
+ 使用小技巧
+ 除錯日誌
+ 用量詳情
+ 總統計
+ 詳細數據
+ 統計圖表
+
+
+ 未配置
+ 已配置
+ 已過期
+ 需重登
+ 重新整理失敗
+ 未獲取
+ 待獲取
+ 正常
+ 未配置 · 點選新增
+ 已配置 · 憑據有效
+ 已過期 · 點選重新配置
+ 需要重新驗證
+ 手動輸入模式
+ 憑據已配置
+ 未配置憑據
+ 載入中
+ 最新
+ 快取
+ 失敗
+ 待輸入
+
+
+ AI 餘額一覽
+ 從未獲取
+ 剛剛更新
+ %1$d 分鐘前更新
+ %1$d 小時前更新
+ %1$s 更新
+ 💡 長按卡片可拖曳排序
+ ⚠ %1$s
+
+ 本地加密儲存,不上傳任何數據
+ 新增桌面小工具?
+ 確認後會請求桌面啟動器新增雨晴Token小工具。部分系統仍會彈出系統確認視窗。
+ 桌面快捷方式權限已被拒絕,請在系統設定中允許後重試
+ 桌面未接受新增請求,請檢查啟動器權限
+ 當前桌面不支援應用內新增,請在桌面長按 → 小工具 → 查詢雨晴Token
+
+
+ 點選配置憑據
+ 下拉重新整理
+ 服務當前不可用
+ 本月已用 %1$s%2$s
+ 剩餘
+ · 已用 %1$s / 共 %2$s
+ %1$s 已用
+ 5h 已用
+ · %1$s
+ %1$s 後重置
+ REST API · ¥
+ WebView 抓取 · 5h 配額
+ JSON API · $
+ ChatGPT Plus · Codex 額度
+ Cookie 抓取 · Cloud 配額
+
+
+ 天
+ 小時
+ 分
+
+
+ 5 小時
+ 5h
+ 5h 用量
+ 5 小時滾動
+ 本週
+ 本月
+ 每月
+ 用量
+ 用量視窗%1$s
+ 用量視窗
+
+
+ OpenCode 用量
+ CommandCode 用量
+ %1$d 條記錄
+ 點選同步
+ 輸入 Token
+ 輸出 Token
+ 推理 Token
+ 總花費
+ 快取讀取 %1$s
+ 快取寫入 %1$s
+ 暫無數據,點選 🔄 同步
+ ✓ 新增 %1$d 條
+ 部分同步完成:插入 %1$d條,%2$d頁失敗
+ 點選切換
+ 所有時間按 UTC+0 計算
+ 已切換到 UTC+8,圖表按北京時間顯示
+ 已切換到 UTC+0,圖表按世界協調時顯示
+ 已用 $%1$s / 共 $%2$s
+ 剩餘 $%1$s
+ 額外充值
+ 按模型統計
+ 按天統計(UTC+0)
+ %1$d 個模型
+ %1$d 個
+ 開始時間
+ 結束時間
+
+
+ 消耗金額 (USD)
+ API 請求次數
+ Token 消耗
+ 輸出
+ 輸入(未命中)
+ 命中快取
+
+
+ 時間
+ 模型
+ 輸入(快取)
+ 輸出
+ 成本
+
+
+ 全部
+ 最近5小時
+ 最近12小時
+ 最近24小時
+ 今天
+ 昨天
+ 最近7天
+ 最近30天
+ 當月
+ 自定義
+ 自定義天
+ 自定義月
+ 自定義時間
+
+
+ 統一查詢 AI 餘額的小工具。\n本地加密儲存憑據,不上傳任何數據。
+ 檢視全部 %1$d 條隱藏操作
+ 檢視 token 重新整理、網路請求等除錯記錄
+
+
+ ✓ 已配置登入態(%1$d 個 Cookie)
+ 如何獲取 Cookie?
+ 1. 在桌面 Chrome 開啟下方登入入口
+ 2. 用 GitHub / Google 登入(不會被攔截)
+ 3. 登入後按 F12 → Application → Cookies
+ 4. 複製該站點下的所有 Cookie 貼上到上面輸入框
+ 格式:name1=value1; name2=value2
+ 如何獲取 OpenCode Go 憑據?
+ 1. 桌面 Chrome 開啟 opencode.ai/auth
+ 2. 用 GitHub / Google 登入(Google 不會攔截 Chrome)
+ 3. 登入後跳轉到 dashboard,URL 形如:
+ https://opencode.ai/workspace/{id}/go
+ 4. 複製 URL 中的 {id} 段 → workspaceId
+ 5. F12 → Application → Cookies
+ 6. 找到域名 opencode.ai 下名為 auth 的 Cookie
+ 7. 複製它的 Value 欄位 → auth cookie
+ 注意:只填 auth 一個 Cookie 即可,不要整個 Cookie 字串
+ 如何獲取 CommandCode Go 憑據?
+ 1. 開啟 https://commandcode.ai 並登入 GitHub
+ ▸ 獲取 API Key(必填):
+ 右上角頭像 → Settings → API Keys → 建立一個 Key
+ ▸ 獲取全部 Cookie(用於用量記錄):
+ F12 → Application → Cookies → commandcode.ai
+ 選中所有 Cookie,複製整個 Cookie 字串
+ 貼上到下方輸入框即可,無需逐個挑選
+ 提示:不填 Cookie 也能正常檢視餘額,只是用量記錄為空。
+ 如何獲取 Codex auth.json?
+ 1. 在終端執行 codex-oauth-proxy 或
+ codex-proxy,完成 ChatGPT 登入
+ 2. 憑證檔案儲存在:
+
+
+ 已儲存
+ 已儲存憑據
+ 連線成功!餘額: %1$s %2$s
+ 連線成功,憑據已儲存
+ API Key 不能為空
+ JSON 格式錯誤
+ 服務拒絕該憑據 (401/403)
+ 測試期間憑據已變更
+ 請求過於頻繁 (429)
+ 服務端錯誤 (%1$d)
+ 網路錯誤:%1$s
+ ,已恢復原憑據
+ ,檢測到憑據已變化,未執行回滾
+ 測試失敗。%1$s%2$s
+ 暫不支援測試此服務
+ auth cookie 和 workspaceId 都需要填寫
+ Cookie 不能為空
+ 憑據未配置
+ 憑據未配置或已過期
+ 憑據無效,請在設定中重新配置
+ 憑據無效,請重新登入
+ 憑據已變更,正在重新載入
+ 憑據已變更,請重新操作
+ 網路異常,請檢查網路
+ 網路異常
+ 服務端異常 (HTTP %1$d)
+ 請求過於頻繁%1$s
+ ,請 %1$d 秒後重試
+ 不支援啟用用量
+ 請先選擇模型
+ 未抓到 Cookie,請確認已登入
+ 未配置登入 URL
+ URL 應以 %1$s 開頭
+ 授權失敗: %1$s
+ State 不匹配,請重試
+ 回撥缺少授權碼
+ Token 交換失敗,請檢視除錯日誌
+
+
+ ✓ 用量已啟用
+ ✓ 啟用成功
+ 請求結果
+ 錯誤: %1$s
+ 響應內容:\n%1$s
+ 模型: %1$s
+ 響應 ID: %1$s
+ 回覆:
+ (空)
+ (無回覆內容)
+ (響應解析失敗,但請求已發出)
+ 用量: prompt=%1$s completion=%2$s total=%3$s tokens
+ 用量: input=%1$s output=%2$s tokens
+ token 重新整理失敗,請重新登入或重新匯入 auth.json
+
+
+ 登入 %1$s
+ 完成登入了嗎?
+ 如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。
+
+
+ 暫無日誌記錄\n重新整理 Codex 用量後這裡會出現除錯資訊
+
+
+ OpenCode Go · 用量
+ DeepSeek 餘額
+ 自動重新整理
+ %1$s · 額度
+ %1$s · %2$s
+ 更新 %1$s
+ 暫無數據
+ 重新整理中…
+
+
+ 長按卡片拖曳排序
+ 長按首頁卡片可以拖曳排序
+ 在首頁長按任意服務卡片即可拖曳到想要的位置,排序會自動儲存。
+ 小工具點左上角進 APP
+ 小工具左上角標識可開啟 APP
+ 桌面小工具左上角 RainyToken › 標識可以點選開啟應用,其它區域點選切換服務。
+ 圖表點選檢視詳情
+ 點選圖表柱子可檢視時段詳情
+ 在用量圖表上點選或滑動手指,可檢視對應時段的詳細數值。
+ 圖表標題可點選
+ 點選圖表標題彈出按模型明細
+ 圖表卡片標題(如「消耗金額」)點選後會彈出按模型拆分的明細。
+ 切換圖表時區
+ 用量詳情頁可切換 UTC+0 / UTC+8
+ 用量詳情頁頂部可切換 UTC+0 / UTC+8,圖表標籤會跟隨切換。
+ CCGO 清除並重新同步
+ CCGO 詳情頁可清除快取重新同步
+ CCGO 用量詳情頁右上角「清除」可刪除本地快取並重新拉取全部數據。
+ 檢視用量詳情
+ 服務卡片底部可進入用量圖表
+ 有憑據的服務卡片底部會出現「檢視用量詳情」入口,點選進入圖表統計。
+ 原始數據瀏覽
+ 用量詳情頁右上角可檢視原始記錄
+ 用量詳情頁右上角「詳細數據」可檢視每條原始呼叫記錄,支援分頁跳轉。
+ 下拉重新整理聯動用量
+ 下拉重新整理會同步餘額和用量
+ 首頁下拉重新整理不僅重新整理餘額,還會自動同步 OCGO / CCGO 用量記錄。
+ 憑據獲取教程
+ 憑據頁底部有獲取教程
+ 憑據編輯頁底部的「如何獲取」按鈕有針對每個服務的詳細操作步驟。
+ 剪貼簿自動匯入
+ Codex 支援從剪貼簿一鍵匯入
+ Codex 憑據頁支援「從剪貼簿自動匯入」,貼上 auth.json 後一鍵填入。
+ 詳細數據頁碼跳轉
+ 原始數據頁可輸入頁碼直跳
+ 原始數據頁底部可輸入頁碼直接跳轉,數據量大時不必逐頁翻。
+ 小工具自動重新整理
+ 劃到小工具會自動重新整理餘額
+ 桌面小工具劃到負一屏時會自動重新整理餘額,也可手動點 ↻ 按鈕重新整理。
+
+
+ %1$d次
+ 選擇日期
+ 選擇月份(任意一天)
+ 開始日期
+ 結束日期
+ 消費明細
+ 呼叫次數明細
+ Token 明細
+ 清除數據
+ 此操作將清除所有 CommandCode 用量本地快取並重新同步,需要幾秒鐘完成。
+ 是否繼續?
+ 確認(%1$ds)
+ 確認清除
+ 關閉
+ 從 %1$s
+ 至 %1$s
+ 應用自定義範圍
+ 確定
+ 結束日期不能早於開始日期 %1$s
+ 時間 %1$s:%2$s
+ 暫無數據
+ 合計: %1$s
+ 總計
+ 記錄數
+ 快取讀取
+ 快取寫入
+ 快取 Token 細分
+ ※ 快取讀取已計入「輸入 Token」
+ %1$d 次呼叫
+ MM月dd日 EEEE
+ %1$d 次 · $%2$s
+ 原始數據
+ 快取寫入(5m)
+ 快取寫入(1h)
+ 成本(USD)
+ 成本(Raw)
+
+
+ ~/.config/codex-oauth-proxy/auth.json
+ 3. 用文字編輯器開啟該檔案,
+ 複製全部內容貼上到上方輸入框
+ JSON 格式示例:
+ {"tokens": {
+ "access_token": "...",
+ "refresh_token": "...",
+ "expiresAt": 1783727108252
+ }}
+ 提示:匯入後 APP 會自動重新整理 token,
+ 不需要手動更新。
+ Ollama Pro 憑據
+ 需要從瀏覽器複製完整 Cookie。登入 ollama.com/settings 後,在 DevTools → Network → 請求頭裡找到 Cookie 行複製。
+ 更新憑據
+ 儲存憑據
+ 測試並儲存
+ 在瀏覽器中開啟 Ollama Settings
+ 複製登入 URL 到剪貼簿
+ 一鍵啟用用量 API Key(可選)
+ 填入 Ollama Cloud API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 ollama.com/settings → API Keys 獲取。
+ ollama-xxx 或 sk-xxx
+ 儲存 API Key
+ OpenCode Go 憑據
+ 需要 auth cookie + workspaceId 兩個值。APP 會用 OkHttp 抓 dashboard 並自動解析 5h/周/月 三檔用量。
+ 從剪貼簿自動匯入
+ dashboard URL 中的 {id} 段
+ auth Cookie 值
+ F12 → Cookies → opencode.ai → auth 的 Value
+ 如何獲取這兩個值?
+ 在瀏覽器中開啟登入入口
+ 填入 OpenCode Zen API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。
+ CommandCode Go 憑據
+ 需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。
+ 從 Settings → API Keys 生成
+ Session Cookie(可選)
+ F12 → Cookies → commandcode.ai → 全選複製貼上即可
+ 如何獲取 API Key 和 Cookie?
+ 請輸入 API Key
+ 憑據會使用 Android Keystore (AES-256 GCM) 加密後儲存到本機 DataStore。
+ 更新
+ Codex / ChatGPT Plus 憑據
+ 推薦使用 OAuth 登入自動獲取憑據。也可手動貼上 auth.json 內容。
+ 🔐 OAuth 登入(推薦)
+ ── 或手動匯入 ──
+ auth.json 完整內容
+ 貼上 {"tokens": {...}} 完整 JSON
+ 匯入並儲存
+ 如何獲取 auth.json?
+ 貼上登入態
+ Google OAuth 會拒絕 APP 內建 WebView,所以請用瀏覽器登入後把 Cookie 粘到這裡。
+ Cookie 字串
+ 儲存 Cookie
+
+
+ 回應體為空
+ 回應根節點不是 JSON 物件
+ 未找到用量視窗資料
+ 未找到模型清單
+ 模型清單為空
+ 回應格式異常
+ 每週
+ 模型呼叫次數
+ 快取數據 · %1$s
+ 顯示的是上次成功獲取的餘額
+ 本月消費 %1$s%2$s
+ 餘額構成
+ 贈送餘額
+ 自費充值
+ 載入模型列表...
+ 選擇模型
+ 重新整理模型列表
+ 啟用中...
+ 重試啟用用量
+ ⚡ 一鍵啟用用量
+ 重新整理餘額
+ 重新登入
+ 配置憑據
+ 當前餘額
+ 5h 實時用量
+ 月度餘額
+ Session 用量
+ 請選擇模型
+ 錯誤詳情:
+ API 響應內容:
+ 複製
+ *** (長度 %1$d) ***
+ %1$s***%2$s (長度 %3$d)
+ Cookie 格式錯誤,應為 name1=value1; name2=value2
+ 已儲存 %1$d 個 Cookie
+ 請貼上 auth.json 內容
+ auth.json 缺少 access_token 或 refresh_token
+ 已儲存 Codex 憑據,token 到期後會自動重新整理
+ 解析失敗:%1$s
+ 連線成功!餘額: $%1$s
+ API Key 已儲存
+ 連線成功!Session: %1$s%% · %2$s
+ 憑據已刪除
+ 剪貼簿為空
+ 已識別 workspaceId 和 auth cookie
+ 已識別 workspaceId,請再貼上 auth cookie
+ 已識別 auth cookie,請再貼上 workspaceId
+ 未識別到有效憑據
+
+
+ Codex OAuth 登入
+ 正在交換 Token...
+ 重試
+ 無頭模式登入
+ 1. 點選「在瀏覽器中開啟」或複製下方連結到瀏覽器\n2. 在 OpenAI 頁面完成登入和授權\n3. 瀏覽器會跳轉到一個 localhost 地址(頁面打不開是正常的)\n4. 複製瀏覽器位址列的完整 URL 貼上到下方輸入框\n5. 點選「提交回撥 URL」
+ 授權連結
+ 在瀏覽器中開啟
+ 複製連結
+ ── 登入後貼上回撥 URL ──
+ 回撥 URL
+ http://localhost:1455/auth/callback?code=...
+ 提交回撥 URL
+ 重新生成授權連結
+ 正在準備授權...
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a787889..e32c4bc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,498 @@
+
- 雨晴Token
- AI用量面板
-OpenCode Go 用量 + DeepSeek 余额,自动刷新
-
\ No newline at end of file
+
+ RainyToken
+ RainyToken · v1.0
+
+
+ RainyToken
+
+
+ AI Usage Panel
+ OpenCode Go usage + DeepSeek balance, auto-refresh
+ Rainy
+
+
+ Back
+ Cancel
+ Add Widget
+ Got it
+ Refresh
+ Settings
+ Delete
+ Clear
+ Save
+ Go
+ ◀ Prev
+ Next ▶
+ Add widget to home screen
+ Language
+ Select language
+ Follow system
+ 简体中文
+ 繁體中文
+ English
+ View details
+ View usage details
+ Raw data
+ Sync usage data
+ I\'m logged in, save
+ Keep waiting
+
+
+ Loading…
+ Unknown error
+ All models
+
+
+ 👈 Select a service to view details
+ Tap a card on the left
+ Settings
+ Credentials
+ About RainyToken
+ Tips
+ Debug log
+ Usage details
+ Overview
+ Raw data
+ Charts
+
+
+ Not set up
+ Configured
+ Expired
+ Re-login
+ Refresh failed
+ No data
+ Pending
+ OK
+ Not set up · Tap to add
+ Configured · Valid
+ Expired · Tap to reconfigure
+ Needs re-verification
+ Manual entry mode
+ Credential set
+ No credential
+ Loading
+ Latest
+ Cached
+ Failed
+ Awaiting input
+
+
+ AI Balance Overview
+ Never fetched
+ Updated just now
+ Updated %1$d min ago
+ Updated %1$d h ago
+ Updated %1$s
+ 💡 Long-press a card to reorder
+ ⚠ %1$s
+ Stored locally & encrypted, nothing is uploaded
+ Add home screen widget?
+ This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog.
+ Shortcut permission denied. Please allow it in system settings and try again.
+ The launcher rejected the request. Please check launcher permissions.
+ This launcher does not support in-app adding. Long-press home → Widgets → find RainyToken.
+
+
+ Tap to configure credentials
+ Pull to refresh
+ Service currently unavailable
+ %1$s%2$s used this month
+ Remaining
+ · %1$s used / %2$s total
+ %1$s used
+ 5h used
+ · %1$s
+ Resets in %1$s
+ REST API · ¥
+ WebView fetch · 5h quota
+ JSON API · $
+ ChatGPT Plus · Codex quota
+ Cookie fetch · Cloud quota
+
+
+ d
+ h
+ m
+
+
+ 5 hours
+ 5h
+ 5h usage
+ 5-hour rolling
+ Week
+ Month
+ Monthly
+ Usage
+ Usage window%1$s
+ Usage window
+
+
+ OpenCode Usage
+ CommandCode Usage
+ %1$d records
+ Tap to sync
+ Input tokens
+ Output tokens
+ Reasoning tokens
+ Total cost
+ Cache read %1$s
+ Cache write %1$s
+ No data yet · tap 🔄 to sync
+ ✓ %1$d new records
+ Partial sync: %1$d records inserted, %2$d pages failed
+ tap to switch
+ All times in UTC+0
+ Switched to UTC+8, charts shown in Beijing time
+ Switched to UTC+0, charts shown in UTC
+ $%1$s spent / $%2$s total
+ $%1$s remaining
+ Extra credits
+ By model
+ By day (UTC+0)
+ %1$d models
+ %1$d
+ Start time
+ End time
+
+
+ Spending (USD)
+ API requests
+ Token usage
+ Output
+ Input (miss)
+ Cache hit
+
+
+ Time
+ Model
+ Input(cache)
+ Output
+ Cost
+
+
+ All
+ Last 5 hours
+ Last 12 hours
+ Last 24 hours
+ Today
+ Yesterday
+ Last 7 days
+ Last 30 days
+ This month
+ Custom
+ Custom day
+ Custom month
+ Custom range
+
+
+ A tiny tool to check AI balances.\nCredentials are encrypted locally — nothing is uploaded.
+ View all %1$d hidden tips
+ Inspect token refresh, network requests and other debug records
+
+
+ ✓ Login state configured (%1$d cookies)
+ How to get the Cookie?
+ 1. Open the login page below in desktop Chrome
+ 2. Sign in with GitHub / Google (won\'t be blocked)
+ 3. After login, press F12 → Application → Cookies
+ 4. Copy all cookies of the site and paste them above
+ Format: name1=value1; name2=value2
+ How to get OpenCode Go credentials?
+ 1. Open opencode.ai/auth in desktop Chrome
+ 2. Sign in with GitHub / Google (Google won\'t block Chrome)
+ 3. You\'ll land on the dashboard, URL looks like:
+ https://opencode.ai/workspace/{id}/go
+ 4. Copy the {id} segment → workspaceId
+ 5. F12 → Application → Cookies
+ 6. Find the cookie named auth under opencode.ai
+ 7. Copy its Value field → auth cookie
+ Note: only the auth cookie is needed, not the whole cookie string
+ How to get CommandCode Go credentials?
+ 1. Open https://commandcode.ai and sign in with GitHub
+ ▸ Get API Key (required):
+ Avatar (top right) → Settings → API Keys → Create a Key
+ ▸ Get all cookies (for usage records):
+ F12 → Application → Cookies → commandcode.ai
+ Select all cookies and copy the whole string
+ Paste it below — no need to pick them one by one
+ Tip: balance works without cookies, usage records will just be empty.
+ How to get Codex auth.json?
+ 1. Run codex-oauth-proxy or
+ codex-proxy in a terminal to sign in to ChatGPT
+ 2. The credential file is saved at:
+
+
+ Saved
+ Credentials saved
+ Connected! Balance: %1$s %2$s
+ Connected, credentials saved
+ API Key cannot be empty
+ Invalid JSON format
+ Service rejected this credential (401/403)
+ Credentials changed during test
+ Too many requests (429)
+ Server error (%1$d)
+ Network error: %1$s
+ , previous credential restored
+ , credential changed, rollback skipped
+ Test failed. %1$s%2$s
+ Testing is not supported for this service
+ Both auth cookie and workspaceId are required
+ Cookie cannot be empty
+ Credential not configured
+ Credential missing or expired
+ Credential invalid, please reconfigure in Settings
+ Credential invalid, please log in again
+ Credentials changed, reloading
+ Credentials changed, please retry
+ Network error, please check your connection
+ Network error
+ Server error (HTTP %1$d)
+ Too many requests%1$s
+ , retry in %1$d seconds
+ Usage activation is not supported
+ Please select a model first
+ No cookie captured, please make sure you\'re logged in
+ No login URL configured
+ URL should start with %1$s
+ Authorization failed: %1$s
+ State mismatch, please retry
+ Callback missing authorization code
+ Token exchange failed, check the debug log
+
+
+ ✓ Usage activated
+ ✓ Activation successful
+ Request result
+ Error: %1$s
+ Response body:\n%1$s
+ Model: %1$s
+ Response ID: %1$s
+ Reply:
+ (empty)
+ (no reply content)
+ (response parsing failed, but the request was sent)
+ Usage: prompt=%1$s completion=%2$s total=%3$s tokens
+ Usage: input=%1$s output=%2$s tokens
+ Token refresh failed. Please log in again or import auth.json.
+
+
+ Sign in to %1$s
+ Done logging in?
+ If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session.
+
+
+ No log entries yet\nRefresh Codex usage and debug info will show up here
+
+
+ OpenCode Go · Usage
+ DeepSeek balance
+ Auto refresh
+ %1$s · Quota
+ %1$s · %2$s
+ Updated %1$s
+ No data
+ Refreshing…
+
+
+ Reorder cards by long-press
+ Long-press a card to reorder it
+ Long-press any service card on the home screen and drag it wherever you like. The order is saved automatically.
+ Widget top-left opens the app
+ Tap the widget\'s top-left mark to open the app
+ The RainyToken › mark at the top-left of the widget opens the app. Tapping elsewhere switches services.
+ Tap charts for details
+ Tap a chart bar to see that time slot
+ Tap or slide on the usage charts to inspect the exact values of a time slot.
+ Chart titles are clickable
+ Tap a chart title for a per-model breakdown
+ Tapping a chart card title (e.g. "Spending") shows a per-model breakdown dialog.
+ Switch chart timezone
+ Usage details can toggle UTC+0 / UTC+8
+ The usage details page can toggle between UTC+0 and UTC+8; chart labels follow along.
+ CCGO clear & resync
+ CCGO details can clear cache and resync
+ The "Clear" button on the CCGO usage details page deletes local cache and re-fetches everything.
+ View usage details
+ Service cards link to usage charts
+ Configured service cards show a "View usage details" entry at the bottom that opens the charts.
+ Browse raw data
+ Top-right of usage details opens raw records
+ "Raw data" at the top-right of usage details lists every record with pagination.
+ Pull-to-refresh syncs usage
+ Pull to refresh syncs balances and usage
+ Pull-to-refresh on the home screen refreshes balances and also syncs OCGO / CCGO usage records.
+ Credential guides
+ Credential pages include step-by-step guides
+ The "How to get" button at the bottom of credential pages walks through every service step by step.
+ Clipboard auto-import
+ Codex supports one-tap import from clipboard
+ The Codex credential page supports "Auto-import from clipboard": paste auth.json and it fills in with one tap.
+ Jump to a page number
+ Raw data page accepts a page number
+ The raw data page lets you enter a page number to jump directly — no need to flip through pages.
+ Widget auto-refresh
+ Swipe to the widget and it auto-refreshes
+ The widget refreshes balances when you swipe to it, or tap the ↻ button manually.
+
+
+ %1$d calls
+ Pick a date
+ Pick a month (any day)
+ Start date
+ End date
+ Spending breakdown
+ Request breakdown
+ Token breakdown
+ Clear data
+ This clears all cached CommandCode usage and re-syncs. It takes a few seconds.
+ Continue?
+ Confirm (%1$ds)
+ Yes, clear
+ Close
+ From %1$s
+ To %1$s
+ Apply custom range
+ OK
+ End date cannot be earlier than start date %1$s
+ Time %1$s:%2$s
+ No data
+ Total: %1$s
+ Total
+ Records
+ Cache read
+ Cache write
+ Cache token breakdown
+ ※ Cache reads are included in "Input tokens"
+ %1$d calls
+ MMM d, EEEE
+ %1$d calls · $%2$s
+ Raw data
+ Cache write (5m)
+ Cache write (1h)
+ Cost (USD)
+ Cost (raw)
+
+
+ ~/.config/codex-oauth-proxy/auth.json
+ 3. Open it with a text editor,
+ copy everything and paste it above
+ JSON example:
+ {"tokens": {
+ "access_token": "...",
+ "refresh_token": "...",
+ "expiresAt": 1783727108252
+ }}
+ Tip: the app auto-refreshes the token after import,
+ no manual updates needed.
+ Ollama Pro credentials
+ Copy the full cookie from your browser. After logging into ollama.com/settings, find the Cookie header in DevTools → Network and copy it.
+ Update credentials
+ Save credentials
+ Test & save
+ Open Ollama Settings in browser
+ Copy login URL to clipboard
+ One-tap usage API Key (optional)
+ With an Ollama Cloud API Key, the details page can send one request to activate usage stats. Get it at ollama.com/settings → API Keys.
+ ollama-xxx or sk-xxx
+ Save API Key
+ OpenCode Go credentials
+ Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage.
+ Auto-import from clipboard
+ The {id} segment of the dashboard URL
+ auth Cookie value
+ F12 → Cookies → opencode.ai → Value of auth
+ How to get these two?
+ Open login page in browser
+ With an OpenCode Zen API Key, the details page can send one request to activate usage stats. Get it at opencode.ai/settings → API Keys.
+ CommandCode Go credentials
+ Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it.
+ Generate at Settings → API Keys
+ Session Cookie (optional)
+ F12 → Cookies → commandcode.ai → select all & paste
+ How to get API Key and Cookie?
+ Enter API Key
+ Credentials are encrypted with Android Keystore (AES-256 GCM) and stored in local DataStore.
+ Update
+ Codex / ChatGPT Plus credentials
+ OAuth login fetches credentials automatically. You can also paste auth.json manually.
+ 🔐 OAuth login (recommended)
+ ── or import manually ──
+ Full auth.json content
+ Paste the full {"tokens": {...}} JSON
+ Import & save
+ How to get auth.json?
+ Paste session
+ Google OAuth blocks the in-app WebView, so log in with a browser and paste the cookie here.
+ Cookie string
+ Save cookie
+
+
+ Empty response body
+ Response root is not a JSON object
+ No usage window data found
+ Model list not found
+ Model list is empty
+ Response format is abnormal
+ Weekly
+ Model call count
+ Cached data · %1$s
+ Showing the last successfully fetched balance
+ %1$s%2$s spent this month
+ Balance breakdown
+ Granted balance
+ Top-ups
+ Loading model list...
+ Select model
+ Refresh model list
+ Activating...
+ Retry usage activation
+ ⚡ One-tap usage activation
+ Refresh balance
+ Log in again
+ Configure credential
+ Current balance
+ 5h live usage
+ Monthly balance
+ Session usage
+ Please select a model
+ Error details:
+ API response:
+ Copy
+ *** (length %1$d) ***
+ %1$s***%2$s (length %3$d)
+ Bad cookie format, expected name1=value1; name2=value2
+ %1$d cookies saved
+ Please paste the auth.json content
+ auth.json is missing access_token or refresh_token
+ Codex credential saved; token auto-refreshes when expiring
+ Parse failed: %1$s
+ Connected! Balance: $%1$s
+ API Key saved
+ Connected! Session: %1$s%% · %2$s
+ Credential deleted
+ Clipboard is empty
+ Recognized workspaceId and auth cookie
+ Recognized workspaceId, please paste the auth cookie too
+ Recognized auth cookie, please paste the workspaceId too
+ No valid credential recognized
+
+
+ Codex OAuth Login
+ Exchanging token...
+ Retry
+ Headless login
+ 1. Tap "Open in browser" or copy the link below to a browser\n2. Complete login & authorization on the OpenAI page\n3. The browser redirects to a localhost address (page not loading is normal)\n4. Copy the full URL from the address bar and paste it below\n5. Tap "Submit callback URL"
+ Authorization link
+ Open in browser
+ Copy link
+ ── paste the callback URL after login ──
+ Callback URL
+ http://localhost:1455/auth/callback?code=...
+ Submit callback URL
+ Regenerate auth link
+ Preparing authorization...
+
diff --git a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt
index aea46e1..628b263 100644
--- a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt
+++ b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt
@@ -4,6 +4,8 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonNull
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -236,11 +238,12 @@ class CodexRepositoryTest {
""".trimIndent()
val result = parseSseResponse(sse, "gpt-5.6")
- assertTrue(result.contains("Hello, world!"))
- assertTrue(result.contains("input=100"))
- assertTrue(result.contains("output=50"))
- assertTrue(result.contains("resp_123"))
- assertTrue(result.contains("gpt-5.6"))
+ assertEquals("Hello, world!", result.reply)
+ assertEquals("100", result.inputTokens)
+ assertEquals("50", result.outputTokens)
+ assertEquals("resp_123", result.responseId)
+ assertEquals("gpt-5.6", result.model)
+ assertFalse(result.parseFailed)
}
@Test
@@ -251,9 +254,10 @@ class CodexRepositoryTest {
""".trimIndent()
val result = parseSseResponse(sse, "o4-mini")
- assertTrue(result.contains("(空)"))
- assertTrue(result.contains("input=10"))
- assertTrue(result.contains("output=5"))
+ assertEquals("", result.reply)
+ assertEquals("10", result.inputTokens)
+ assertEquals("5", result.outputTokens)
+ assertNull(result.totalTokens)
}
@Test
@@ -265,16 +269,18 @@ class CodexRepositoryTest {
""".trimIndent()
val result = parseSseResponse(sse, "gpt-5.6")
- assertTrue(result.contains("Hi there"))
- // Should not contain usage line when no usage data
- assertTrue(!result.contains("用量:"))
+ assertEquals("Hi there", result.reply)
+ // Should be null when no usage data present
+ assertNull(result.inputTokens)
+ assertNull(result.outputTokens)
}
@Test
fun `parseSseResponse handles empty input`() {
val result = parseSseResponse("", "gpt-5.6")
- assertTrue(result.contains("(空)"))
- assertTrue(result.contains("gpt-5.6"))
+ assertEquals("", result.reply)
+ assertEquals("gpt-5.6", result.model)
+ assertNull(result.responseId)
}
@Test
@@ -288,9 +294,9 @@ class CodexRepositoryTest {
""".trimIndent()
val result = parseSseResponse(sse, "o4-mini")
- assertTrue(result.contains("Test reply"))
- assertTrue(result.contains("input=200"))
- assertTrue(result.contains("output=100"))
+ assertEquals("Test reply", result.reply)
+ assertEquals("200", result.inputTokens)
+ assertEquals("100", result.outputTokens)
}
@Test
@@ -304,18 +310,22 @@ class CodexRepositoryTest {
val result = parseSseResponse(sse, "gpt-5.6")
// Malformed line should be skipped, valid ones parsed
- assertTrue(result.contains("Good"))
- assertTrue(result.contains("input=1"))
+ assertEquals("Good", result.reply)
+ assertEquals("1", result.inputTokens)
}
@Test
- fun `parseSseResponse contains activation success header`() {
+ fun `parseSseResponse returns structured metadata`() {
val sse = """
data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}}
data: [DONE]
""".trimIndent()
val result = parseSseResponse(sse, "gpt-5.6")
- assertTrue(result.contains("✓ 激活成功"))
+ assertEquals("r1", result.responseId)
+ assertEquals("0", result.inputTokens)
+ assertEquals("0", result.outputTokens)
+ assertEquals("", result.reply)
+ assertFalse(result.parseFailed)
}
}
\ No newline at end of file
diff --git a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt
index 79df352..7f13167 100644
--- a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt
+++ b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt
@@ -112,12 +112,19 @@ class FormatUtilsTest {
assertEquals("每周", normalizeWindowLabel("weekly"))
assertEquals("每周", normalizeWindowLabel("Weekly"))
assertEquals("每周", normalizeWindowLabel("WEEKLY"))
+ assertEquals("每周", normalizeWindowLabel("每周"))
}
@Test
- fun `normalizeWindowLabel non-weekly passes through`() {
+ fun `normalizeWindowLabel monthly maps to monthly label`() {
+ assertEquals("每月", normalizeWindowLabel("monthly"))
+ assertEquals("Monthly", normalizeWindowLabel("MONTHLY", monthlyLabel = "Monthly"))
+ assertEquals("每月", normalizeWindowLabel("每月"))
+ }
+
+ @Test
+ fun `normalizeWindowLabel unknown passes through`() {
assertEquals("5h", normalizeWindowLabel("5h"))
- assertEquals("monthly", normalizeWindowLabel("monthly"))
assertEquals("30d", normalizeWindowLabel("30d"))
}