feat(checkin): 签到记录含日期/状态/id/数量,失败可当天重试;同步本地主题与组件改动

This commit is contained in:
Liuxinyu176 2026-10-08 21:08:22 +08:00
parent ffc06a38ca
commit ceb5e6a268
34 changed files with 653 additions and 230 deletions

View File

@ -59,7 +59,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
| ➕ **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 | | ➕ **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 | | 💡 **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 | | ⚡ **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 | | 🎀 **Rainy pink theme** | Material Design 3 · Refined Rose primary #C9436C · Blush white background #FDEFF4 |
| 🔐 **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 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 | | ⚡ **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 | | ⚡ **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 |

View File

@ -59,7 +59,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 | | ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 | | 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 | | ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC | | 🎀 **雨晴粉主题** | Material Design 3 · 精致玫瑰粉 主色 #C9436C · 粉白背景 #FDEFF4 |
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json | | 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
| ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 | | ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
| ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 | | ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 |

View File

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" /> <uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:name=".RainyTokenApplication" android:name=".RainyTokenApplication"
@ -33,6 +34,15 @@
android:name=".ui.widget.WidgetRefreshReceiver" android:name=".ui.widget.WidgetRefreshReceiver"
android:exported="false" /> android:exported="false" />
<!-- 每日自动签到:零点闹钟广播 + 开机后排程(Hilt 经 EntryPoint 注入,无 @AndroidEntryPoint) -->
<receiver
android:name=".ui.dashboard.autocheckin.AutoCheckinReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- OpenCode Go 小组件(MIUI Widget) --> <!-- OpenCode Go 小组件(MIUI Widget) -->
<receiver <receiver
android:name=".ui.widget.OpenCodeGoWidgetProvider" android:name=".ui.widget.OpenCodeGoWidgetProvider"

View File

@ -15,7 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
* *
* 布局策略: * 布局策略:
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体) * - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
* - 再套 RainyBackground(樱粉渐变背景,全局共享) * - 再套 RainyBackground(精致粉调渐变背景,全局共享)
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding * - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱 * - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
*/ */

View File

@ -6,20 +6,21 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import org.json.JSONObject
/** /**
* 每日签到状态的统一本地记录(SharedPreferences "checkin_status")。 * 每日签到状态的统一本地记录(SharedPreferences "checkin_status")。
* *
* 手动签到与自动签到成功/已签后都会落一条 `service → 签到日期` 记录,供三处消费: * 一条记录包含:
* - 服务详情页:进入时直接显示「今日已签到」,不再盲目展示可点按钮; * - checkinDate:签到日期(本地时区 yyyy-MM-dd)
* - 首页卡片:Trae / WorkBuddy 各账号卡角标「今日已签到 ✓」; * - status:签到结果状态(SUCCESS/ALREADY/FAILED/RETRYABLE/DEVICE_BLOCKED)
* - 自动签到:当日已有记录的(默认)账号跳过,保证每日只签一次。 * - checkinId:签到 id(服务端未提供时为空;新签到成功时生成本地 UUID 便于追溯)
* - credits:签到获得数量
* - updatedAt:本地记录更新时间戳
* *
* 多账号服务按账号分别记录(键 `last_checkin_{storageKey}:{accountId}`); * 「今日已签到」的判定 = checkinDate == 今天 && status ∈ {SUCCESS, ALREADY}。
* 单账号服务沿用旧键 `last_checkin_{storageKey}`([accountId] 传 null)。 * 因此 FAILED/RETRYABLE/DEVICE_BLOCKED 的记录不会挡住当天重试:标识位和时间
* * 对不上时,重开 App 会再次唤起签到流程。
* 与 TraeCredential.lastCheckinDate 的关系:后者是 Trae Repository 层的网络去重
* 短路字段(仅 Trae 有);本存储是 UI/编排层的展示与调度记录,覆盖所有可签服务。
*/ */
@Singleton @Singleton
class CheckinStatusStore @Inject constructor( class CheckinStatusStore @Inject constructor(
@ -29,14 +30,80 @@ class CheckinStatusStore @Inject constructor(
private fun prefs() = private fun prefs() =
context.getSharedPreferences("checkin_status", Context.MODE_PRIVATE) context.getSharedPreferences("checkin_status", Context.MODE_PRIVATE)
/** 该服务今日是否已有签到记录(本地时区 yyyy-MM-dd 比对)。 */ /** 该服务今日是否已「成功」签到(本地时区 yyyy-MM-dd 比对)。 */
fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean = fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean {
lastCheckinDate(service, accountId) == LocalDate.now().toString() val record = lastRecord(service, accountId) ?: return false
return record.checkinDate == LocalDate.now().toString() &&
(record.status == STATUS_SUCCESS || record.status == STATUS_ALREADY)
}
/** 最近一次签到日期(yyyy-MM-dd),无记录返回 null。 */ /** 最近一次签到记录(含失败记录),无记录返回 null。 */
fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? { fun lastRecord(service: ServiceType, accountId: String? = null): CheckinRecord? {
val raw = prefs().getString(recordKey(service, accountId), null)
if (raw != null) {
return runCatching {
val obj = JSONObject(raw)
val id = if (obj.isNull(KEY_ID)) null else obj.optString(KEY_ID).takeIf { it.isNotBlank() }
CheckinRecord(
service = service.storageKey,
accountId = accountId,
checkinDate = obj.optString(KEY_DATE),
status = obj.optString(KEY_STATUS),
checkinId = id,
credits = obj.optLong(KEY_CREDITS),
updatedAt = obj.optLong(KEY_UPDATED_AT)
)
}.getOrNull()
}
// 旧版 date-only 记录迁移:没有 JSON 记录时回退读旧键
val legacyDate = legacyDate(service, accountId) ?: return null
return CheckinRecord(
service = service.storageKey,
accountId = accountId,
checkinDate = legacyDate,
status = STATUS_SUCCESS,
checkinId = null,
credits = 0,
updatedAt = 0
)
}
/** 最近一次成功签到日期(yyyy-MM-dd),无记录返回 null。 */
fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? =
lastRecord(service, accountId)
?.takeIf { it.status == STATUS_SUCCESS || it.status == STATUS_ALREADY }
?.checkinDate
/** 记录一条签到结果(成功或失败都会落库,便于当天重试/统计)。 */
fun record(
service: ServiceType,
accountId: String? = null,
status: String,
checkinId: String? = null,
credits: Long = 0,
updatedAt: Long = System.currentTimeMillis()
) {
val obj = JSONObject()
obj.put(KEY_DATE, LocalDate.now().toString())
obj.put(KEY_STATUS, status)
obj.put(KEY_ID, checkinId ?: JSONObject.NULL)
obj.put(KEY_CREDITS, credits)
obj.put(KEY_UPDATED_AT, updatedAt)
prefs().edit().putString(recordKey(service, accountId), obj.toString()).apply()
}
/** 旧版「已成功签到」快捷写入(服务端确认已签/手动已签场景)。 */
fun markChecked(service: ServiceType, accountId: String? = null) {
record(service, accountId, STATUS_ALREADY)
}
private fun recordKey(service: ServiceType, accountId: String?): String =
"checkin_record_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
private fun legacyDate(service: ServiceType, accountId: String?): String? {
val p = prefs() val p = prefs()
val scoped = p.getString(key(service, accountId), null) val scopedKey = "last_checkin_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
val scoped = p.getString(scopedKey, null)
if (scoped != null) return scoped if (scoped != null) return scoped
// 仅旧版迁移来的 “default” 账号回退读旧版单账号键(其他账号没有旧记录,不能共用), // 仅旧版迁移来的 “default” 账号回退读旧版单账号键(其他账号没有旧记录,不能共用),
// 避免升级当日重复签到请求 // 避免升级当日重复签到请求
@ -47,15 +114,28 @@ class CheckinStatusStore @Inject constructor(
} }
} }
/** 记录该服务(账号)今日已签到(SUCCESS / ALREADY 均调用)。 */ /** 单条签到记录。 */
fun markChecked(service: ServiceType, accountId: String? = null) { data class CheckinRecord(
prefs().edit().putString(key(service, accountId), LocalDate.now().toString()).apply() val service: String,
} val accountId: String?,
val checkinDate: String,
val status: String,
val checkinId: String?,
val credits: Long,
val updatedAt: Long
)
private fun key(service: ServiceType, accountId: String?): String = companion object {
if (accountId.isNullOrBlank()) { const val STATUS_SUCCESS = "SUCCESS"
"last_checkin_${service.storageKey}" const val STATUS_ALREADY = "ALREADY"
} else { const val STATUS_FAILED = "FAILED"
"last_checkin_${service.storageKey}:$accountId" const val STATUS_RETRYABLE = "RETRYABLE"
const val STATUS_DEVICE_BLOCKED = "DEVICE_BLOCKED"
private const val KEY_DATE = "date"
private const val KEY_STATUS = "status"
private const val KEY_ID = "id"
private const val KEY_CREDITS = "credits"
private const val KEY_UPDATED_AT = "updated_at"
} }
} }

