feat(checkin): 签到记录含日期/状态/id/数量,失败可当天重试;同步本地主题与组件改动
This commit is contained in:
parent
ffc06a38ca
commit
ceb5e6a268
@ -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 |
|
||||
|
||||
@ -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 动态获取 |
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".RainyTokenApplication"
|
||||
@ -33,6 +34,15 @@
|
||||
android:name=".ui.widget.WidgetRefreshReceiver"
|
||||
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) -->
|
||||
<receiver
|
||||
android:name=".ui.widget.OpenCodeGoWidgetProvider"
|
||||
|
||||
@ -15,7 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
*
|
||||
* 布局策略:
|
||||
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
|
||||
* - 再套 RainyBackground(樱粉渐变背景,全局共享)
|
||||
* - 再套 RainyBackground(精致粉调渐变背景,全局共享)
|
||||
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
|
||||
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
|
||||
*/
|
||||
|
||||
@ -6,20 +6,21 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 每日签到状态的统一本地记录(SharedPreferences "checkin_status")。
|
||||
*
|
||||
* 手动签到与自动签到成功/已签后都会落一条 `service → 签到日期` 记录,供三处消费:
|
||||
* - 服务详情页:进入时直接显示「今日已签到」,不再盲目展示可点按钮;
|
||||
* - 首页卡片:Trae / WorkBuddy 各账号卡角标「今日已签到 ✓」;
|
||||
* - 自动签到:当日已有记录的(默认)账号跳过,保证每日只签一次。
|
||||
* 一条记录包含:
|
||||
* - checkinDate:签到日期(本地时区 yyyy-MM-dd)
|
||||
* - status:签到结果状态(SUCCESS/ALREADY/FAILED/RETRYABLE/DEVICE_BLOCKED)
|
||||
* - checkinId:签到 id(服务端未提供时为空;新签到成功时生成本地 UUID 便于追溯)
|
||||
* - credits:签到获得数量
|
||||
* - updatedAt:本地记录更新时间戳
|
||||
*
|
||||
* 多账号服务按账号分别记录(键 `last_checkin_{storageKey}:{accountId}`);
|
||||
* 单账号服务沿用旧键 `last_checkin_{storageKey}`([accountId] 传 null)。
|
||||
*
|
||||
* 与 TraeCredential.lastCheckinDate 的关系:后者是 Trae Repository 层的网络去重
|
||||
* 短路字段(仅 Trae 有);本存储是 UI/编排层的展示与调度记录,覆盖所有可签服务。
|
||||
* 「今日已签到」的判定 = checkinDate == 今天 && status ∈ {SUCCESS, ALREADY}。
|
||||
* 因此 FAILED/RETRYABLE/DEVICE_BLOCKED 的记录不会挡住当天重试:标识位和时间
|
||||
* 对不上时,重开 App 会再次唤起签到流程。
|
||||
*/
|
||||
@Singleton
|
||||
class CheckinStatusStore @Inject constructor(
|
||||
@ -29,14 +30,80 @@ class CheckinStatusStore @Inject constructor(
|
||||
private fun prefs() =
|
||||
context.getSharedPreferences("checkin_status", Context.MODE_PRIVATE)
|
||||
|
||||
/** 该服务今日是否已有签到记录(本地时区 yyyy-MM-dd 比对)。 */
|
||||
fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean =
|
||||
lastCheckinDate(service, accountId) == LocalDate.now().toString()
|
||||
/** 该服务今日是否已「成功」签到(本地时区 yyyy-MM-dd 比对)。 */
|
||||
fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean {
|
||||
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。 */
|
||||
fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? {
|
||||
/** 最近一次签到记录(含失败记录),无记录返回 null。 */
|
||||
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 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
|
||||
// 仅旧版迁移来的 “default” 账号回退读旧版单账号键(其他账号没有旧记录,不能共用),
|
||||
// 避免升级当日重复签到请求
|
||||
@ -47,15 +114,28 @@ class CheckinStatusStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录该服务(账号)今日已签到(SUCCESS / ALREADY 均调用)。 */
|
||||
fun markChecked(service: ServiceType, accountId: String? = null) {
|
||||
prefs().edit().putString(key(service, accountId), LocalDate.now().toString()).apply()
|
||||
}
|
||||
/** 单条签到记录。 */
|
||||
data class CheckinRecord(
|
||||
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 =
|
||||
if (accountId.isNullOrBlank()) {
|
||||
"last_checkin_${service.storageKey}"
|
||||
} else {
|
||||
"last_checkin_${service.storageKey}:$accountId"
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
const val STATUS_SUCCESS = "SUCCESS"
|
||||
const val STATUS_ALREADY = "ALREADY"
|
||||
const val STATUS_FAILED = "FAILED"
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package com.rainy.token.domain.usecase
|
||||
import com.rainy.token.data.repository.CodexRepository
|
||||
import com.rainy.token.data.repository.CommandCodeGoRepository
|
||||
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.OllamaRepository
|
||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
||||
@ -107,10 +108,29 @@ class RefreshBalanceUseCase @Inject constructor(
|
||||
withCredentialSession(ServiceType.WORKBUDDY, accountId) {
|
||||
workBuddyRepositoryProvider.get().checkIn(accountId).also { result ->
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 容器层,所有页面共享同一层渐变。
|
||||
*/
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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<List<ServiceResult>?>(null)
|
||||
val report: StateFlow<List<ServiceResult>?> = _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<ServiceResult>()
|
||||
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)
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) */
|
||||
|
||||
@ -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) // 暗色下描边
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 11 KiB |
@ -3,8 +3,8 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="16dp" />
|
||||
<solid android:color="#2D1825" />
|
||||
<solid android:color="#1A1417" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#4D2D3D" />
|
||||
android:color="#4C3B44" />
|
||||
</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 |
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 雨晴Token 背景 · 樱粉渐变 -->
|
||||
<!-- 雨晴Token 背景 · 精致粉调渐变 -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
@ -12,10 +12,10 @@
|
||||
android:startX="0" android:startY="0"
|
||||
android:endX="108" android:endY="108"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFF0F5" />
|
||||
<item android:offset="0.5" android:color="#FFD1DC" />
|
||||
<item android:offset="1" android:color="#FFB3C1" />
|
||||
<item android:offset="0" android:color="#FFF9FB" />
|
||||
<item android:offset="0.5" android:color="#FDEFF4" />
|
||||
<item android:offset="1" android:color="#FBDCE8" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
</vector>
|
||||
@ -7,14 +7,14 @@
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!--
|
||||
配色统一雨晴品牌系:
|
||||
· 水滴:草莓粉 #FF85A2(主品牌色)
|
||||
· sparkle:樱粉 #FFD1DC(浅层次)
|
||||
配色统一雨晴品牌系(精致粉调):
|
||||
· 水滴:深玫瑰 #C9436C(主品牌色)
|
||||
· sparkle:浅玫瑰粉 #FFC9D8(浅层次)
|
||||
· 高光:白色
|
||||
· 背景:樱粉渐变 #FFF0F5→#FFD1DC→#FFB3C1
|
||||
· 背景:粉白渐变 #FFF9FB→#FDEFF4→#FBDCE8
|
||||
-->
|
||||
|
||||
<!-- 大水滴 · 草莓粉 -->
|
||||
<!-- 大水滴 · 深玫瑰 -->
|
||||
<path
|
||||
android:pathData="
|
||||
M54,30
|
||||
@ -24,7 +24,7 @@
|
||||
Q64,76 68,68
|
||||
Q72,62 64,50
|
||||
Q54,30 54,30Z"
|
||||
android:fillColor="#FF85A2" />
|
||||
android:fillColor="#C9436C" />
|
||||
|
||||
<!-- 水滴高光 · 白色椭圆 -->
|
||||
<path
|
||||
@ -32,24 +32,24 @@
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.75" />
|
||||
|
||||
<!-- sparkle 小太阳 · 樱粉 · 右上角 (72,32) -->
|
||||
<!-- sparkle 小太阳 · 浅玫瑰粉 · 右上角 (72,32) -->
|
||||
<path android:pathData="M72,22L72,42"
|
||||
android:strokeColor="#FFD1DC" android:strokeWidth="3"
|
||||
android:strokeColor="#FFC9D8" android:strokeWidth="3"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="@android:color/transparent" />
|
||||
<path android:pathData="M62,32L82,32"
|
||||
android:strokeColor="#FFD1DC" android:strokeWidth="3"
|
||||
android:strokeColor="#FFC9D8" android:strokeWidth="3"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="@android:color/transparent" />
|
||||
<path android:pathData="M66,26L78,38"
|
||||
android:strokeColor="#FFD1DC" android:strokeWidth="2"
|
||||
android:strokeColor="#FFC9D8" android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="@android:color/transparent" />
|
||||
<path android:pathData="M78,26L66,38"
|
||||
android:strokeColor="#FFD1DC" android:strokeWidth="2"
|
||||
android:strokeColor="#FFC9D8" android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
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"
|
||||
android:fillColor="#FFD1DC" />
|
||||
android:fillColor="#FFC9D8" />
|
||||
|
||||
</vector>
|
||||
@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
RainyToken 几何字标 · 与 OpenCode Go logo 完全同构
|
||||
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴粉色系:
|
||||
- Go 深色 #211E1E → 粉色 #C2185B
|
||||
- Go 浅色 #CFCECD → 浅粉 #FFD1DC
|
||||
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴精致粉系:
|
||||
- Go 深色 #211E1E → 深玫瑰 #C9436C
|
||||
- Go 浅色 #CFCECD → 浅玫瑰粉 #FFC9D8
|
||||
viewBox 和 path 与 ic_opencode_go_logo 完全一致
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
@ -11,20 +11,20 @@
|
||||
android:height="30dp"
|
||||
android:viewportWidth="54"
|
||||
android:viewportHeight="30">
|
||||
<!-- 左块(深粉) -->
|
||||
<!-- 左块(深玫瑰) -->
|
||||
<path
|
||||
android:fillColor="#C2185B"
|
||||
android:fillColor="#C9436C"
|
||||
android:pathData="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z"/>
|
||||
<!-- 左块 S 内弯(浅粉) -->
|
||||
<path
|
||||
android:fillColor="#FFD1DC"
|
||||
android:fillColor="#FFC9D8"
|
||||
android:pathData="M12 18H18V24H6V12H12V18Z"/>
|
||||
<!-- 右块 S 内弯(浅粉) -->
|
||||
<path
|
||||
android:fillColor="#FFD1DC"
|
||||
android:fillColor="#FFC9D8"
|
||||
android:pathData="M48 12V24H36V12H48Z"/>
|
||||
<!-- 右块(深粉) -->
|
||||
<!-- 右块(深玫瑰) -->
|
||||
<path
|
||||
android:fillColor="#C2185B"
|
||||
android:fillColor="#C9436C"
|
||||
android:pathData="M54 30H30V0H54V30ZM36 24H48V6H36V24Z"/>
|
||||
</vector>
|
||||
@ -2,8 +2,8 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="16dp" />
|
||||
<solid android:color="#FFF0F5" />
|
||||
<solid android:color="#FFF9FB" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#FFD1DC" />
|
||||
android:color="#F2DCE4" />
|
||||
</shape>
|
||||
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #2D1825 -->
|
||||
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #1A1417 -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
@ -35,7 +35,7 @@
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="24dp"
|
||||
android:text=">"
|
||||
android:textColor="#8A7A82"
|
||||
android:textColor="#CBB8C0"
|
||||
android:textSize="14sp"
|
||||
android:gravity="center" />
|
||||
|
||||
@ -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" />
|
||||
</LinearLayout>
|
||||
|
||||
@ -113,10 +113,10 @@
|
||||
<TextView android:id="@+id/row1_label"
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
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"
|
||||
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" />
|
||||
<ProgressBar android:id="@+id/row1_bar"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
@ -126,7 +126,7 @@
|
||||
android:progressTint="@color/widget_bar_5h" />
|
||||
<TextView android:id="@+id/row1_reset"
|
||||
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>
|
||||
|
||||
<!-- 本周 -->
|
||||
@ -139,10 +139,10 @@
|
||||
<TextView android:id="@+id/row2_label"
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
android:maxLines="1" android:ellipsize="end"
|
||||
android:text="@string/window_weekly" android:textColor="#C4A0AB" android:textSize="11sp" />
|
||||
android:text="@string/window_weekly" android:textColor="#CBB8C0" android:textSize="11sp" />
|
||||
<TextView android:id="@+id/row2_pct"
|
||||
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" />
|
||||
<ProgressBar android:id="@+id/row2_bar"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
@ -152,7 +152,7 @@
|
||||
android:progressTint="@color/widget_bar_weekly" />
|
||||
<TextView android:id="@+id/row2_reset"
|
||||
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>
|
||||
|
||||
<!-- 本月 -->
|
||||
@ -165,10 +165,10 @@
|
||||
<TextView android:id="@+id/row3_label"
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
android:maxLines="1" android:ellipsize="end"
|
||||
android:text="@string/window_monthly" android:textColor="#C4A0AB" android:textSize="11sp" />
|
||||
android:text="@string/window_monthly" android:textColor="#CBB8C0" android:textSize="11sp" />
|
||||
<TextView android:id="@+id/row3_pct"
|
||||
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" />
|
||||
<ProgressBar android:id="@+id/row3_bar"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
@ -178,7 +178,7 @@
|
||||
android:progressTint="@color/widget_bar_monthly" />
|
||||
<TextView android:id="@+id/row3_reset"
|
||||
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>
|
||||
|
||||
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
|
||||
@ -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" />
|
||||
<TextView
|
||||
android:id="@+id/widget_ds_amount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textColor="#FFD1DC"
|
||||
android:textColor="#FFC9D8"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginStart="4dp" />
|
||||
@ -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" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
<TextView
|
||||
@ -77,7 +77,7 @@
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="24dp"
|
||||
android:text="↻"
|
||||
android:textColor="#FF85A2"
|
||||
android:textColor="#C9436C"
|
||||
android:textSize="20sp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:gravity="center" />
|
||||
@ -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" />
|
||||
</LinearLayout>
|
||||
|
||||
@ -120,11 +120,11 @@
|
||||
<TextView android:id="@+id/row1_label"
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
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"
|
||||
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" />
|
||||
|
||||
<ProgressBar android:id="@+id/row1_bar"
|
||||
@ -136,7 +136,7 @@
|
||||
|
||||
<TextView android:id="@+id/row1_reset"
|
||||
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>
|
||||
|
||||
<!-- 本周 -->
|
||||
@ -150,11 +150,11 @@
|
||||
<TextView android:id="@+id/row2_label"
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
android:maxLines="1" android:ellipsize="end"
|
||||
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||
android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
|
||||
|
||||
<TextView android:id="@+id/row2_pct"
|
||||
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" />
|
||||
|
||||
<ProgressBar android:id="@+id/row2_bar"
|
||||
@ -166,7 +166,7 @@
|
||||
|
||||
<TextView android:id="@+id/row2_reset"
|
||||
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>
|
||||
|
||||
<!-- 本月 -->
|
||||
@ -180,11 +180,11 @@
|
||||
<TextView android:id="@+id/row3_label"
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
android:maxLines="1" android:ellipsize="end"
|
||||
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||
android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
|
||||
|
||||
<TextView android:id="@+id/row3_pct"
|
||||
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" />
|
||||
|
||||
<ProgressBar android:id="@+id/row3_bar"
|
||||
@ -196,7 +196,7 @@
|
||||
|
||||
<TextView android:id="@+id/row3_reset"
|
||||
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>
|
||||
|
||||
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
|
||||
@ -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" />
|
||||
|
||||
<TextView
|
||||
@ -229,7 +229,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textColor="#3D2C35"
|
||||
android:textColor="#3A2B31"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginStart="4dp" />
|
||||
@ -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" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@ -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" />
|
||||
</LinearLayout>
|
||||
|
||||
@ -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" />
|
||||
</LinearLayout>
|
||||
|
||||
@ -79,11 +79,11 @@
|
||||
<TextView
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
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: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" />
|
||||
|
||||
<ProgressBar
|
||||
@ -91,7 +91,7 @@
|
||||
android:layout_width="0dp" android:layout_height="6dp"
|
||||
android:layout_weight="1" android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp" android:max="100" android:progress="42"
|
||||
android:progressTint="#FF85A2" />
|
||||
android:progressTint="#C9436C" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 本周 -->
|
||||
@ -105,11 +105,11 @@
|
||||
<TextView
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
android:maxLines="1" android:ellipsize="end"
|
||||
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||
android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
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" />
|
||||
|
||||
<ProgressBar
|
||||
@ -131,11 +131,11 @@
|
||||
<TextView
|
||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||
android:maxLines="1" android:ellipsize="end"
|
||||
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||
android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
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" />
|
||||
|
||||
<ProgressBar
|
||||
@ -143,7 +143,7 @@
|
||||
android:layout_width="0dp" android:layout_height="6dp"
|
||||
android:layout_weight="1" android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp" android:max="100" android:progress="35"
|
||||
android:progressTint="#FF85A2" />
|
||||
android:progressTint="#C9436C" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- DeepSeek 余额 -->
|
||||
@ -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" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="¥18.00"
|
||||
android:textColor="#3D2C35"
|
||||
android:textColor="#3A2B31"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginStart="4dp" />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 进度条颜色:<50% 粉色 / 50-80% 橙色 / >80% 玫红 -->
|
||||
<color name="widget_bar_5h">#FF85A2</color>
|
||||
<!-- 进度条颜色:<50% 玫瑰 / 50-80% 橙色 / >80% 玫红(与 App 内状态色一致) -->
|
||||
<color name="widget_bar_5h">#C9436C</color>
|
||||
<color name="widget_bar_weekly">#FFA726</color>
|
||||
<color name="widget_bar_monthly">#E91E63</color>
|
||||
<color name="widget_bar_monthly">#D64A6E</color>
|
||||
</resources>
|
||||
|
||||
136
docs/签到核心请求.md
Normal file
136
docs/签到核心请求.md
Normal 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「参与用户太多」)。
|
||||
Loading…
Reference in New Issue
Block a user