diff --git a/README.en.md b/README.en.md
index 4721367..4dd2140 100644
--- a/README.en.md
+++ b/README.en.md
@@ -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 |
| 💡 **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 |
+| 🎀 **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 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 |
diff --git a/README.md b/README.md
index 00b1515..cf9a4c8 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
| ⚡ **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 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
| ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 |
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fc63127..18632ca 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+
+
+
+
+
+
+
result.getOrNull()?.let { s ->
- if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS ||
- s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY
- ) {
- checkinStatusStore.markChecked(ServiceType.WORKBUDDY, accountId)
+ val isChecked = s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS ||
+ s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY ||
+ s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY
+ 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 ||
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
+ )
}
}
}
diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt
index 277c7ad..903d253 100644
--- a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt
@@ -66,6 +66,7 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.formatAmount
import com.rainy.token.ui.components.resolve
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.inkMuted
import java.text.SimpleDateFormat
@@ -420,7 +421,7 @@ private fun ApiRowCard(
@Composable
private fun StatusDot(state: CredentialStatus.State) {
val color = when (state) {
- CredentialStatus.State.OK -> Color(0xFF3BB273)
+ CredentialStatus.State.OK -> StatusGreen
CredentialStatus.State.WARNING -> StatusOrange
CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error
CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f)
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 15d7cb8..ee4ec33 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
@@ -39,6 +39,8 @@ 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.StatusOrange
+import com.rainy.token.ui.theme.StatusRed
import com.rainy.token.ui.theme.StrawberryPink
@OptIn(ExperimentalMaterial3Api::class)
@@ -103,8 +105,8 @@ fun DebugLogScreen(onBack: () -> Unit) {
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
val levelColor = when (entry.level) {
DebugLog.Level.INFO -> InkMuted
- DebugLog.Level.WARN -> Color(0xFFFFA726)
- DebugLog.Level.ERROR -> Color(0xFFE91E63)
+ DebugLog.Level.WARN -> StatusOrange
+ DebugLog.Level.ERROR -> StatusRed
}
Card(
modifier = Modifier.fillMaxWidth(),
diff --git a/app/src/main/java/com/rainy/token/ui/components/RainyBackground.kt b/app/src/main/java/com/rainy/token/ui/components/RainyBackground.kt
index f2c7057..103b960 100644
--- a/app/src/main/java/com/rainy/token/ui/components/RainyBackground.kt
+++ b/app/src/main/java/com/rainy/token/ui/components/RainyBackground.kt
@@ -13,10 +13,10 @@ import com.rainy.token.ui.theme.DarkSurface
import androidx.compose.foundation.isSystemInDarkTheme
/**
- * 雨晴风格全局背景。
+ * 雨晴风格全局背景(精致粉调)。
*
- * Light 模式:樱粉渐变(#FFF0F5 → #FFD1DC,从左上到右下)
- * Dark 模式:暖深渐变(#1F1419 → #2A1F25)
+ * Light 模式:低饱和粉白渐变(#FFF9FB → #FDEFF4,从左上到右下)
+ * Dark 模式:暖深渐变(#1A1417 → #251C21)
*
* 放在 Scaffold 容器层,所有页面共享同一层渐变。
*/
diff --git a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt
index 0072828..13941d3 100644
--- a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt
+++ b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt
@@ -44,7 +44,7 @@ fun ServiceIcon(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
- .background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
+ .background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
contentAlignment = Alignment.Center
) {
Image(
@@ -59,7 +59,7 @@ fun ServiceIcon(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
- .background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
+ .background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
contentAlignment = Alignment.Center
) {
Image(
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 4ae9658..cab238d 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
@@ -1,6 +1,7 @@
package com.rainy.token.ui.components
import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -44,11 +45,14 @@ fun StatusChip(
style: StatusStyle,
modifier: Modifier = Modifier
) {
+ // 暗色模式下胶囊底色是状态色 12% alpha 叠加暗色卡片,
+ // 深色文字几乎不可读,故暗色使用同色系亮色文字。
+ val dark = isSystemInDarkTheme()
val (dotColor, textColor) = when (style.level) {
- StatusLevel.OK -> StatusGreen to Color(0xFF1B5E20)
- StatusLevel.WARNING -> StatusOrange to Color(0xFF8A4A00)
- StatusLevel.ERROR -> StatusRed to Color(0xFF8B0033)
- StatusLevel.INFO -> StatusBlue to Color(0xFF0D47A1)
+ StatusLevel.OK -> StatusGreen to if (dark) Color(0xFF9CE8BC) else Color(0xFF1B5E20)
+ StatusLevel.WARNING -> StatusOrange to if (dark) Color(0xFFFFDBA6) else Color(0xFF8A4A00)
+ StatusLevel.ERROR -> StatusRed to if (dark) Color(0xFFFFB6CA) else Color(0xFF8A1F40)
+ StatusLevel.INFO -> StatusBlue to if (dark) Color(0xFFAED7FF) else Color(0xFF0D47A1)
}
val bgColor = dotColor.copy(alpha = 0.12f)
Row(
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 fa035fe..c9fe07f 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
@@ -13,6 +13,7 @@ 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.dashboard.autocheckin.CHECKIN_SERVICES
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -62,6 +63,8 @@ class DashboardViewModel @Inject constructor(
// 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。
// 用 try/catch 而非 runCatching:后者会吞掉 CancellationException,
// 导致 ViewModel 销毁取消后协程仍继续往下跑。
+ // 2.5) 打开 App 时把零点后台闹钟重排到下次零点(开机/升级/后台被杀后的兜底)
+ autoCheckinManager.refreshSchedule()
try {
autoCheckinManager.runDailyIfDue()
} catch (cancelled: CancellationException) {
@@ -475,9 +478,6 @@ private data class AccountKey(
val accountId: String? = null
)
-/** 支持每日签到的服务(首页角标与自动签到的统一口径)。 */
-internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY)
-
/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
private fun errorToUiText(error: Throwable): UiText = when (error) {
is RepositoryError.SessionExpired ->
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 64f5a28..1c365fa 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
@@ -76,7 +76,7 @@ import java.util.Locale
// 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型)
internal val modelColors = listOf(
Color(0xFFF44336), // Red
- Color(0xFFE91E63), // Pink
+ Color(0xFFC9436C), // Pink(玫瑰主色)
Color(0xFF9C27B0), // Purple
Color(0xFF673AB7), // Deep Purple
Color(0xFF3F51B5), // Indigo
@@ -89,11 +89,11 @@ internal val modelColors = listOf(
Color(0xFF607D8B) // Blue Grey
)
-// Token 堆叠色板:命中缓存(浅粉·顶)、未命中输入(粉·中)、输出(深粉·底)
+// Token 堆叠色板:命中缓存(浅樱粉·顶)、未命中输入(玫瑰粉·中)、输出(深玫瑰·底)
internal val tokenColors = listOf(
- Color(0xFFFFD1DC), // cache hit — 浅粉(上面)
- Color(0xFFFF85A2), // cache miss input — 粉色(中间)
- Color(0xFFE91E63) // output — 深粉(下面)
+ Color(0xFFFFE3EC), // cache hit — 浅樱粉(上面)
+ Color(0xFFFFA3BC), // cache miss input — 玫瑰粉(中间)
+ Color(0xFFC9436C) // output — 深玫瑰(主色,下面)
)
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinEntryPoint.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinEntryPoint.kt
new file mode 100644
index 0000000..21f2e68
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinEntryPoint.kt
@@ -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
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt
index 8ca1919..515d0ae 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt
@@ -1,6 +1,9 @@
package com.rainy.token.ui.dashboard.autocheckin
+import android.app.AlarmManager
+import android.app.PendingIntent
import android.content.Context
+import android.content.Intent
import com.rainy.token.data.repository.CheckinStatusStore
import com.rainy.token.data.repository.CredentialRepository
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.usecase.RefreshBalanceUseCase
import dagger.hilt.android.qualifiers.ApplicationContext
-import java.time.LocalDate
+import java.time.ZonedDateTime
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
/**
* 每日自动签到编排器(小组件开关控制启用)。
*
- * 行为契约(用户需求 2026-09-13 / 多账号版 2026-10):
- * - 开启后,每天首次打开 App 自动为所有「已配置凭据」的可签服务的【默认账号】各签一次;
- * - 当日只签一次:[CheckinStatusStore] 有当日记录的账号直接跳过(手动签过也算);
+ * 行为契约(用户需求 2026-09-13 / 多账号版 2026-10 / 零点后台版 2026-11):
+ * - 开启后,每天零点后由 AlarmManager 自动触发([AutoCheckinReceiver]),
+ * 为所有「已配置凭据」的可签服务的【全部账号】各签一次;
+ * - 兜底:每天首次打开 App(DashboardViewModel)也会补签一次(错过闹钟/被杀进程场景);
+ * - 当日每账号只签一次:本地有「今日已成功签到」记录的账号直接跳过(手动签过也算);
+ * 失败/限流/设备拦截等非成功记录不占位,下次打开 App 会再次唤起签到流程;
* - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」;
* - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。
*
* 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。
+ *
+ * 调度说明:使用框架自带的 AlarmManager(非精确 setAndAllowWhileIdle)实现「每日零点后
+ * 后台签到」,零新增依赖;重启设备后闹钟由 BOOT_COMPLETED 广播重排。国产 ROM(如 vivo/
+ * iQOO)可能因电池优化延迟或抑制该闹钟,届时由「首次打开 App」兜底补签。
*/
@Singleton
class AutoCheckinManager @Inject constructor(
@@ -54,52 +66,54 @@ class AutoCheckinManager @Inject constructor(
)
/**
- * 自动签到汇总结果。null=没有可展示的内容(未开启/无默认账号/全部跳过)。
+ * 自动签到汇总结果。null=没有可展示的内容(未开启/无候选账号/全部账号已签或静默跳过)。
* 弹窗消费后由 [consumeReport] 置 null。
*/
private val _report = MutableStateFlow?>(null)
val report: StateFlow?> = _report.asStateFlow()
+ /** 防同日并发重复签到:闹钟广播(IO 线程)与 Dashboard init(viewModelScope)可能同时进入。 */
+ private val runMutex = Mutex()
+
private fun prefs() =
context.getSharedPreferences("auto_checkin", Context.MODE_PRIVATE)
- /** 自动签到开关(小组件 CheckBox 与 App 内共用)。 */
+ /** 自动签到开关(小组件 CheckBox 与 App 内共用)。开关变更时同步排/撤零点闹钟。 */
var enabled: Boolean
get() = prefs().getBoolean(KEY_ENABLED, false)
set(value) {
prefs().edit().putBoolean(KEY_ENABLED, value).apply()
- }
-
- /** 已执行过「今日自动签到」的日期(防止同日多次开 App 重复跑)。 */
- private var lastRunDate: String?
- get() = prefs().getString(KEY_LAST_RUN, null)
- set(value) {
- prefs().edit().putString(KEY_LAST_RUN, value).apply()
+ refreshSchedule()
}
/**
- * 每日首次打开 App 时调用:开启且今日未跑过 → 为已配置凭据的可签服务默认账号各签一次。
+ * 每日签到执行入口(后台闹钟与首次打开 App 共用):
+ * 开启时 → 为已配置凭据的可签服务中【今日尚未成功签到】的账号各签一次。
* 返回 true 表示本次实际执行了签到(无论结果)。
*/
- suspend fun runDailyIfDue(): Boolean {
+ suspend fun runDailyIfDue(): Boolean = runMutex.withLock {
if (!enabled) return false
- val today = LocalDate.now().toString()
- if (lastRunDate == today) return false
- // 先占位再执行:即使执行中崩溃/杀进程,同日再开也不会重跑(宁可少签不重复打扰)
- lastRunDate = today
-
+ // 不再使用全局 lastRunDate 占位:只有真正「今日签到成功」的账号才跳过,
+ // 失败/限流/设备拦截等不会消费当天机会,重开 App 可再次唤起签到流程。
val results = mutableListOf()
for (service in CHECKIN_SERVICES) {
- val defaultAccount = credentialRepository.getDefaultAccount(service) ?: continue
- if (credentialRepository.statusFor(service, defaultAccount.id).state ==
- CredentialStatus.State.NOT_CONFIGURED
- ) {
- continue
+ // 多账号服务遍历全部账号;无账号文档时 getDefaultAccount 同样返回 null,
+ // 候选为空 = 不签,与旧版「只签默认账号」的边界行为一致,不会误签旧凭据
+ 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
+ ) {
+ continue
+ }
+ // 今日已有成功签到记录(手动签过也算)→ 跳过,不再发网络请求
+ if (checkinStatusStore.isCheckedToday(service, account.id)) continue
+ // runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目
+ runOne(service, account)?.let { results += it }
}
- // 当日已有签到记录(手动签过)→ 跳过,不再发网络请求
- if (checkinStatusStore.isCheckedToday(service, defaultAccount.id)) continue
- // runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目
- runOne(service, defaultAccount)?.let { results += it }
}
if (results.isNotEmpty()) {
_report.value = results
@@ -107,6 +121,39 @@ class AutoCheckinManager @Inject constructor(
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 不支持签到、静默跳过)。
*/
@@ -132,15 +179,24 @@ class AutoCheckinManager @Inject constructor(
ServiceResult(service, label, false, true, 0, "", false)
// INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」)
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null
- TraeRepository.CheckinOutcome.DEVICE_BLOCKED ->
+ TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> {
+ recordFailure(service, account.id, CheckinStatusStore.STATUS_DEVICE_BLOCKED)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
- TraeRepository.CheckinOutcome.RETRYABLE ->
+ }
+ TraeRepository.CheckinOutcome.RETRYABLE -> {
+ recordFailure(service, account.id, CheckinStatusStore.STATUS_RETRYABLE)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
- TraeRepository.CheckinOutcome.FAILED ->
+ }
+ 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(
onSuccess = { s ->
@@ -158,11 +214,16 @@ class AutoCheckinManager @Inject constructor(
ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.QUERY ->
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)
+ }
}
},
- 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 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败)
else -> null
@@ -173,7 +234,7 @@ class AutoCheckinManager @Inject constructor(
* 签到请求失败的汇报结果。
*
* 竞态兜底:签到请求在服务端已成功([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() {
_report.value = null
}
- private companion object {
+ companion object {
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)
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinReceiver.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinReceiver.kt
new file mode 100644
index 0000000..dc7f5a7
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinReceiver.kt
@@ -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()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt
index 8bc860e..0069700 100644
--- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt
+++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt
@@ -66,24 +66,24 @@ import kotlinx.coroutines.flow.first
// ── 热力图颜色 ──────────────────────────────────────────────
-/** 亮色模式 6 级粉色梯度 */
+/** 亮色模式 6 级玫瑰粉梯度(精致粉调,最高级与主色一致) */
private val LightHeatmapColors = listOf(
- Color(0xFFEFE0E5), // Level 0: 极浅暖灰
- Color(0xFFFFD6E0), // Level 1: 浅樱粉
- Color(0xFFFFB3C6), // Level 2: 草莓粉柔
- Color(0xFFFF85A2), // Level 3: 草莓粉
- Color(0xFFFF6B8E), // Level 4: 草莓粉深
- Color(0xFFE84973), // Level 5: 玫红
+ Color(0xFFF3E9EE), // Level 0: 极浅暖灰
+ Color(0xFFFFE3EC), // Level 1: 浅樱粉
+ Color(0xFFFFC9D9), // Level 2: 浅玫瑰柔
+ Color(0xFFFFA3BC), // Level 3: 玫瑰粉
+ Color(0xFFF27E9F), // Level 4: 玫瑰粉深
+ Color(0xFFC9436C), // Level 5: 深玫瑰(主色)
)
-/** 暗色模式 6 级粉色梯度 */
+/** 暗色模式 6 级玫瑰粉梯度 */
private val DarkHeatmapColors = listOf(
- Color(0xFF3A2A30), // Level 0: 暖深灰
- Color(0xFF4A2E3A), // Level 1
- Color(0xFF6B3D4A), // Level 2
- Color(0xFF8B4D5A), // Level 3
- Color(0xFFB86880), // Level 4
- Color(0xFFD67F9A), // Level 5
+ Color(0xFF3A2B33), // Level 0: 暖深灰
+ Color(0xFF4A3239), // Level 1
+ Color(0xFF643D4A), // Level 2
+ Color(0xFF855062), // Level 3
+ Color(0xFFA9627A), // Level 4
+ Color(0xFFE07C9A), // Level 5
)
/** 月份名称资源(按月索引 1~12,locale-aware) */
diff --git a/app/src/main/java/com/rainy/token/ui/theme/Color.kt b/app/src/main/java/com/rainy/token/ui/theme/Color.kt
index 467e195..8502a18 100644
--- a/app/src/main/java/com/rainy/token/ui/theme/Color.kt
+++ b/app/src/main/java/com/rainy/token/ui/theme/Color.kt
@@ -3,40 +3,41 @@ package com.rainy.token.ui.theme
import androidx.compose.ui.graphics.Color
/**
- * 雨晴风格配色(RainyStyle Palette)。
+ * 雨晴风格配色(RainyStyle Palette)— 精致粉调。
*
- * 视觉基调:元气樱粉 / 草莓粉主调,搭配纯白卡片与暖灰文本。
+ * 视觉基调:低饱和樱粉白背景 + 纯白卡片 + 深玫瑰主色点缀,
+ * 通过克制用色与留白呈现精致感,保留"雨晴粉"品牌识别度。
* 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。
*
- * 背景使用樱粉渐变(#FFF0F5 → #FFD1DC),主元素采用草莓粉
- * (#FF85A2 / #FF6B8E),与白色形成层次。
+ * 背景使用柔和粉白渐变(#FFF9FB → #FDEFF4),主元素采用深玫瑰
+ * (#C9436C / #B3305B),与白色形成优雅层次。
*/
// ─── 品牌主色 ───
-val CherryPinkLight = Color(0xFFFFF0F5) // 樱粉浅(背景起始)
-val CherryPinkDeep = Color(0xFFFFD1DC) // 樱粉深(背景结束)
-val StrawberryPink = Color(0xFFFF85A2) // 草莓粉(主色 / 强调)
-val StrawberryPinkDark = Color(0xFFFF6B8E)// 草莓粉深(按下态 / 主按钮)
-val StrawberryPinkSoft = Color(0xFFFFB3C6)// 草莓粉柔(hover / 副按钮)
+val CherryPinkLight = Color(0xFFFFF9FB) // 粉白(背景起始)
+val CherryPinkDeep = Color(0xFFFDEFF4) // 浅樱粉(背景结束)
+val StrawberryPink = Color(0xFFC9436C) // 深玫瑰(主色 / 强调,白底对比度 ≥ 4.5:1)
+val StrawberryPinkDark = Color(0xFFB3305B)// 深玫瑰按下态(主按钮)
+val StrawberryPinkSoft = Color(0xFFFFC9D8)// 浅玫瑰柔(容器 / 选中背景)
// ─── 中性色 ───
val PureWhite = Color(0xFFFFFFFF)
-val SnowWhite = Color(0xFFFAF6F8) // 卡片次级背景
-val InkWarm = Color(0xFF3D2C35) // 暖黑(主文本)
-val InkMuted = Color(0xFF7A6B72) // 暖灰(次要文本)
-val InkOutline = Color(0xFFD9CFD3) // 暖灰(分隔线 / 描边)
+val SnowWhite = Color(0xFFFAF5F7) // 卡片次级背景
+val InkWarm = Color(0xFF3A2B31) // 暖黑(主文本)
+val InkMuted = Color(0xFF7A6870) // 暖灰(次要文本,白底 ≥ 4.5:1)
+val InkOutline = Color(0xFFE8DCE1) // 暖灰(分隔线 / 描边)
// ─── 状态色 ───
-val StatusGreen = Color(0xFF66BB6A) // 正常
+val StatusGreen = Color(0xFF4CAF50) // 正常
val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登
-val StatusRed = Color(0xFFE91E63) // 错误 / 异常
+val StatusRed = Color(0xFFD64A6E) // 错误 / 异常(玫红,与粉调统一)
val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存)
// ─── 暗色模式 ───
-val DarkBackground = Color(0xFF1F1419) // 暖深背景
-val DarkSurface = Color(0xFF2A1F25) // 卡片
-val DarkOnSurface = Color(0xFFEFE0E5) // 卡片文本
-val DarkPrimary = Color(0xFFFFB3C6) // 暗色下主色更亮
-val DarkInkWarm = Color(0xFFEFE0E5) // 暗色下暖黑→浅粉白
-val DarkInkMuted = Color(0xFFC9B8BE) // 暗色下暖灰→浅灰粉
-val DarkInkOutline = Color(0xFF4D3A42) // 暗色下描边
+val DarkBackground = Color(0xFF1A1417) // 暖深背景
+val DarkSurface = Color(0xFF251C21) // 卡片
+val DarkOnSurface = Color(0xFFF2E6EB) // 卡片文本
+val DarkPrimary = Color(0xFFFFA9BF) // 暗色下主色更亮
+val DarkInkWarm = Color(0xFFF2E6EB) // 暗色下暖黑→浅粉白
+val DarkInkMuted = Color(0xFFCBB8C0) // 暗色下暖灰→浅灰粉
+val DarkInkOutline = Color(0xFF4C3B44) // 暗色下描边
diff --git a/app/src/main/java/com/rainy/token/ui/theme/Theme.kt b/app/src/main/java/com/rainy/token/ui/theme/Theme.kt
index c375f84..e297063 100644
--- a/app/src/main/java/com/rainy/token/ui/theme/Theme.kt
+++ b/app/src/main/java/com/rainy/token/ui/theme/Theme.kt
@@ -13,21 +13,22 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
/**
- * 雨晴风格主题。
+ * 雨晴风格主题(精致粉调)。
*
- * - **不使用** dynamicColor(系统主题色),固定使用雨晴樱粉品牌色。
+ * - **不使用** dynamicColor(系统主题色),固定使用雨晴玫瑰粉品牌色。
* 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性;
* 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。
- * - 暗色模式:暖深背景 + 亮草莓粉强调,跟 Light 模式同色系不同明度。
+ * - 主色:深玫瑰 #C9436C;容器色:浅玫瑰粉;背景:低饱和粉白渐变。
+ * - 暗色模式:暖深背景 + 亮玫瑰粉强调,跟 Light 模式同色系不同明度。
*/
private val LightColors = lightColorScheme(
primary = StrawberryPink,
onPrimary = PureWhite,
primaryContainer = StrawberryPinkSoft,
- onPrimaryContainer = InkWarm,
- secondary = StrawberryPinkDark,
+ onPrimaryContainer = Color(0xFF5C112C),
+ secondary = Color(0xFFB7698B),
onSecondary = PureWhite,
- secondaryContainer = Color(0xFFFFE4EC),
+ secondaryContainer = Color(0xFFFFE7EE),
onSecondaryContainer = InkWarm,
tertiary = StatusGreen,
onTertiary = PureWhite,
@@ -38,32 +39,32 @@ private val LightColors = lightColorScheme(
surfaceVariant = SnowWhite,
onSurfaceVariant = InkMuted,
outline = InkOutline,
- outlineVariant = Color(0xFFEFE0E5),
+ outlineVariant = Color(0xFFF2E7EB),
error = StatusRed,
onError = PureWhite
)
private val DarkColors = darkColorScheme(
primary = DarkPrimary,
- onPrimary = DarkBackground,
- primaryContainer = StrawberryPinkDark,
- onPrimaryContainer = PureWhite,
+ onPrimary = Color(0xFF4A1025),
+ primaryContainer = Color(0xFF6B2C42),
+ onPrimaryContainer = DarkOnSurface,
secondary = StrawberryPink,
onSecondary = DarkBackground,
secondaryContainer = Color(0xFF4A2E3A),
onSecondaryContainer = DarkOnSurface,
- tertiary = StatusGreen,
- onTertiary = DarkBackground,
+ tertiary = Color(0xFF7BCF9E),
+ onTertiary = Color(0xFF0D2B1C),
background = DarkBackground,
onBackground = DarkOnSurface,
surface = DarkSurface,
onSurface = DarkOnSurface,
- surfaceVariant = Color(0xFF352329),
- onSurfaceVariant = Color(0xFFC9B8BE),
- outline = Color(0xFF4D3A42),
- outlineVariant = Color(0xFF3A2A30),
- error = Color(0xFFFF6B8E),
- onError = PureWhite
+ surfaceVariant = Color(0xFF32262C),
+ onSurfaceVariant = Color(0xFFCBB8C0),
+ outline = Color(0xFF4C3B44),
+ outlineVariant = Color(0xFF3B2C33),
+ error = Color(0xFFFF8194),
+ onError = Color(0xFF4A1025)
)
@Composable
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 2d9d026..6a52d99 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
@@ -122,7 +122,7 @@ fun CodexOAuthScreen(
Text(
uiState.error!!.asString(),
style = MaterialTheme.typography.bodyLarge,
- color = androidx.compose.ui.graphics.Color(0xFFE91E63)
+ color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
Text(stringResource(R.string.action_retry), color = StrawberryPink)
@@ -286,7 +286,7 @@ private fun HeadlessOAuthContent(
Text(
text = error.asString(),
style = MaterialTheme.typography.bodySmall,
- color = androidx.compose.ui.graphics.Color(0xFFE91E63),
+ color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp)
)
TextButton(onClick = onRetry) {
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 56e54d1..f3d94cd 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
@@ -339,9 +339,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
/** 根据用量百分比动态改进度条颜色 */
private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) {
val color = when {
- pct >= 80 -> 0xFFE91E63.toInt() // 玫红
+ pct >= 80 -> 0xFFD64A6E.toInt() // 玫红
pct >= 50 -> 0xFFFFA726.toInt() // 暖橙
- else -> 0xFFFF85A2.toInt() // 草莓粉
+ else -> 0xFFC9436C.toInt() // 深玫瑰(主色)
}
views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color))
}
diff --git a/app/src/main/res/drawable-night/rainytoken_wordmark.png b/app/src/main/res/drawable-night/rainytoken_wordmark.png
index b4d65ba..974ef9f 100644
Binary files a/app/src/main/res/drawable-night/rainytoken_wordmark.png and b/app/src/main/res/drawable-night/rainytoken_wordmark.png differ
diff --git a/app/src/main/res/drawable-night/widget_bg.xml b/app/src/main/res/drawable-night/widget_bg.xml
index f99fb09..73f621a 100644
--- a/app/src/main/res/drawable-night/widget_bg.xml
+++ b/app/src/main/res/drawable-night/widget_bg.xml
@@ -3,8 +3,8 @@
-
+
+ android:color="#4C3B44" />
\ No newline at end of file
diff --git a/app/src/main/res/drawable-nodpi/rainytoken_wordmark.png b/app/src/main/res/drawable-nodpi/rainytoken_wordmark.png
index d8435c8..0990a3b 100644
Binary files a/app/src/main/res/drawable-nodpi/rainytoken_wordmark.png and b/app/src/main/res/drawable-nodpi/rainytoken_wordmark.png differ
diff --git a/app/src/main/res/drawable-nodpi/widget_preview.png b/app/src/main/res/drawable-nodpi/widget_preview.png
index 187a472..79c3064 100644
Binary files a/app/src/main/res/drawable-nodpi/widget_preview.png and b/app/src/main/res/drawable-nodpi/widget_preview.png differ
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 2722a46..b78f5ee 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,5 +1,5 @@
-
+
-
-
-
+
+
+
-
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 6a60b13..1347412 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -7,14 +7,14 @@
android:viewportHeight="108">
-
+
+ android:fillColor="#C9436C" />
-
+
+ android:fillColor="#FFC9D8" />
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_rainy_wordmark.xml b/app/src/main/res/drawable/ic_rainy_wordmark.xml
index 2a110f1..1c4eaf3 100644
--- a/app/src/main/res/drawable/ic_rainy_wordmark.xml
+++ b/app/src/main/res/drawable/ic_rainy_wordmark.xml
@@ -1,9 +1,9 @@
-
+
-
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/widget_bg.xml b/app/src/main/res/drawable/widget_bg.xml
index 8e73ed4..67925d2 100644
--- a/app/src/main/res/drawable/widget_bg.xml
+++ b/app/src/main/res/drawable/widget_bg.xml
@@ -2,8 +2,8 @@
-
+
+ android:color="#F2DCE4" />
\ No newline at end of file
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 06e632d..b551c7e 100644
--- a/app/src/main/res/layout-night/widget_opencode_go.xml
+++ b/app/src/main/res/layout-night/widget_opencode_go.xml
@@ -1,5 +1,5 @@
-
+
@@ -44,7 +44,7 @@
android:layout_width="48dp"
android:layout_height="24dp"
android:text="OCGO"
- android:textColor="#FF85A2"
+ android:textColor="#FFA9BF"
android:textSize="10sp"
android:textStyle="bold"
android:layout_marginStart="6dp"
@@ -75,7 +75,7 @@
android:layout_width="40dp"
android:layout_height="24dp"
android:text="↻"
- android:textColor="#FF85A2"
+ android:textColor="#FFA9BF"
android:textSize="20sp"
android:layout_marginStart="6dp"
android:gravity="center" />
@@ -99,7 +99,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
- android:textColor="#C4A0AB"
+ android:textColor="#CBB8C0"
android:textSize="11sp" />
@@ -113,10 +113,10 @@
+ android:text="5h" android:textColor="#CBB8C0" android:textSize="11sp" />
+ android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
@@ -139,10 +139,10 @@
+ android:text="@string/window_weekly" android:textColor="#CBB8C0" android:textSize="11sp" />
+ android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
@@ -165,10 +165,10 @@
+ android:text="@string/window_monthly" android:textColor="#CBB8C0" android:textSize="11sp" />
+ android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
@@ -200,14 +200,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
- android:textColor="#C4A0AB"
+ android:textColor="#CBB8C0"
android:textSize="11sp" />
@@ -233,7 +233,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_brand"
- android:textColor="#8A7A82"
+ android:textColor="#CBB8C0"
android:textSize="9sp" />
diff --git a/app/src/main/res/layout/widget_opencode_go.xml b/app/src/main/res/layout/widget_opencode_go.xml
index 6f121ef..73f9f0e 100644
--- a/app/src/main/res/layout/widget_opencode_go.xml
+++ b/app/src/main/res/layout/widget_opencode_go.xml
@@ -36,7 +36,7 @@
android:layout_width="10dp"
android:layout_height="24dp"
android:text=">"
- android:textColor="#C4A0AB"
+ android:textColor="#7A6870"
android:textSize="14sp"
android:gravity="center" />
@@ -45,7 +45,7 @@
android:layout_width="48dp"
android:layout_height="24dp"
android:text="OCGO"
- android:textColor="#FF85A2"
+ android:textColor="#C9436C"
android:textSize="10sp"
android:textStyle="bold"
android:layout_marginStart="6dp"
@@ -69,7 +69,7 @@
android:maxLines="1"
android:ellipsize="end"
android:text="--"
- android:textColor="#C4A0AB"
+ android:textColor="#7A6870"
android:textSize="10sp" />
@@ -105,7 +105,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
- android:textColor="#8A7A82"
+ android:textColor="#7A6870"
android:textSize="11sp" />
@@ -120,11 +120,11 @@
+ android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
+ android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
@@ -150,11 +150,11 @@
+ android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
+ android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
@@ -180,11 +180,11 @@
+ android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
+ android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
@@ -221,7 +221,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
- android:textColor="#8A7A82"
+ android:textColor="#7A6870"
android:textSize="11sp" />
@@ -258,7 +258,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_brand"
- android:textColor="#D4C4CC"
+ android:textColor="#7A6870"
android:textSize="9sp" />
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 a33984a..95d0c15 100644
--- a/app/src/main/res/layout/widget_opencode_go_preview.xml
+++ b/app/src/main/res/layout/widget_opencode_go_preview.xml
@@ -42,7 +42,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_auto_refresh"
- android:textColor="#C4A0AB"
+ android:textColor="#7A6870"
android:textSize="10sp" />
@@ -64,7 +64,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
- android:textColor="#8A7A82"
+ android:textColor="#7A6870"
android:textSize="11sp" />
@@ -79,11 +79,11 @@
+ android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
+ android:progressTint="#C9436C" />
@@ -105,11 +105,11 @@
+ android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
+ android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
+ android:progressTint="#C9436C" />
@@ -165,14 +165,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
- android:textColor="#8A7A82"
+ android:textColor="#7A6870"
android:textSize="11sp" />
diff --git a/app/src/main/res/values/widget_colors.xml b/app/src/main/res/values/widget_colors.xml
index 0559e10..45d3fa4 100644
--- a/app/src/main/res/values/widget_colors.xml
+++ b/app/src/main/res/values/widget_colors.xml
@@ -1,7 +1,7 @@
-
- #FF85A2
+
+ #C9436C
#FFA726
- #E91E63
+ #D64A6E
diff --git a/docs/签到核心请求.md b/docs/签到核心请求.md
new file mode 100644
index 0000000..9209f4d
--- /dev/null
+++ b/docs/签到核心请求.md
@@ -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 `,`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 {
+ 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 `。签到域名是 `www.codebuddy.cn`(余额主查询才是 `www.workbuddy.cn`)。
+
+```kotlin
+// 查询今日签到状态(两条路径依次 fallback,共享 token 轮换状态)
+suspend fun fetchCheckinStatus(accountId: String? = null): Result {
+ 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 {
+ 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 单次消费后失效
+ }
+}
+```
+
+### 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「参与用户太多」)。