View File

@ -3,6 +3,7 @@ package com.rainy.token.domain.usecase
import com.rainy.token.data.repository.CodexRepository import com.rainy.token.data.repository.CodexRepository
import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.CommandCodeGoRepository
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.CheckinStatusStore
import com.rainy.token.data.repository.DeepSeekRepository import com.rainy.token.data.repository.DeepSeekRepository
import com.rainy.token.data.repository.OllamaRepository import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.data.repository.OpenCodeGoRepository
@ -107,10 +108,29 @@ class RefreshBalanceUseCase @Inject constructor(
withCredentialSession(ServiceType.WORKBUDDY, accountId) { withCredentialSession(ServiceType.WORKBUDDY, accountId) {
workBuddyRepositoryProvider.get().checkIn(accountId).also { result -> workBuddyRepositoryProvider.get().checkIn(accountId).also { result ->
result.getOrNull()?.let { s -> result.getOrNull()?.let { s ->
if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS || val isChecked = s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS ||
s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY ||
) { s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY
checkinStatusStore.markChecked(ServiceType.WORKBUDDY, accountId) if (isChecked) {
checkinStatusStore.record(
service = ServiceType.WORKBUDDY,
accountId = accountId,
status = if (s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY) {
CheckinStatusStore.STATUS_ALREADY
} else {
s.outcome.name
},
checkinId = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
java.util.UUID.randomUUID().toString()
} else {
null
},
credits = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit
} else {
0
}
)
} }
} }
} }
@ -123,7 +143,17 @@ class RefreshBalanceUseCase @Inject constructor(
if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS || if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS ||
r.outcome == TraeRepository.CheckinOutcome.ALREADY r.outcome == TraeRepository.CheckinOutcome.ALREADY
) { ) {
checkinStatusStore.markChecked(ServiceType.TRAE, accountId) checkinStatusStore.record(
service = ServiceType.TRAE,
accountId = accountId,
status = r.outcome.name,
checkinId = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) {
java.util.UUID.randomUUID().toString()
} else {
null
},
credits = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) r.points else 0
)
} }
} }
} }

View File

@ -66,6 +66,7 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.formatAmount import com.rainy.token.ui.components.formatAmount
import com.rainy.token.ui.components.resolve import com.rainy.token.ui.components.resolve
import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.theme.StatusGreen
import com.rainy.token.ui.theme.StatusOrange import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.inkMuted
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -420,7 +421,7 @@ private fun ApiRowCard(
@Composable @Composable
private fun StatusDot(state: CredentialStatus.State) { private fun StatusDot(state: CredentialStatus.State) {
val color = when (state) { val color = when (state) {
CredentialStatus.State.OK -> Color(0xFF3BB273) CredentialStatus.State.OK -> StatusGreen
CredentialStatus.State.WARNING -> StatusOrange CredentialStatus.State.WARNING -> StatusOrange
CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error
CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f) CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f)

View File

@ -39,6 +39,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog import com.rainy.token.data.debug.DebugLog
import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.StatusRed
import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.theme.StrawberryPink
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -103,8 +105,8 @@ fun DebugLogScreen(onBack: () -> Unit) {
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry -> items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
val levelColor = when (entry.level) { val levelColor = when (entry.level) {
DebugLog.Level.INFO -> InkMuted DebugLog.Level.INFO -> InkMuted
DebugLog.Level.WARN -> Color(0xFFFFA726) DebugLog.Level.WARN -> StatusOrange
DebugLog.Level.ERROR -> Color(0xFFE91E63) DebugLog.Level.ERROR -> StatusRed
} }
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View File

@ -13,10 +13,10 @@ import com.rainy.token.ui.theme.DarkSurface
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
/** /**
* 雨晴风格全局背景。 * 雨晴风格全局背景(精致粉调)。
* *
* Light 模式:樱粉渐变(#FFF0F5 → #FFD1DC,从左上到右下) * Light 模式:低饱和粉白渐变(#FFF9FB → #FDEFF4,从左上到右下)
* Dark 模式:暖深渐变(#1F1419 → #2A1F25) * Dark 模式:暖深渐变(#1A1417 → #251C21)
* *
* 放在 Scaffold 容器层,所有页面共享同一层渐变。 * 放在 Scaffold 容器层,所有页面共享同一层渐变。
*/ */

View File

@ -44,7 +44,7 @@ fun ServiceIcon(
modifier = modifier modifier = modifier
.size(size.dp) .size(size.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White), .background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Image( Image(
@ -59,7 +59,7 @@ fun ServiceIcon(
modifier = modifier modifier = modifier
.size(size.dp) .size(size.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White), .background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Image( Image(

View File

@ -1,6 +1,7 @@
package com.rainy.token.ui.components package com.rainy.token.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -44,11 +45,14 @@ fun StatusChip(
style: StatusStyle, style: StatusStyle,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// 暗色模式下胶囊底色是状态色 12% alpha 叠加暗色卡片,
// 深色文字几乎不可读,故暗色使用同色系亮色文字。
val dark = isSystemInDarkTheme()
val (dotColor, textColor) = when (style.level) { val (dotColor, textColor) = when (style.level) {
StatusLevel.OK -> StatusGreen to Color(0xFF1B5E20) StatusLevel.OK -> StatusGreen to if (dark) Color(0xFF9CE8BC) else Color(0xFF1B5E20)
StatusLevel.WARNING -> StatusOrange to Color(0xFF8A4A00) StatusLevel.WARNING -> StatusOrange to if (dark) Color(0xFFFFDBA6) else Color(0xFF8A4A00)
StatusLevel.ERROR -> StatusRed to Color(0xFF8B0033) StatusLevel.ERROR -> StatusRed to if (dark) Color(0xFFFFB6CA) else Color(0xFF8A1F40)
StatusLevel.INFO -> StatusBlue to Color(0xFF0D47A1) StatusLevel.INFO -> StatusBlue to if (dark) Color(0xFFAED7FF) else Color(0xFF0D47A1)
} }
val bgColor = dotColor.copy(alpha = 0.12f) val bgColor = dotColor.copy(alpha = 0.12f)
Row( Row(

View File

@ -13,6 +13,7 @@ import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.ui.components.UiText import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.dashboard.autocheckin.CHECKIN_SERVICES
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -62,6 +63,8 @@ class DashboardViewModel @Inject constructor(
// 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。 // 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。
// 用 try/catch 而非 runCatching:后者会吞掉 CancellationException, // 用 try/catch 而非 runCatching:后者会吞掉 CancellationException,
// 导致 ViewModel 销毁取消后协程仍继续往下跑。 // 导致 ViewModel 销毁取消后协程仍继续往下跑。
// 2.5) 打开 App 时把零点后台闹钟重排到下次零点(开机/升级/后台被杀后的兜底)
autoCheckinManager.refreshSchedule()
try { try {
autoCheckinManager.runDailyIfDue() autoCheckinManager.runDailyIfDue()
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
@ -475,9 +478,6 @@ private data class AccountKey(
val accountId: String? = null val accountId: String? = null
) )
/** 支持每日签到的服务(首页角标与自动签到的统一口径)。 */
internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY)
/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */ /** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
private fun errorToUiText(error: Throwable): UiText = when (error) { private fun errorToUiText(error: Throwable): UiText = when (error) {
is RepositoryError.SessionExpired -> is RepositoryError.SessionExpired ->

View File

@ -76,7 +76,7 @@ import java.util.Locale
// 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型) // 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型)
internal val modelColors = listOf( internal val modelColors = listOf(
Color(0xFFF44336), // Red Color(0xFFF44336), // Red
Color(0xFFE91E63), // Pink Color(0xFFC9436C), // Pink(玫瑰主色)
Color(0xFF9C27B0), // Purple Color(0xFF9C27B0), // Purple
Color(0xFF673AB7), // Deep Purple Color(0xFF673AB7), // Deep Purple
Color(0xFF3F51B5), // Indigo Color(0xFF3F51B5), // Indigo
@ -89,11 +89,11 @@ internal val modelColors = listOf(
Color(0xFF607D8B) // Blue Grey Color(0xFF607D8B) // Blue Grey
) )
// Token 堆叠色板:命中缓存(浅粉·顶)、未命中输入(粉·中)、输出(深粉·底) // Token 堆叠色板:命中缓存(浅樱粉·顶)、未命中输入(玫瑰粉·中)、输出(深玫瑰·底)
internal val tokenColors = listOf( internal val tokenColors = listOf(
Color(0xFFFFD1DC), // cache hit — 浅粉(上面) Color(0xFFFFE3EC), // cache hit — 浅樱粉(上面)
Color(0xFFFF85A2), // cache miss input — 粉色(中间) Color(0xFFFFA3BC), // cache miss input — 玫瑰粉(中间)
Color(0xFFE91E63) // output — 深粉(下面) Color(0xFFC9436C) // output — 深玫瑰(主色,下面)
) )
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)

View File

@ -0,0 +1,16 @@
package com.rainy.token.ui.dashboard.autocheckin
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* 后台广播(自动签到闹钟 / 开机重排)获取 Hilt 单例的入口。
* 与 ui.widget.WidgetRefreshEntryPoint 同一模式:广播接收器不依赖
* @AndroidEntryPoint,通过 EntryPointAccessors 拿 SingletonComponent 里的依赖。
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AutoCheckinEntryPoint {
fun autoCheckinManager(): AutoCheckinManager
}

View File

@ -1,6 +1,9 @@
package com.rainy.token.ui.dashboard.autocheckin package com.rainy.token.ui.dashboard.autocheckin
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import com.rainy.token.data.repository.CheckinStatusStore import com.rainy.token.data.repository.CheckinStatusStore
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError import com.rainy.token.data.repository.RepositoryError
@ -11,23 +14,32 @@ import com.rainy.token.domain.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.LocalDate import java.time.ZonedDateTime
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** /**
* 每日自动签到编排器(小组件开关控制启用)。 * 每日自动签到编排器(小组件开关控制启用)。
* *
* 行为契约(用户需求 2026-09-13 / 多账号版 2026-10): * 行为契约(用户需求 2026-09-13 / 多账号版 2026-10 / 零点后台版 2026-11):
* - 开启后,每天首次打开 App 自动为所有「已配置凭据」的可签服务的【默认账号】各签一次; * - 开启后,每天零点后由 AlarmManager 自动触发([AutoCheckinReceiver]),
* - 当日只签一次:[CheckinStatusStore] 有当日记录的账号直接跳过(手动签过也算); * 为所有「已配置凭据」的可签服务的【全部账号】各签一次;
* - 兜底:每天首次打开 App(DashboardViewModel)也会补签一次(错过闹钟/被杀进程场景);
* - 当日每账号只签一次:本地有「今日已成功签到」记录的账号直接跳过(手动签过也算);
* 失败/限流/设备拦截等非成功记录不占位,下次打开 App 会再次唤起签到流程;
* - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」; * - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」;
* - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。 * - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。
* *
* 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。 * 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。
*
* 调度说明:使用框架自带的 AlarmManager(非精确 setAndAllowWhileIdle)实现「每日零点后
* 后台签到」,零新增依赖;重启设备后闹钟由 BOOT_COMPLETED 广播重排。国产 ROM(如 vivo/
* iQOO)可能因电池优化延迟或抑制该闹钟,届时由「首次打开 App」兜底补签。
*/ */
@Singleton @Singleton
class AutoCheckinManager @Inject constructor( class AutoCheckinManager @Inject constructor(
@ -54,52 +66,54 @@ class AutoCheckinManager @Inject constructor(
) )
/** /**
* 自动签到汇总结果。null=没有可展示的内容(未开启/无默认账号/全部跳过)。 * 自动签到汇总结果。null=没有可展示的内容(未开启/无候选账号/全部账号已签或静默跳过)。
* 弹窗消费后由 [consumeReport] 置 null。 * 弹窗消费后由 [consumeReport] 置 null。
*/ */
private val _report = MutableStateFlow<List<ServiceResult>?>(null) private val _report = MutableStateFlow<List<ServiceResult>?>(null)
val report: StateFlow<List<ServiceResult>?> = _report.asStateFlow() val report: StateFlow<List<ServiceResult>?> = _report.asStateFlow()
/** 防同日并发重复签到:闹钟广播(IO 线程)与 Dashboard init(viewModelScope)可能同时进入。 */
private val runMutex = Mutex()
private fun prefs() = private fun prefs() =
context.getSharedPreferences("auto_checkin", Context.MODE_PRIVATE) context.getSharedPreferences("auto_checkin", Context.MODE_PRIVATE)
/** 自动签到开关(小组件 CheckBox 与 App 内共用)。 */ /** 自动签到开关(小组件 CheckBox 与 App 内共用)。开关变更时同步排/撤零点闹钟。 */
var enabled: Boolean var enabled: Boolean
get() = prefs().getBoolean(KEY_ENABLED, false) get() = prefs().getBoolean(KEY_ENABLED, false)
set(value) { set(value) {
prefs().edit().putBoolean(KEY_ENABLED, value).apply() prefs().edit().putBoolean(KEY_ENABLED, value).apply()
} refreshSchedule()
/** 已执行过「今日自动签到」的日期(防止同日多次开 App 重复跑)。 */
private var lastRunDate: String?
get() = prefs().getString(KEY_LAST_RUN, null)
set(value) {
prefs().edit().putString(KEY_LAST_RUN, value).apply()
} }
/** /**
* 每日首次打开 App 时调用:开启且今日未跑过 → 为已配置凭据的可签服务默认账号各签一次。 * 每日签到执行入口(后台闹钟与首次打开 App 共用):
* 开启时 → 为已配置凭据的可签服务中【今日尚未成功签到】的账号各签一次。
* 返回 true 表示本次实际执行了签到(无论结果)。 * 返回 true 表示本次实际执行了签到(无论结果)。
*/ */
suspend fun runDailyIfDue(): Boolean { suspend fun runDailyIfDue(): Boolean = runMutex.withLock {
if (!enabled) return false if (!enabled) return false
val today = LocalDate.now().toString() // 不再使用全局 lastRunDate 占位:只有真正「今日签到成功」的账号才跳过,
if (lastRunDate == today) return false // 失败/限流/设备拦截等不会消费当天机会,重开 App 可再次唤起签到流程。
// 先占位再执行:即使执行中崩溃/杀进程,同日再开也不会重跑(宁可少签不重复打扰)
lastRunDate = today
val results = mutableListOf<ServiceResult>() val results = mutableListOf<ServiceResult>()
for (service in CHECKIN_SERVICES) { for (service in CHECKIN_SERVICES) {
val defaultAccount = credentialRepository.getDefaultAccount(service) ?: continue // 多账号服务遍历全部账号;无账号文档时 getDefaultAccount 同样返回 null,
if (credentialRepository.statusFor(service, defaultAccount.id).state == // 候选为空 = 不签,与旧版「只签默认账号」的边界行为一致,不会误签旧凭据
val accounts = credentialRepository.accountsFor(service)
val candidates = accounts.ifEmpty {
listOfNotNull(credentialRepository.getDefaultAccount(service))
}
for (account in candidates) {
if (credentialRepository.statusFor(service, account.id).state ==
CredentialStatus.State.NOT_CONFIGURED CredentialStatus.State.NOT_CONFIGURED
) { ) {
continue continue
} }
// 当日已有签到记录(手动签过)→ 跳过,不再发网络请求 // 今日已有成功签到记录(手动签过也算)→ 跳过,不再发网络请求
if (checkinStatusStore.isCheckedToday(service, defaultAccount.id)) continue if (checkinStatusStore.isCheckedToday(service, account.id)) continue
// runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目 // runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目
runOne(service, defaultAccount)?.let { results += it } runOne(service, account)?.let { results += it }
}
} }
if (results.isNotEmpty()) { if (results.isNotEmpty()) {
_report.value = results _report.value = results
@ -107,6 +121,39 @@ class AutoCheckinManager @Inject constructor(
return true return true
} }
/**
* 注册/取消每日零点后台闹钟(非精确 setAndAllowWhileIdle,零点后几分钟内触发)。
* 设备重启后闹钟丢失,由 BOOT_COMPLETED 广播再次调用本方法重排。
*/
fun refreshSchedule() {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AutoCheckinReceiver::class.java)
.setAction(ACTION_DAILY_CHECKIN)
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
if (!enabled) {
alarmManager.cancel(pendingIntent)
return
}
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
nextRunTimeMillis(),
pendingIntent
)
}
/** 下一次零点(00:05,本地时区)的运行时间戳。 */
private fun nextRunTimeMillis(): Long {
val now = ZonedDateTime.now()
var next = now.toLocalDate().atTime(CHECKIN_HOUR, CHECKIN_MINUTE).atZone(now.zone)
if (!next.isAfter(now)) next = next.plusDays(1)
return next.toInstant().toEpochMilli()
}
/** /**
* 返回 null 表示该账号无需出现在汇报弹窗里(如 INTL 不支持签到、静默跳过)。 * 返回 null 表示该账号无需出现在汇报弹窗里(如 INTL 不支持签到、静默跳过)。
*/ */
@ -132,15 +179,24 @@ class AutoCheckinManager @Inject constructor(
ServiceResult(service, label, false, true, 0, "", false) ServiceResult(service, label, false, true, 0, "", false)
// INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」) // INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」)
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null
TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> {
ServiceResult(service, label, false, false, 0, r.message, failed = true) recordFailure(service, account.id, CheckinStatusStore.STATUS_DEVICE_BLOCKED)
TraeRepository.CheckinOutcome.RETRYABLE ->
ServiceResult(service, label, false, false, 0, r.message, failed = true)
TraeRepository.CheckinOutcome.FAILED ->
ServiceResult(service, label, false, false, 0, r.message, failed = true) ServiceResult(service, label, false, false, 0, r.message, failed = true)
} }
TraeRepository.CheckinOutcome.RETRYABLE -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_RETRYABLE)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
}
TraeRepository.CheckinOutcome.FAILED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
}
}
}, },
onFailure = { e -> failureResult(service, label, account.id, e) } onFailure = { e ->
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
failureResult(service, label, account.id, e)
}
) )
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(account.id).fold( ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(account.id).fold(
onSuccess = { s -> onSuccess = { s ->
@ -158,11 +214,16 @@ class AutoCheckinManager @Inject constructor(
ServiceResult(service, label, false, true, 0, "", false) ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.QUERY -> WorkBuddyRepository.CheckinOutcome.QUERY ->
ServiceResult(service, label, false, true, 0, "", false) ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.FAILED -> WorkBuddyRepository.CheckinOutcome.FAILED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
ServiceResult(service, label, false, false, 0, s.message, failed = true) ServiceResult(service, label, false, false, 0, s.message, failed = true)
} }
}
}, },
onFailure = { e -> failureResult(service, label, account.id, e) } onFailure = { e ->
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
failureResult(service, label, account.id, e)
}
) )
// CHECKIN_SERVICES 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败) // CHECKIN_SERVICES 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败)
else -> null else -> null
@ -173,7 +234,7 @@ class AutoCheckinManager @Inject constructor(
* 签到请求失败的汇报结果。 * 签到请求失败的汇报结果。
* *
* 竞态兜底:签到请求在服务端已成功([RefreshBalanceUseCase.checkInTrae] 的 also 块 * 竞态兜底:签到请求在服务端已成功([RefreshBalanceUseCase.checkInTrae] 的 also 块
* 已 markChecked),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged] * 已写 CheckinStatusStore 成功记录),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged]
* 丢弃时——若本地记录确认今日已签,按「今日已签到」汇报,而不是报「凭据」错误 * 丢弃时——若本地记录确认今日已签,按「今日已签到」汇报,而不是报「凭据」错误
* (用户会看到积分实际到账却提示失败的矛盾)。 * (用户会看到积分实际到账却提示失败的矛盾)。
*/ */
@ -199,16 +260,37 @@ class AutoCheckinManager @Inject constructor(
) )
} }
/**
* 落一条非成功签到记录(仅当今天还没有成功记录时写入)。
* 失败/限流/设备拦截不消费当天签到机会,重开 App 仍会重试;
* 但若在一次 CredentialChanged 竞态中服务端实际已成功,保留成功记录不被覆盖。
*/
private fun recordFailure(
service: ServiceType,
accountId: String,
status: String
) {
if (!checkinStatusStore.isCheckedToday(service, accountId)) {
checkinStatusStore.record(service, accountId, status)
}
}
/** 弹窗关闭时调用,清除汇报状态(下次执行前不再重复弹)。 */ /** 弹窗关闭时调用,清除汇报状态(下次执行前不再重复弹)。 */
fun consumeReport() { fun consumeReport() {
_report.value = null _report.value = null
} }
private companion object { companion object {
const val KEY_ENABLED = "enabled" const val KEY_ENABLED = "enabled"
const val KEY_LAST_RUN = "last_run_date"
/** 每日自动签到闹钟的广播 action(供 [AutoCheckinReceiver] 分发)。 */
const val ACTION_DAILY_CHECKIN = "com.rainy.token.action.DAILY_AUTO_CHECKIN"
/** 每日签到目标时间:零点后 5 分钟(避开日切边界),本地时区。 */
const val CHECKIN_HOUR = 0
const val CHECKIN_MINUTE = 5
} }
} }
/** 支持每日签到的服务(与 Dashboard 的 CHECKIN_SERVICES 同一口径,避免两处漂移)。 */ /** 支持每日签到的服务(DashboardViewModel 与自动签到共用的唯一来源,避免两处漂移)。 */
internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY) internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY)

View File

@ -0,0 +1,60 @@
package com.rainy.token.ui.dashboard.autocheckin
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.EntryPointAccessors
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* 每日自动签到后台触发点。
*
* 两种入口共用同一个接收器:
* - [AutoCheckinManager.ACTION_DAILY_CHECKIN]:AlarmManager 每日零点后发出的闹钟广播,
* 到点就为该服务的全部已配置账号签到;
* - ACTION_BOOT_COMPLETED:设备重启后闹钟会被系统清空,这里只负责重新排程,不抢签。
*
* 通过 EntryPointAccessors 获取 Hilt 单例,不依赖 @AndroidEntryPoint。
*/
class AutoCheckinReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
val appContext = context.applicationContext
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val manager = EntryPointAccessors.fromApplication(
appContext, AutoCheckinEntryPoint::class.java
).autoCheckinManager()
when (action) {
AutoCheckinManager.ACTION_DAILY_CHECKIN -> {
// 到点签到(关闭时 runDailyIfDue 自身会短路返回);
// 无论本次签到是否抛异常,都必须把一次性闹钟重排到明天
try {
manager.runDailyIfDue()
} finally {
manager.refreshSchedule()
}
}
Intent.ACTION_BOOT_COMPLETED -> {
// 重启后闹钟丢失,重新排程(今天是否签到由首次打开 App 兜底)
manager.refreshSchedule()
}
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// 后台静默:签到/排程失败不阻塞下次闹钟,等下次打开 App 再补救
} finally {
pendingResult.finish()
}
}
}
}

View File

@ -66,24 +66,24 @@ import kotlinx.coroutines.flow.first
// ── 热力图颜色 ────────────────────────────────────────────── // ── 热力图颜色 ──────────────────────────────────────────────
/** 亮色模式 6 级粉色梯度 */ /** 亮色模式 6 级玫瑰粉梯度(精致粉调,最高级与主色一致) */
private val LightHeatmapColors = listOf( private val LightHeatmapColors = listOf(
Color(0xFFEFE0E5), // Level 0: 极浅暖灰 Color(0xFFF3E9EE), // Level 0: 极浅暖灰
Color(0xFFFFD6E0), // Level 1: 浅樱粉 Color(0xFFFFE3EC), // Level 1: 浅樱粉
Color(0xFFFFB3C6), // Level 2: 草莓粉柔 Color(0xFFFFC9D9), // Level 2: 浅玫瑰柔
Color(0xFFFF85A2), // Level 3: 草莓粉 Color(0xFFFFA3BC), // Level 3: 玫瑰粉
Color(0xFFFF6B8E), // Level 4: 草莓粉深 Color(0xFFF27E9F), // Level 4: 玫瑰粉深
Color(0xFFE84973), // Level 5: 玫红 Color(0xFFC9436C), // Level 5: 深玫瑰(主色)
) )
/** 暗色模式 6 级粉色梯度 */ /** 暗色模式 6 级玫瑰粉梯度 */
private val DarkHeatmapColors = listOf( private val DarkHeatmapColors = listOf(
Color(0xFF3A2A30), // Level 0: 暖深灰 Color(0xFF3A2B33), // Level 0: 暖深灰
Color(0xFF4A2E3A), // Level 1 Color(0xFF4A3239), // Level 1
Color(0xFF6B3D4A), // Level 2 Color(0xFF643D4A), // Level 2
Color(0xFF8B4D5A), // Level 3 Color(0xFF855062), // Level 3
Color(0xFFB86880), // Level 4 Color(0xFFA9627A), // Level 4
Color(0xFFD67F9A), // Level 5 Color(0xFFE07C9A), // Level 5
) )
/** 月份名称资源(按月索引 1~12,locale-aware) */ /** 月份名称资源(按月索引 1~12,locale-aware) */

View File

@ -3,40 +3,41 @@ package com.rainy.token.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
/** /**
* 雨晴风格配色(RainyStyle Palette)。 * 雨晴风格配色(RainyStyle Palette)— 精致粉调。
* *
* 视觉基调:元气樱粉 / 草莓粉主调,搭配纯白卡片与暖灰文本。 * 视觉基调:低饱和樱粉白背景 + 纯白卡片 + 深玫瑰主色点缀,
* 通过克制用色与留白呈现精致感,保留"雨晴粉"品牌识别度。
* 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。 * 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。
* *
* 背景使用樱粉渐变(#FFF0F5 → #FFD1DC),主元素采用草莓粉 * 背景使用柔和粉白渐变(#FFF9FB → #FDEFF4),主元素采用深玫瑰
* (#FF85A2 / #FF6B8E),与白色形成层次。 * (#C9436C / #B3305B),与白色形成优雅层次。
*/ */
// ─── 品牌主色 ─── // ─── 品牌主色 ───
val CherryPinkLight = Color(0xFFFFF0F5) // 樱粉浅(背景起始) val CherryPinkLight = Color(0xFFFFF9FB) // 粉白(背景起始)
val CherryPinkDeep = Color(0xFFFFD1DC) // 樱粉深(背景结束) val CherryPinkDeep = Color(0xFFFDEFF4) // 浅樱粉(背景结束)
val StrawberryPink = Color(0xFFFF85A2) // 草莓粉(主色 / 强调) val StrawberryPink = Color(0xFFC9436C) // 深玫瑰(主色 / 强调,白底对比度 ≥ 4.5:1)
val StrawberryPinkDark = Color(0xFFFF6B8E)// 草莓粉深(按下态 / 主按钮) val StrawberryPinkDark = Color(0xFFB3305B)// 深玫瑰按下态(主按钮)
val StrawberryPinkSoft = Color(0xFFFFB3C6)// 草莓粉柔(hover / 副按钮) val StrawberryPinkSoft = Color(0xFFFFC9D8)// 浅玫瑰柔(容器 / 选中背景)
// ─── 中性色 ─── // ─── 中性色 ───
val PureWhite = Color(0xFFFFFFFF) val PureWhite = Color(0xFFFFFFFF)
val SnowWhite = Color(0xFFFAF6F8) // 卡片次级背景 val SnowWhite = Color(0xFFFAF5F7) // 卡片次级背景
val InkWarm = Color(0xFF3D2C35) // 暖黑(主文本) val InkWarm = Color(0xFF3A2B31) // 暖黑(主文本)
val InkMuted = Color(0xFF7A6B72) // 暖灰(次要文本) val InkMuted = Color(0xFF7A6870) // 暖灰(次要文本,白底 ≥ 4.5:1)
val InkOutline = Color(0xFFD9CFD3) // 暖灰(分隔线 / 描边) val InkOutline = Color(0xFFE8DCE1) // 暖灰(分隔线 / 描边)
// ─── 状态色 ─── // ─── 状态色 ───
val StatusGreen = Color(0xFF66BB6A) // 正常 val StatusGreen = Color(0xFF4CAF50) // 正常
val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登 val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登
val StatusRed = Color(0xFFE91E63) // 错误 / 异常 val StatusRed = Color(0xFFD64A6E) // 错误 / 异常(玫红,与粉调统一)
val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存) val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存)
// ─── 暗色模式 ─── // ─── 暗色模式 ───
val DarkBackground = Color(0xFF1F1419) // 暖深背景 val DarkBackground = Color(0xFF1A1417) // 暖深背景
val DarkSurface = Color(0xFF2A1F25) // 卡片 val DarkSurface = Color(0xFF251C21) // 卡片
val DarkOnSurface = Color(0xFFEFE0E5) // 卡片文本 val DarkOnSurface = Color(0xFFF2E6EB) // 卡片文本
val DarkPrimary = Color(0xFFFFB3C6) // 暗色下主色更亮 val DarkPrimary = Color(0xFFFFA9BF) // 暗色下主色更亮
val DarkInkWarm = Color(0xFFEFE0E5) // 暗色下暖黑→浅粉白 val DarkInkWarm = Color(0xFFF2E6EB) // 暗色下暖黑→浅粉白
val DarkInkMuted = Color(0xFFC9B8BE) // 暗色下暖灰→浅灰粉 val DarkInkMuted = Color(0xFFCBB8C0) // 暗色下暖灰→浅灰粉
val DarkInkOutline = Color(0xFF4D3A42) // 暗色下描边 val DarkInkOutline = Color(0xFF4C3B44) // 暗色下描边

View File

@ -13,21 +13,22 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
/** /**
* 雨晴风格主题。 * 雨晴风格主题(精致粉调)。
* *
* - **不使用** dynamicColor(系统主题色),固定使用雨晴樱粉品牌色。 * - **不使用** dynamicColor(系统主题色),固定使用雨晴玫瑰粉品牌色。
* 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性; * 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性;
* 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。 * 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。
* - 暗色模式:暖深背景 + 亮草莓粉强调,跟 Light 模式同色系不同明度。 * - 主色:深玫瑰 #C9436C;容器色:浅玫瑰粉;背景:低饱和粉白渐变。
* - 暗色模式:暖深背景 + 亮玫瑰粉强调,跟 Light 模式同色系不同明度。
*/ */
private val LightColors = lightColorScheme( private val LightColors = lightColorScheme(
primary = StrawberryPink, primary = StrawberryPink,
onPrimary = PureWhite, onPrimary = PureWhite,
primaryContainer = StrawberryPinkSoft, primaryContainer = StrawberryPinkSoft,
onPrimaryContainer = InkWarm, onPrimaryContainer = Color(0xFF5C112C),
secondary = StrawberryPinkDark, secondary = Color(0xFFB7698B),
onSecondary = PureWhite, onSecondary = PureWhite,
secondaryContainer = Color(0xFFFFE4EC), secondaryContainer = Color(0xFFFFE7EE),
onSecondaryContainer = InkWarm, onSecondaryContainer = InkWarm,
tertiary = StatusGreen, tertiary = StatusGreen,
onTertiary = PureWhite, onTertiary = PureWhite,
@ -38,32 +39,32 @@ private val LightColors = lightColorScheme(
surfaceVariant = SnowWhite, surfaceVariant = SnowWhite,
onSurfaceVariant = InkMuted, onSurfaceVariant = InkMuted,
outline = InkOutline, outline = InkOutline,
outlineVariant = Color(0xFFEFE0E5), outlineVariant = Color(0xFFF2E7EB),
error = StatusRed, error = StatusRed,
onError = PureWhite onError = PureWhite
) )
private val DarkColors = darkColorScheme( private val DarkColors = darkColorScheme(
primary = DarkPrimary, primary = DarkPrimary,
onPrimary = DarkBackground, onPrimary = Color(0xFF4A1025),
primaryContainer = StrawberryPinkDark, primaryContainer = Color(0xFF6B2C42),
onPrimaryContainer = PureWhite, onPrimaryContainer = DarkOnSurface,
secondary = StrawberryPink, secondary = StrawberryPink,
onSecondary = DarkBackground, onSecondary = DarkBackground,
secondaryContainer = Color(0xFF4A2E3A), secondaryContainer = Color(0xFF4A2E3A),
onSecondaryContainer = DarkOnSurface, onSecondaryContainer = DarkOnSurface,
tertiary = StatusGreen, tertiary = Color(0xFF7BCF9E),
onTertiary = DarkBackground, onTertiary = Color(0xFF0D2B1C),
background = DarkBackground, background = DarkBackground,
onBackground = DarkOnSurface, onBackground = DarkOnSurface,
surface = DarkSurface, surface = DarkSurface,
onSurface = DarkOnSurface, onSurface = DarkOnSurface,
surfaceVariant = Color(0xFF352329), surfaceVariant = Color(0xFF32262C),
onSurfaceVariant = Color(0xFFC9B8BE), onSurfaceVariant = Color(0xFFCBB8C0),
outline = Color(0xFF4D3A42), outline = Color(0xFF4C3B44),
outlineVariant = Color(0xFF3A2A30), outlineVariant = Color(0xFF3B2C33),
error = Color(0xFFFF6B8E), error = Color(0xFFFF8194),
onError = PureWhite onError = Color(0xFF4A1025)
) )
@Composable @Composable

View File

@ -122,7 +122,7 @@ fun CodexOAuthScreen(
Text( Text(
uiState.error!!.asString(), uiState.error!!.asString(),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = androidx.compose.ui.graphics.Color(0xFFE91E63) color = MaterialTheme.colorScheme.error
) )
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) { TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
Text(stringResource(R.string.action_retry), color = StrawberryPink) Text(stringResource(R.string.action_retry), color = StrawberryPink)
@ -286,7 +286,7 @@ private fun HeadlessOAuthContent(
Text( Text(
text = error.asString(), text = error.asString(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color(0xFFE91E63), color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
) )
TextButton(onClick = onRetry) { TextButton(onClick = onRetry) {

View File

@ -339,9 +339,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
/** 根据用量百分比动态改进度条颜色 */ /** 根据用量百分比动态改进度条颜色 */
private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) { private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) {
val color = when { val color = when {
pct >= 80 -> 0xFFE91E63.toInt() // 玫红 pct >= 80 -> 0xFFD64A6E.toInt() // 玫红
pct >= 50 -> 0xFFFFA726.toInt() // 暖橙 pct >= 50 -> 0xFFFFA726.toInt() // 暖橙
else -> 0xFFFF85A2.toInt() // 草莓粉 else -> 0xFFC9436C.toInt() // 深玫瑰(主色)
} }
views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color)) views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color))
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -3,8 +3,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="rectangle">
<corners android:radius="16dp" /> <corners android:radius="16dp" />
<solid android:color="#2D1825" /> <solid android:color="#1A1417" />
<stroke <stroke
android:width="1dp" android:width="1dp"
android:color="#4D2D3D" /> android:color="#4C3B44" />
</shape> </shape>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- 雨晴Token 背景 · 樱粉渐变 --> <!-- 雨晴Token 背景 · 精致粉调渐变 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt" xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
@ -12,9 +12,9 @@
android:startX="0" android:startY="0" android:startX="0" android:startY="0"
android:endX="108" android:endY="108" android:endX="108" android:endY="108"
android:type="linear"> android:type="linear">
<item android:offset="0" android:color="#FFF0F5" /> <item android:offset="0" android:color="#FFF9FB" />
<item android:offset="0.5" android:color="#FFD1DC" /> <item android:offset="0.5" android:color="#FDEFF4" />
<item android:offset="1" android:color="#FFB3C1" /> <item android:offset="1" android:color="#FBDCE8" />
</gradient> </gradient>
</aapt:attr> </aapt:attr>
</path> </path>

View File

@ -7,14 +7,14 @@
android:viewportHeight="108"> android:viewportHeight="108">
<!-- <!--
配色统一雨晴品牌系: 配色统一雨晴品牌系(精致粉调):
· 水滴:草莓粉 #FF85A2(主品牌色) · 水滴:深玫瑰 #C9436C(主品牌色)
· sparkle:樱粉 #FFD1DC(浅层次) · sparkle:浅玫瑰粉 #FFC9D8(浅层次)
· 高光:白色 · 高光:白色
· 背景:樱粉渐变 #FFF0F5→#FFD1DC→#FFB3C1 · 背景:粉白渐变 #FFF9FB→#FDEFF4→#FBDCE8
--> -->
<!-- 大水滴 · 草莓粉 --> <!-- 大水滴 · 深玫瑰 -->
<path <path
android:pathData=" android:pathData="
M54,30 M54,30
@ -24,7 +24,7 @@
Q64,76 68,68 Q64,76 68,68
Q72,62 64,50 Q72,62 64,50
Q54,30 54,30Z" Q54,30 54,30Z"
android:fillColor="#FF85A2" /> android:fillColor="#C9436C" />
<!-- 水滴高光 · 白色椭圆 --> <!-- 水滴高光 · 白色椭圆 -->
<path <path
@ -32,24 +32,24 @@
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:fillAlpha="0.75" /> android:fillAlpha="0.75" />
<!-- sparkle 小太阳 · 樱粉 · 右上角 (72,32) --> <!-- sparkle 小太阳 · 浅玫瑰粉 · 右上角 (72,32) -->
<path android:pathData="M72,22L72,42" <path android:pathData="M72,22L72,42"
android:strokeColor="#FFD1DC" android:strokeWidth="3" android:strokeColor="#FFC9D8" android:strokeWidth="3"
android:strokeLineCap="round" android:strokeLineCap="round"
android:fillColor="@android:color/transparent" /> android:fillColor="@android:color/transparent" />
<path android:pathData="M62,32L82,32" <path android:pathData="M62,32L82,32"
android:strokeColor="#FFD1DC" android:strokeWidth="3" android:strokeColor="#FFC9D8" android:strokeWidth="3"
android:strokeLineCap="round" android:strokeLineCap="round"
android:fillColor="@android:color/transparent" /> android:fillColor="@android:color/transparent" />
<path android:pathData="M66,26L78,38" <path android:pathData="M66,26L78,38"
android:strokeColor="#FFD1DC" android:strokeWidth="2" android:strokeColor="#FFC9D8" android:strokeWidth="2"
android:strokeLineCap="round" android:strokeLineCap="round"
android:fillColor="@android:color/transparent" /> android:fillColor="@android:color/transparent" />
<path android:pathData="M78,26L66,38" <path android:pathData="M78,26L66,38"
android:strokeColor="#FFD1DC" android:strokeWidth="2" android:strokeColor="#FFC9D8" android:strokeWidth="2"
android:strokeLineCap="round" android:strokeLineCap="round"
android:fillColor="@android:color/transparent" /> android:fillColor="@android:color/transparent" />
<path android:pathData="M72,32m-2.5,0a2.5,2.5 0,1,1 5,0a2.5,2.5 0,1,1 -5,0" <path android:pathData="M72,32m-2.5,0a2.5,2.5 0,1,1 5,0a2.5,2.5 0,1,1 -5,0"
android:fillColor="#FFD1DC" /> android:fillColor="#FFC9D8" />
</vector> </vector>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
RainyToken 几何字标 · 与 OpenCode Go logo 完全同构 RainyToken 几何字标 · 与 OpenCode Go logo 完全同构
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴粉色系: 同样的两个 24x30 S 形方块拼接,只是配色换成雨晴精致粉系:
- Go 深色 #211E1E → 粉色 #C2185B - Go 深色 #211E1E → 深玫瑰 #C9436C
- Go 浅色 #CFCECD → 浅粉 #FFD1DC - Go 浅色 #CFCECD → 浅玫瑰粉 #FFC9D8
viewBox 和 path 与 ic_opencode_go_logo 完全一致 viewBox 和 path 与 ic_opencode_go_logo 完全一致
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
@ -11,20 +11,20 @@
android:height="30dp" android:height="30dp"
android:viewportWidth="54" android:viewportWidth="54"
android:viewportHeight="30"> android:viewportHeight="30">
<!-- 左块(深粉) --> <!-- 左块(深玫瑰) -->
<path <path
android:fillColor="#C2185B" android:fillColor="#C9436C"
android:pathData="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z"/> android:pathData="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z"/>
<!-- 左块 S 内弯(浅粉) --> <!-- 左块 S 内弯(浅粉) -->
<path <path
android:fillColor="#FFD1DC" android:fillColor="#FFC9D8"
android:pathData="M12 18H18V24H6V12H12V18Z"/> android:pathData="M12 18H18V24H6V12H12V18Z"/>
<!-- 右块 S 内弯(浅粉) --> <!-- 右块 S 内弯(浅粉) -->
<path <path
android:fillColor="#FFD1DC" android:fillColor="#FFC9D8"
android:pathData="M48 12V24H36V12H48Z"/> android:pathData="M48 12V24H36V12H48Z"/>
<!-- 右块(深粉) --> <!-- 右块(深玫瑰) -->
<path <path
android:fillColor="#C2185B" android:fillColor="#C9436C"
android:pathData="M54 30H30V0H54V30ZM36 24H48V6H36V24Z"/> android:pathData="M54 30H30V0H54V30ZM36 24H48V6H36V24Z"/>
</vector> </vector>

View File

@ -2,8 +2,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="rectangle">
<corners android:radius="16dp" /> <corners android:radius="16dp" />
<solid android:color="#FFF0F5" /> <solid android:color="#FFF9FB" />
<stroke <stroke
android:width="1dp" android:width="1dp"
android:color="#FFD1DC" /> android:color="#F2DCE4" />
</shape> </shape>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #2D1825 --> <!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #1A1417 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/background" android:id="@android:id/background"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -35,7 +35,7 @@
android:layout_width="10dp" android:layout_width="10dp"
android:layout_height="24dp" android:layout_height="24dp"
android:text=">" android:text=">"
android:textColor="#8A7A82" android:textColor="#CBB8C0"
android:textSize="14sp" android:textSize="14sp"
android:gravity="center" /> android:gravity="center" />
@ -44,7 +44,7 @@
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="24dp" android:layout_height="24dp"
android:text="OCGO" android:text="OCGO"
android:textColor="#FF85A2" android:textColor="#FFA9BF"
android:textSize="10sp" android:textSize="10sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
@ -75,7 +75,7 @@
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="24dp" android:layout_height="24dp"
android:text="↻" android:text="↻"
android:textColor="#FF85A2" android:textColor="#FFA9BF"
android:textSize="20sp" android:textSize="20sp"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:gravity="center" /> android:gravity="center" />
@ -99,7 +99,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_title_opencode" android:text="@string/widget_title_opencode"
android:textColor="#C4A0AB" android:textColor="#CBB8C0"
android:textSize="11sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
@ -113,10 +113,10 @@
<TextView android:id="@+id/row1_label" <TextView android:id="@+id/row1_label"
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#C4A0AB" android:textSize="11sp" /> android:text="5h" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row1_pct" <TextView android:id="@+id/row1_pct"
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp" android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row1_bar" <ProgressBar android:id="@+id/row1_bar"
style="@android:style/Widget.ProgressBar.Horizontal" style="@android:style/Widget.ProgressBar.Horizontal"
@ -126,7 +126,7 @@
android:progressTint="@color/widget_bar_5h" /> android:progressTint="@color/widget_bar_5h" />
<TextView android:id="@+id/row1_reset" <TextView android:id="@+id/row1_reset"
android:layout_width="48dp" android:layout_height="wrap_content" android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" /> android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<!-- 本周 --> <!-- 本周 -->
@ -139,10 +139,10 @@
<TextView android:id="@+id/row2_label" <TextView android:id="@+id/row2_label"
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#C4A0AB" android:textSize="11sp" /> android:text="@string/window_weekly" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct" <TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp" android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row2_bar" <ProgressBar android:id="@+id/row2_bar"
style="@android:style/Widget.ProgressBar.Horizontal" style="@android:style/Widget.ProgressBar.Horizontal"
@ -152,7 +152,7 @@
android:progressTint="@color/widget_bar_weekly" /> android:progressTint="@color/widget_bar_weekly" />
<TextView android:id="@+id/row2_reset" <TextView android:id="@+id/row2_reset"
android:layout_width="48dp" android:layout_height="wrap_content" android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" /> android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<!-- 本月 --> <!-- 本月 -->
@ -165,10 +165,10 @@
<TextView android:id="@+id/row3_label" <TextView android:id="@+id/row3_label"
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#C4A0AB" android:textSize="11sp" /> android:text="@string/window_monthly" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row3_pct" <TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp" android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row3_bar" <ProgressBar android:id="@+id/row3_bar"
style="@android:style/Widget.ProgressBar.Horizontal" style="@android:style/Widget.ProgressBar.Horizontal"
@ -178,7 +178,7 @@
android:progressTint="@color/widget_bar_monthly" /> android:progressTint="@color/widget_bar_monthly" />
<TextView android:id="@+id/row3_reset" <TextView android:id="@+id/row3_reset"
android:layout_width="48dp" android:layout_height="wrap_content" android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" /> android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) --> <!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
@ -200,14 +200,14 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance" android:text="@string/widget_deepseek_balance"
android:textColor="#C4A0AB" android:textColor="#CBB8C0"
android:textSize="11sp" /> android:textSize="11sp" />
<TextView <TextView
android:id="@+id/widget_ds_amount" android:id="@+id/widget_ds_amount"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="--" android:text="--"
android:textColor="#FFD1DC" android:textColor="#FFC9D8"
android:textSize="11sp" android:textSize="11sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="4dp" /> android:layout_marginStart="4dp" />
@ -233,7 +233,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_brand" android:text="@string/widget_brand"
android:textColor="#8A7A82" android:textColor="#CBB8C0"
android:textSize="9sp" /> android:textSize="9sp" />
</LinearLayout> </LinearLayout>

View File

@ -36,7 +36,7 @@
android:layout_width="10dp" android:layout_width="10dp"
android:layout_height="24dp" android:layout_height="24dp"
android:text=">" android:text=">"
android:textColor="#C4A0AB" android:textColor="#7A6870"
android:textSize="14sp" android:textSize="14sp"
android:gravity="center" /> android:gravity="center" />
@ -45,7 +45,7 @@
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="24dp" android:layout_height="24dp"
android:text="OCGO" android:text="OCGO"
android:textColor="#FF85A2" android:textColor="#C9436C"
android:textSize="10sp" android:textSize="10sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
@ -69,7 +69,7 @@
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:text="--" android:text="--"
android:textColor="#C4A0AB" android:textColor="#7A6870"
android:textSize="10sp" /> android:textSize="10sp" />
<TextView <TextView
@ -77,7 +77,7 @@
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="24dp" android:layout_height="24dp"
android:text="↻" android:text="↻"
android:textColor="#FF85A2" android:textColor="#C9436C"
android:textSize="20sp" android:textSize="20sp"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:gravity="center" /> android:gravity="center" />
@ -105,7 +105,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_title_opencode" android:text="@string/widget_title_opencode"
android:textColor="#8A7A82" android:textColor="#7A6870"
android:textSize="11sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
@ -120,11 +120,11 @@
<TextView android:id="@+id/row1_label" <TextView android:id="@+id/row1_label"
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" /> android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row1_pct" <TextView android:id="@+id/row1_pct"
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp" android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row1_bar" <ProgressBar android:id="@+id/row1_bar"
@ -136,7 +136,7 @@
<TextView android:id="@+id/row1_reset" <TextView android:id="@+id/row1_reset"
android:layout_width="48dp" android:layout_height="wrap_content" android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" /> android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<!-- 本周 --> <!-- 本周 -->
@ -150,11 +150,11 @@
<TextView android:id="@+id/row2_label" <TextView android:id="@+id/row2_label"
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" /> android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct" <TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp" android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row2_bar" <ProgressBar android:id="@+id/row2_bar"
@ -166,7 +166,7 @@
<TextView android:id="@+id/row2_reset" <TextView android:id="@+id/row2_reset"
android:layout_width="48dp" android:layout_height="wrap_content" android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" /> android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<!-- 本月 --> <!-- 本月 -->
@ -180,11 +180,11 @@
<TextView android:id="@+id/row3_label" <TextView android:id="@+id/row3_label"
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" /> android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row3_pct" <TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp" android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row3_bar" <ProgressBar android:id="@+id/row3_bar"
@ -196,7 +196,7 @@
<TextView android:id="@+id/row3_reset" <TextView android:id="@+id/row3_reset"
android:layout_width="48dp" android:layout_height="wrap_content" android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" /> android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) --> <!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
@ -221,7 +221,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance" android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82" android:textColor="#7A6870"
android:textSize="11sp" /> android:textSize="11sp" />
<TextView <TextView
@ -229,7 +229,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="--" android:text="--"
android:textColor="#3D2C35" android:textColor="#3A2B31"
android:textSize="11sp" android:textSize="11sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="4dp" /> android:layout_marginStart="4dp" />
@ -258,7 +258,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_brand" android:text="@string/widget_brand"
android:textColor="#D4C4CC" android:textColor="#7A6870"
android:textSize="9sp" /> android:textSize="9sp" />
</LinearLayout> </LinearLayout>

View File

@ -42,7 +42,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_auto_refresh" android:text="@string/widget_auto_refresh"
android:textColor="#C4A0AB" android:textColor="#7A6870"
android:textSize="10sp" /> android:textSize="10sp" />
</LinearLayout> </LinearLayout>
@ -64,7 +64,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_title_opencode" android:text="@string/widget_title_opencode"
android:textColor="#8A7A82" android:textColor="#7A6870"
android:textSize="11sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
@ -79,11 +79,11 @@
<TextView <TextView
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" /> android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
<TextView <TextView
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="42%" android:textColor="#3D2C35" android:textSize="12sp" android:text="42%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar <ProgressBar
@ -91,7 +91,7 @@
android:layout_width="0dp" android:layout_height="6dp" android:layout_width="0dp" android:layout_height="6dp"
android:layout_weight="1" android:layout_marginStart="8dp" android:layout_weight="1" android:layout_marginStart="8dp"
android:layout_marginEnd="8dp" android:max="100" android:progress="42" android:layout_marginEnd="8dp" android:max="100" android:progress="42"
android:progressTint="#FF85A2" /> android:progressTint="#C9436C" />
</LinearLayout> </LinearLayout>
<!-- 本周 --> <!-- 本周 -->
@ -105,11 +105,11 @@
<TextView <TextView
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" /> android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView <TextView
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="68%" android:textColor="#3D2C35" android:textSize="12sp" android:text="68%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar <ProgressBar
@ -131,11 +131,11 @@
<TextView <TextView
android:layout_width="44dp" android:layout_height="wrap_content" android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end" android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" /> android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView <TextView
android:layout_width="38dp" android:layout_height="wrap_content" android:layout_width="38dp" android:layout_height="wrap_content"
android:text="35%" android:textColor="#3D2C35" android:textSize="12sp" android:text="35%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" /> android:textStyle="bold" android:gravity="end" />
<ProgressBar <ProgressBar
@ -143,7 +143,7 @@
android:layout_width="0dp" android:layout_height="6dp" android:layout_width="0dp" android:layout_height="6dp"
android:layout_weight="1" android:layout_marginStart="8dp" android:layout_weight="1" android:layout_marginStart="8dp"
android:layout_marginEnd="8dp" android:max="100" android:progress="35" android:layout_marginEnd="8dp" android:max="100" android:progress="35"
android:progressTint="#FF85A2" /> android:progressTint="#C9436C" />
</LinearLayout> </LinearLayout>
<!-- DeepSeek 余额 --> <!-- DeepSeek 余额 -->
@ -165,14 +165,14 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance" android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82" android:textColor="#7A6870"
android:textSize="11sp" /> android:textSize="11sp" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="¥18.00" android:text="¥18.00"
android:textColor="#3D2C35" android:textColor="#3A2B31"
android:textSize="11sp" android:textSize="11sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="4dp" /> android:layout_marginStart="4dp" />

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- 进度条颜色:<50% 粉色 / 50-80% 橙色 / >80% 玫红 --> <!-- 进度条颜色:<50% 玫瑰 / 50-80% 橙色 / >80% 玫红(与 App 内状态色一致) -->
<color name="widget_bar_5h">#FF85A2</color> <color name="widget_bar_5h">#C9436C</color>
<color name="widget_bar_weekly">#FFA726</color> <color name="widget_bar_weekly">#FFA726</color>
<color name="widget_bar_monthly">#E91E63</color> <color name="widget_bar_monthly">#D64A6E</color>
</resources> </resources>

136
docs/签到核心请求.md Normal file
View File

@ -0,0 +1,136 @@
# WB(WorkBuddy)与 Trae 每日签到核心请求
> 本文档整理自仓库现有实现(源码为准):
> - `app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt`
> - `app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt`
>
> 其中 **WB = 腾讯 WorkBuddy**(非微博)。所有请求均为 `POST`,body 默认为 `{}`。
## 一、端点汇总
| 服务 | 用途 | URL | Body |
| --- | --- | --- | --- |
| Trae(CN) | 查询签到状态 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/status` | `{}` |
| Trae(CN) | 执行签到 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim` | `{}`(9074 时换 `{"req_source":2}` → `{"req_source":1}`) |
| WorkBuddy | 查询签到状态(优先) | `https://www.codebuddy.cn/v2/billing/meter/checkin-activity-status` | `{}` |
| WorkBuddy | 查询签到状态(兜底) | `https://www.codebuddy.cn/v2/billing/meter/checkin-status` | `{}` |
| WorkBuddy | 执行签到 | `https://www.codebuddy.cn/v2/billing/meter/daily-checkin` | `{}` |
| WorkBuddy | accessToken 刷新 | `https://copilot.tencent.com/v2/plugin/auth/token/refresh` | 头 `X-Refresh-Token` |
## 二、Trae 核心请求源码
认证头:`Authorization: Cloud-IDE-JWT <jwt>`,`x-device-id` 必须是账号绑定的官方客户端设备 ID。
```kotlin
// 官方签到接口路径
const val CHECKIN_STATUS_PATH = "/trae/api/v2/ug/checkin_credits/status"
const val CHECKIN_CLAIM_PATH = "/trae/api/v2/ug/checkin_credits/claim"
private fun checkinRequest(
jwt: String,
deviceId: String,
deviceSrc: String,
path: String,
bodyJson: String = "{}"
): Result<String> {
val requestId = java.util.UUID.randomUUID().toString()
val request = Request.Builder()
.url("https://api.trae.cn" + path)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "*/*")
.addHeader("User-Agent", "VSCode 1.107.1 (TRAE SOLO CN)")
.addHeader("Authorization", "Cloud-IDE-JWT " + jwt)
.addHeader("X-User-Region", "CN")
.addHeader("Accept-Language", "zh-CN")
.addHeader("Package-Type", "stable_cn")
.addHeader("X-Lgw-Req-Sdk-Type", "3")
.addHeader("X-Market-Client-Id", "VSCode 1.107.1")
.addHeader("X-Device-Brand", "83DG")
.addHeader("X-Device-Type", "windows")
.addHeader("X-OS-Version", "Windows 11 Pro")
.addHeader("App-Version", "0.1.61")
.addHeader("X-Request-Id", requestId)
.addHeader("X-TT-Trace-Id", ttTraceId(requestId))
.addHeader("X-Device-Id", deviceId)
.addHeader("Sec-Fetch-Dest", "empty")
.addHeader("Sec-Fetch-Mode", "no-cors")
.addHeader("Sec-Fetch-Site", "none")
.post(bodyJson.toRequestBody("application/json".toMediaType()))
.build()
// ...
}
```
### Trae 签到结果判定
- `code = 0 / 200` → **SUCCESS**(成功)
- `code = 1001 / 10001`,或文案含 `已签到 / 已经签到 / 明日再来 / 今日已完成 / 已领取 / already / checked / claimed` → **ALREADY**(今日已签)
- 文案含 `设备 / device / machine` → **DEVICE_BLOCKED**(设备级拦截)
- 文案含 `参与 / 稍后重试 / 火爆 / 繁忙 / 限流 / 排队 / too many / retry / busy / popular / later` → **RETRYABLE**(临时限流)
- 其余 → **FAILED**
### Trae 注意事项
- UA 必须是 `VSCode 1.107.1 (TRAE SOLO CN)`,这是签到走的 VSCode 插件进程身份;与 IDE 主进程 / Web 混用会触发风控。
- `X-Device-Id` 不可用固定 ClientID `en1oxy7wnw8j9n`(所有用户相同),会按设备风控拒绝。
- 仓库内置默认设备 ID:`ACCOUNT_BOUND_DEVICE_ID = "895482260673817"`,账号级,换设备需更新。
- INTL 区签到接口未实测,仓库实现直接不支持。
## 三、WorkBuddy(WB)核心请求源码
认证头:`Authorization: Bearer <accessToken>`。签到域名是 `www.codebuddy.cn`(余额主查询才是 `www.workbuddy.cn`)。
```kotlin
// 查询今日签到状态(两条路径依次 fallback,共享 token 轮换状态)
suspend fun fetchCheckinStatus(accountId: String? = null): Result<CheckinStatus> {
val body = authedPostFirstSuccessful(
credential,
accountId,
"/v2/billing/meter/checkin-activity-status",
"/v2/billing/meter/checkin-status"
) ?: return Result.failure(RepositoryError.Unknown())
return runCatching { parseCheckin(body) }...
}
// 执行签到
suspend fun checkIn(accountId: String? = null): Result<CheckinStatus> {
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
is AuthedPostResult.Ok -> r.body
is AuthedPostResult.Failed -> r.body
// ...
}
// code=0 成功;code=10001 或文案含「已签/今日/already」→ 已签
}
// 带自动 refresh 一次的 POST 核心
private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): AuthedPostResult {
var access = credential.accessToken
var refresh = credential.refreshToken
repeat(2) { attempt ->
val request = Request.Builder()
.url("https://www.codebuddy.cn" + path)
.addHeader("Authorization", "Bearer " + access)
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody(jsonMedia))
.build()
// 2xx → Ok
// 401/403 且第一次尝试 → tryRefesh(refresh) 换新 token 后重试
// refresh 请求头携带 X-Refresh-Token <refreshToken>
// 轮换出的新凭据必须先落库再用,否则旧 refreshToken 单次消费后失效
}
}
```
### WorkBuddy 签到结果判定
- `code = 0` → **SUCCESS**
- `code = 10001`,或文案含 `已签 / 已经签到 / 今日 / already / checked / claimed` → **ALREADY**(今日已签)
- 其余 → **FAILED**(HTTP 400 body 也可能携带签到业务语义,需透传解析,不能直接当网络错误丢弃)
## 四、通用约定
1. Trae 用 `Cloud-IDE-JWT`,WorkBuddy 用 `Bearer`,两者凭据类型不同,不能混用。
2. 两者签到都幂等:今日已签时服务端返回已签语义,客户端本地也会记录 `lastCheckinDate` 短路,避免重复请求。
3. 401/403:Trae 直接视为凭据失效;WorkBuddy 会尝试用 refreshToken 轮换一次。
4. 不要随机生成设备 ID(Trae)或篡改客户端头,否则会被风控(Trae 9074「参与用户太多」)。