feat: 首页拖拽排序 + Widget三服务切换 + 刷新优化

- DashboardScreen.kt: 长按自由拖动排序(原生Compose,原位节点+offset让位+中心命中模型+自动滚动补偿)
- OpenCodeGoWidgetProvider.kt: 新增三服务切换按钮(OCGO/CCGO/Codex循环)、showRefreshing()、isRefreshing互斥锁
- WidgetRefreshReceiver.kt: 只刷当前服务+DeepSeek、25s超时保护
- CodexRepository.kt: weekly→每周文案统一
- ServiceIcon.kt: CCGO复用OCGO图标
- widget_opencode_go.xml: 新增切换按钮布局
This commit is contained in:
WaterRain 2026-07-01 15:29:34 +00:00
parent 9a7c0284a1
commit a5ba79ea4b
No known key found for this signature in database
6 changed files with 505 additions and 79 deletions

View File

@ -140,7 +140,7 @@ class CodexRepository(
return result
}
private fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "Weekly"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" }
private fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "每周"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" }
@Serializable data class OAuthRefreshRequest(@kotlinx.serialization.SerialName("grant_type") val grantType: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String, @kotlinx.serialization.SerialName("client_id") val clientId: String, val scope: String)
@Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null)

View File

@ -52,17 +52,17 @@ fun ServiceIcon(
}
}
ServiceType.COMMANDCODE_GO -> {
// CommandCode Go:暂无官方 logo,用 ⚡ emoji 占位
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF2D3748)),
.clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Text(
text = "⚡",
fontSize = (size * 0.5).sp
Image(
painter = painterResource(id = R.drawable.ic_opencode_go_logo),
contentDescription = "CommandCode Go",
modifier = Modifier.size((size * 0.85).dp, ((size * 0.85f * 30f / 54f)).dp)
)
}
}

View File

@ -5,18 +5,21 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -46,17 +49,30 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus
@ -98,6 +114,17 @@ fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val cardOrder = remember { mutableStateListOf<String>() }
LaunchedEffect(Unit) {
val saved = context.getSharedPreferences(DASHBOARD_ORDER_PREFS, android.content.Context.MODE_PRIVATE)
.getString(DASHBOARD_ORDER_KEY, null)
?.split(',')
?.filter { it.isNotBlank() }
.orEmpty()
cardOrder.clear()
cardOrder.addAll(saved)
}
// 全局刷新触发器——每次 dashboard 刷新完成后 +1,UsageStatsCard 据此同步用量数据
var usageSyncTrigger by remember { mutableIntStateOf(0) }
@ -130,7 +157,6 @@ fun DashboardScreen(
},
actions = {
// 添加小组件:优先 requestPinAppWidget,不支持则打开系统小组件选择器
val context = LocalContext.current
IconButton(onClick = {
val appWidgetManager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
@ -197,58 +223,41 @@ fun DashboardScreen(
// 容器自身宽度 > 600dp 时开双列(自适应父容器,而非全局窗口)
val wideEnough = maxWidth > 600.dp
val contentPadding = if (wideEnough) 16.dp else 16.dp
val scrollState = rememberScrollState()
var viewportHeightPx by remember { mutableIntStateOf(1) }
var viewportTopInWindow by remember { mutableFloatStateOf(0f) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.onSizeChanged { viewportHeightPx = it.height.coerceAtLeast(1) }
.onGloballyPositioned { viewportTopInWindow = it.positionInWindow().y }
.verticalScroll(scrollState)
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 用量统计卡片(主页面,独立 ViewModel)
if (wideEnough) {
// 面板够宽:两张用量卡片并排
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.weight(1f)) {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
}
Box(modifier = Modifier.weight(1f)) {
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger)
}
}
} else {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger)
}
// 分隔
Text(
text = "服务余额",
style = MaterialTheme.typography.labelMedium,
color = inkMuted(),
modifier = Modifier.padding(top = 8.dp)
val items = rememberDashboardItems(
cards = uiState.cards,
order = cardOrder,
onOpenUsageDetail = onOpenUsageDetail,
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
onOpenService = onOpenService,
refreshTrigger = usageSyncTrigger
)
if (wideEnough) {
// 面板够宽:服务卡片 FlowRow 双列
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
uiState.cards.forEach { card ->
Box(modifier = Modifier.fillMaxWidth(0.5f)) {
DashboardCard(card = card, onClick = { onOpenService(card.service) },
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null)
}
}
DraggableDashboardCards(
items = items,
wideEnough = wideEnough,
scrollState = scrollState,
viewportHeightPx = viewportHeightPx,
viewportTopInWindow = viewportTopInWindow,
onOrderChanged = { newOrder ->
cardOrder.clear()
cardOrder.addAll(newOrder)
context.getSharedPreferences(DASHBOARD_ORDER_PREFS, android.content.Context.MODE_PRIVATE)
.edit()
.putString(DASHBOARD_ORDER_KEY, newOrder.joinToString(","))
.apply()
}
} else {
uiState.cards.forEach { card ->
DashboardCard(card = card, onClick = { onOpenService(card.service) },
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null)
}
}
)
// 底部 footer:填空白 + 提供版本号
DashboardFooter()
}
@ -258,6 +267,282 @@ fun DashboardScreen(
}
}
private const val DASHBOARD_ORDER_PREFS = "dashboard_card_order"
private const val DASHBOARD_ORDER_KEY = "order"
private const val USAGE_OCGO_CARD_ID = "usage:opencode_go"
private const val USAGE_CCGO_CARD_ID = "usage:commandcode_go"
private const val DASHBOARD_CARD_SPACING_DP = 12
private data class DashboardHomeItem(
val id: String,
val content: @Composable () -> Unit
)
@Composable
private fun rememberDashboardItems(
cards: List<DashboardCardUi>,
order: List<String>,
onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit,
onOpenService: (ServiceType) -> Unit,
refreshTrigger: Int
): List<DashboardHomeItem> {
val defaultItems = buildList {
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = refreshTrigger)
})
add(DashboardHomeItem(USAGE_CCGO_CARD_ID) {
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger)
})
cards.forEach { card ->
add(DashboardHomeItem("service:${card.service.storageKey}") {
DashboardCard(
card = card,
onClick = { onOpenService(card.service) },
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null
)
})
}
}
val itemById = defaultItems.associateBy { it.id }
val ordered = order.mapNotNull { itemById[it] }
return ordered + defaultItems.filterNot { item -> ordered.any { it.id == item.id } }
}
@Composable
private fun DraggableDashboardCards(
items: List<DashboardHomeItem>,
wideEnough: Boolean,
scrollState: ScrollState,
viewportHeightPx: Int,
viewportTopInWindow: Float,
onOrderChanged: (List<String>) -> Unit
) {
val columns = if (wideEnough) 2 else 1
val gestureKey = remember(items) { items.joinToString("|") { it.id } }
var draggingId by remember { mutableStateOf<String?>(null) }
val displayOrder = remember { mutableStateListOf<String>() }
LaunchedEffect(gestureKey) {
if (draggingId == null) {
displayOrder.clear()
displayOrder.addAll(items.map { it.id })
}
}
val density = androidx.compose.ui.platform.LocalDensity.current
val spacingPx = with(density) { DASHBOARD_CARD_SPACING_DP.dp.toPx() }
val edgeScrollZonePx = with(density) { 96.dp.toPx() }
val maxAutoScrollPx = with(density) { 26.dp.toPx() }
var visualDragOffsetX by remember { mutableFloatStateOf(0f) }
var visualDragOffsetY by remember { mutableFloatStateOf(0f) }
var dragStartCenterXInWindow by remember { mutableFloatStateOf(-1f) }
var dragStartCenterYInWindow by remember { mutableFloatStateOf(-1f) }
var dragCenterYInViewport by remember { mutableFloatStateOf(-1f) }
var cardTopInViewport by remember { mutableFloatStateOf(0f) }
var cardWidthPx by remember { mutableIntStateOf(1) }
var cardHeightPx by remember { mutableIntStateOf(1) }
var dragFromIndex by remember { mutableIntStateOf(-1) }
var dragTargetIndex by remember { mutableIntStateOf(-1) }
val itemCenterById = remember { mutableStateMapOf<String, androidx.compose.ui.geometry.Offset>() }
fun autoScrollDelta(): Float {
if (dragCenterYInViewport < 0f || scrollState.maxValue <= 0) return 0f
val topZoneEnd = viewportTopInWindow + edgeScrollZonePx
val bottomZoneStart = viewportTopInWindow + viewportHeightPx - edgeScrollZonePx
return when {
dragCenterYInViewport < topZoneEnd -> {
-maxAutoScrollPx * ((topZoneEnd - dragCenterYInViewport) / edgeScrollZonePx).coerceIn(0.2f, 1f)
}
dragCenterYInViewport > bottomZoneStart -> {
maxAutoScrollPx * ((dragCenterYInViewport - bottomZoneStart) / edgeScrollZonePx).coerceIn(0.2f, 1f)
}
else -> 0f
}
}
fun updateDragTargetIndex() {
val id = draggingId ?: return
if (dragFromIndex < 0 || dragStartCenterXInWindow < 0f || dragStartCenterYInWindow < 0f) return
val dragCenter = androidx.compose.ui.geometry.Offset(
x = dragStartCenterXInWindow + visualDragOffsetX,
y = dragStartCenterYInWindow + visualDragOffsetY
)
val targetId = displayOrder
.asSequence()
.filter { it != id }
.minByOrNull { otherId ->
val center = itemCenterById[otherId] ?: return@minByOrNull Float.MAX_VALUE
val dx = center.x - dragCenter.x
val dy = center.y - dragCenter.y
dx * dx + dy * dy
}
?: return
val targetCenter = itemCenterById[targetId] ?: return
val activationDistance = minOf(cardWidthPx, cardHeightPx) * 0.45f
val dx = targetCenter.x - dragCenter.x
val dy = targetCenter.y - dragCenter.y
dragTargetIndex = if (dx * dx + dy * dy <= activationDistance * activationDistance) {
displayOrder.indexOf(targetId).takeIf { it >= 0 } ?: dragFromIndex
} else {
dragFromIndex
}
}
fun settleDraggedItem() {
val id = draggingId ?: return
if (dragFromIndex < 0 || dragTargetIndex < 0 || dragFromIndex == dragTargetIndex) return
val currentIndex = displayOrder.indexOf(id)
if (currentIndex < 0) return
displayOrder.add(dragTargetIndex.coerceIn(0, displayOrder.lastIndex), displayOrder.removeAt(currentIndex))
}
fun displacementFor(index: Int): IntOffset {
if (draggingId == null || dragFromIndex < 0 || dragTargetIndex < 0 || dragFromIndex == dragTargetIndex) {
return IntOffset.Zero
}
val targetIndex = dragTargetIndex.coerceIn(0, displayOrder.lastIndex)
val displacedIndex = when {
dragFromIndex < targetIndex && index in (dragFromIndex + 1)..targetIndex -> index - 1
dragFromIndex > targetIndex && index in targetIndex until dragFromIndex -> index + 1
else -> index
}
if (displacedIndex == index) return IntOffset.Zero
val cellWidth = cardWidthPx + spacingPx
val cellHeight = cardHeightPx + spacingPx
val fromColumn = index % columns
val fromRow = index / columns
val toColumn = displacedIndex % columns
val toRow = displacedIndex / columns
return IntOffset(
x = ((toColumn - fromColumn) * cellWidth).roundToInt(),
y = ((toRow - fromRow) * cellHeight).roundToInt()
)
}
LaunchedEffect(draggingId, dragCenterYInViewport, viewportHeightPx, viewportTopInWindow) {
while (draggingId != null) {
val delta = autoScrollDelta()
if (delta != 0f) {
val before = scrollState.value
scrollState.scrollBy(delta)
val consumed = scrollState.value - before
visualDragOffsetY += consumed
dragStartCenterYInWindow -= consumed
itemCenterById.keys.toList().forEach { id ->
itemCenterById[id] = itemCenterById[id]?.let { center ->
center.copy(y = center.y - consumed)
} ?: return@forEach
}
dragCenterYInViewport = dragStartCenterYInWindow + visualDragOffsetY
updateDragTargetIndex()
}
delay(16L)
}
}
val itemById = items.associateBy { it.id }
val displayedItems = displayOrder.mapNotNull { itemById[it] }.ifEmpty { items }
displayedItems.chunked(columns).forEachIndexed { rowIndex, rowItems ->
val rowContainsDraggingItem = draggingId != null && rowItems.any { it.id == draggingId }
Row(
modifier = Modifier
.fillMaxWidth()
.zIndex(if (rowContainsDraggingItem) 10f else 0f),
horizontalArrangement = Arrangement.spacedBy(DASHBOARD_CARD_SPACING_DP.dp)
) {
rowItems.forEachIndexed { columnIndex, item ->
key(item.id) {
val itemIndex = rowIndex * columns + columnIndex
val isDragging = draggingId == item.id
val itemDisplacement = if (isDragging) IntOffset.Zero else displacementFor(itemIndex)
Box(
modifier = Modifier
.weight(1f)
.onSizeChanged {
cardWidthPx = it.width.coerceAtLeast(1)
cardHeightPx = it.height.coerceAtLeast(1)
}
.onGloballyPositioned { coordinates ->
val position = coordinates.positionInWindow()
val size = coordinates.size
if (draggingId == null) {
itemCenterById[item.id] = androidx.compose.ui.geometry.Offset(
x = position.x + size.width / 2f,
y = position.y + size.height / 2f
)
}
if (isDragging) {
cardTopInViewport = position.y
}
}
.offset {
if (isDragging) {
IntOffset(visualDragOffsetX.roundToInt(), visualDragOffsetY.roundToInt())
} else {
itemDisplacement
}
}
.zIndex(if (isDragging) 20f else 0f)
.alpha(if (isDragging) 0.92f else 1f)
.pointerInput(gestureKey) {
detectDragGesturesAfterLongPress(
onDragStart = {
draggingId = item.id
visualDragOffsetX = 0f
visualDragOffsetY = 0f
dragFromIndex = displayOrder.indexOf(item.id)
dragTargetIndex = dragFromIndex
val center = itemCenterById[item.id]
dragStartCenterXInWindow = center?.x ?: -1f
dragStartCenterYInWindow = center?.y ?: -1f
dragCenterYInViewport = (center?.y ?: cardTopInViewport + cardHeightPx / 2f)
},
onDragEnd = {
settleDraggedItem()
onOrderChanged(displayOrder.toList())
draggingId = null
visualDragOffsetX = 0f
visualDragOffsetY = 0f
dragStartCenterXInWindow = -1f
dragStartCenterYInWindow = -1f
dragFromIndex = -1
dragTargetIndex = -1
dragCenterYInViewport = -1f
},
onDragCancel = {
displayOrder.clear()
displayOrder.addAll(items.map { it.id })
draggingId = null
visualDragOffsetX = 0f
visualDragOffsetY = 0f
dragStartCenterXInWindow = -1f
dragStartCenterYInWindow = -1f
dragFromIndex = -1
dragTargetIndex = -1
dragCenterYInViewport = -1f
},
onDrag = { change, dragAmount ->
change.consume()
visualDragOffsetX += dragAmount.x
visualDragOffsetY += dragAmount.y
dragCenterYInViewport = dragStartCenterYInWindow + visualDragOffsetY
updateDragTargetIndex()
}
)
}
) {
item.content()
}
}
}
repeat(columns - rowItems.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
@Composable
private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenCcgoUsageDetail: (() -> Unit)? = null) {
Card(
@ -554,7 +839,7 @@ private fun CodexUsageWindows(balance: com.rainy.token.domain.model.ServiceBalan
.maxOrNull()?.plus(1) ?: 0
val windows = (0 until windowCount).map { i ->
val label = extras["window_$i.label"] ?: "Usage"
val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage")
val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
Triple(label, remainingPct, resetAt)
@ -577,6 +862,11 @@ private fun CodexUsageWindows(balance: com.rainy.token.domain.model.ServiceBalan
}
}
private fun normalizeWindowLabel(label: String): String = when (label.lowercase()) {
"weekly" -> "每周"
else -> label
}
@Composable
private fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
Column {

View File

@ -33,13 +33,18 @@ import java.util.Locale
class OpenCodeGoWidgetProvider : AppWidgetProvider() {
override fun onReceive(context: Context, intent: Intent) {
if ("miui.appwidget.action.APPWIDGET_UPDATE" == intent.action) {
val appWidgetIds = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS)
if (appWidgetIds != null) {
onUpdate(context, AppWidgetManager.getInstance(context), appWidgetIds)
when (intent.action) {
ACTION_SWITCH_SERVICE -> {
switchDisplayService(context)
notifyDataChanged(context)
}
} else {
super.onReceive(context, intent)
"miui.appwidget.action.APPWIDGET_UPDATE" -> {
val appWidgetIds = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS)
if (appWidgetIds != null) {
onUpdate(context, AppWidgetManager.getInstance(context), appWidgetIds)
}
}
else -> super.onReceive(context, intent)
}
}
@ -48,7 +53,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
var hasCachedData = false
var selectedHasCachedData = false
for (widgetId in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
@ -70,32 +75,33 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
)
views.setOnClickPendingIntent(R.id.widget_refresh, refreshPendingIntent)
val switchPendingIntent = PendingIntent.getBroadcast(
context, 2, Intent(context, OpenCodeGoWidgetProvider::class.java).apply { action = ACTION_SWITCH_SERVICE },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_switch, switchPendingIntent)
// 读缓存并填充数据
runBlocking {
try {
val dataStore = context.applicationContext.balanceCacheDataStore
val cache = BalanceCache(dataStore)
val cached = cache.get(ServiceType.OPENCODE_GO)
val selectedService = currentDisplayService(context)
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度")
views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService))
val cached = cache.get(selectedService)
if (cached != null) {
hasCachedData = true
val bal = cached.balance
val extras = bal.extras
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
pct = extras["rolling.pct"]?.toIntOrNull(),
resetSec = extras["rolling.resetInSec"]?.toLongOrNull())
populateRow(views, R.id.row2_pct, R.id.row2_bar, R.id.row2_reset,
pct = extras["weekly.pct"]?.toIntOrNull(),
resetSec = extras["weekly.resetInSec"]?.toLongOrNull())
populateRow(views, R.id.row3_pct, R.id.row3_bar, R.id.row3_reset,
pct = extras["monthly.pct"]?.toIntOrNull(),
resetSec = extras["monthly.resetInSec"]?.toLongOrNull())
selectedHasCachedData = true
populateServiceRows(views, selectedService, cached.balance)
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val timeText = "更新 " + sdf.format(Date(cached.fetchedAt))
views.setTextViewText(R.id.widget_updated, timeText)
} else {
setEmptyState(views)
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度")
}
// DeepSeek 余额
@ -116,12 +122,79 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
}
// 自动刷新:缓存为空 或 超过冷却时间 → 触发后台刷新
if (!hasCachedData || shouldAutoRefresh(context)) {
if (!selectedHasCachedData || shouldAutoRefresh(context)) {
markAutoRefreshTime(context)
context.sendBroadcast(WidgetRefreshReceiver.createIntent(context))
}
}
private fun populateServiceRows(
views: RemoteViews,
service: ServiceType,
balance: com.rainy.token.domain.model.ServiceBalance
) {
val extras = balance.extras
when (service) {
ServiceType.OPENCODE_GO -> {
setRowLabel(views, "5h", "本周", "本月")
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
pct = extras["rolling.pct"]?.toIntOrNull(),
resetSec = extras["rolling.resetInSec"]?.toLongOrNull())
populateRow(views, R.id.row2_pct, R.id.row2_bar, R.id.row2_reset,
pct = extras["weekly.pct"]?.toIntOrNull(),
resetSec = extras["weekly.resetInSec"]?.toLongOrNull())
populateRow(views, R.id.row3_pct, R.id.row3_bar, R.id.row3_reset,
pct = extras["monthly.pct"]?.toIntOrNull(),
resetSec = extras["monthly.resetInSec"]?.toLongOrNull())
}
ServiceType.COMMANDCODE_GO -> {
fun calcPct(used: Double?, cap: Double?): Int? {
if (used == null || cap == null || cap <= 0) return null
return ((used / cap) * 100).toInt().coerceIn(0, 100)
}
setRowLabel(views, "5h", "本周", "本月")
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
pct = calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()),
resetSec = extras["fiveHour.resetInSec"]?.toLongOrNull())
populateRow(views, R.id.row2_pct, R.id.row2_bar, R.id.row2_reset,
pct = calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()),
resetSec = extras["weekly.resetInSec"]?.toLongOrNull())
populateRow(views, R.id.row3_pct, R.id.row3_bar, R.id.row3_reset,
pct = calcPct(balance.monthlySpent, balance.totalQuota),
resetSec = extras["monthly.resetInSec"]?.toLongOrNull())
}
ServiceType.CODEX -> {
val windows = (0 until 3).map { index ->
val label = normalizeWindowLabel(extras["window_$index.label"] ?: if (index == 0) "5h" else "Usage")
val remaining = extras["window_$index.remainingPct"]?.toIntOrNull()
val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
}
setRowLabel(views, windows.getOrNull(0)?.first ?: "5h", windows.getOrNull(1)?.first ?: "周", windows.getOrNull(2)?.first ?: "月")
listOf(
Triple(R.id.row1_pct, R.id.row1_bar, R.id.row1_reset),
Triple(R.id.row2_pct, R.id.row2_bar, R.id.row2_reset),
Triple(R.id.row3_pct, R.id.row3_bar, R.id.row3_reset)
).forEachIndexed { index, ids ->
val window = windows.getOrNull(index)
populateRow(views, ids.first, ids.second, ids.third, pct = window?.second, resetSec = window?.third)
}
}
ServiceType.DEEPSEEK -> setEmptyState(views)
}
}
private fun setRowLabel(views: RemoteViews, first: String, second: String, third: String) {
views.setTextViewText(R.id.row1_label, first)
views.setTextViewText(R.id.row2_label, second)
views.setTextViewText(R.id.row3_label, third)
}
private fun normalizeWindowLabel(label: String): String = when (label.lowercase()) {
"weekly" -> "每周"
else -> label
}
private fun populateRow(
views: RemoteViews,
pctViewId: Int,
@ -175,6 +248,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
private const val AUTO_REFRESH_COOLDOWN_MS = 5 * 60 * 1000L
private const val PREFS_NAME = "widget_auto_refresh"
private const val KEY_LAST_AUTO_REFRESH = "last_auto_refresh"
private const val KEY_DISPLAY_SERVICE = "display_service"
private const val ACTION_SWITCH_SERVICE = "com.rainy.token.action.WIDGET_SWITCH_SERVICE"
private val DISPLAY_SERVICES = listOf(ServiceType.OPENCODE_GO, ServiceType.COMMANDCODE_GO, ServiceType.CODEX)
private fun autoRefreshPrefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@ -190,6 +266,30 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
.apply()
}
fun currentDisplayService(context: Context): ServiceType {
val key = autoRefreshPrefs(context).getString(KEY_DISPLAY_SERVICE, ServiceType.OPENCODE_GO.storageKey)
return DISPLAY_SERVICES.firstOrNull { it.storageKey == key } ?: ServiceType.OPENCODE_GO
}
private fun switchDisplayService(context: Context) {
val current = currentDisplayService(context)
val next = DISPLAY_SERVICES[(DISPLAY_SERVICES.indexOf(current).coerceAtLeast(0) + 1) % DISPLAY_SERVICES.size]
autoRefreshPrefs(context).edit().putString(KEY_DISPLAY_SERVICE, next.storageKey).apply()
}
private fun shortName(service: ServiceType): String = when (service) {
ServiceType.OPENCODE_GO -> "OCGO"
ServiceType.COMMANDCODE_GO -> "CCGO"
ServiceType.CODEX -> "Codex"
ServiceType.DEEPSEEK -> "DS"
}
private fun widgetLogo(service: ServiceType): Int = when (service) {
ServiceType.OPENCODE_GO, ServiceType.COMMANDCODE_GO -> R.drawable.ic_opencode_go_logo
ServiceType.CODEX -> R.drawable.ic_codex_logo
ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo
}
/**
* APP 内刷新后主动更新 Widget。
* 同时更新自动刷新时间戳,避免后续 onUpdate() 重复触发网络请求。
@ -208,6 +308,17 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
}
}
fun showRefreshing(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
val ids = appWidgetManager.getAppWidgetIds(component)
ids.forEach { id ->
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
views.setTextViewText(R.id.widget_updated, "刷新中…")
appWidgetManager.partiallyUpdateAppWidget(id, views)
}
}
private fun formatReset(sec: Long): String {
if (sec <= 0) return ""
val days = sec / 86400

View File

@ -8,6 +8,7 @@ import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* 接收 Widget 刷新按钮广播,后台静默刷新 DeepSeek + OpenCode Go。
@ -19,28 +20,39 @@ class WidgetRefreshReceiver : BroadcastReceiver() {
if (intent.action != ACTION) return
val appContext = context.applicationContext
if (isRefreshing) return
isRefreshing = true
val pendingResult = goAsync()
val entryPoint = EntryPointAccessors.fromApplication(
appContext, WidgetRefreshEntryPoint::class.java
)
val useCase = entryPoint.refreshBalanceUseCase()
OpenCodeGoWidgetProvider.showRefreshing(appContext)
CoroutineScope(Dispatchers.IO).launch {
try {
val goResult = useCase(ServiceType.OPENCODE_GO)
val dsResult = useCase(ServiceType.DEEPSEEK)
if (goResult.isSuccess || dsResult.isSuccess) {
val selectedService = OpenCodeGoWidgetProvider.currentDisplayService(appContext)
val refreshed = withTimeoutOrNull(25_000L) {
val selectedResult = useCase(selectedService)
val dsResult = useCase(ServiceType.DEEPSEEK)
selectedResult.isSuccess || dsResult.isSuccess
} == true
if (refreshed) {
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
}
} catch (_: Exception) {
// 静默,Widget 保留旧数据
} finally {
isRefreshing = false
pendingResult.finish()
}
}
}
companion object {
@Volatile
private var isRefreshing = false
const val ACTION = "com.rainy.token.action.WIDGET_REFRESH"
fun createIntent(context: Context): Intent =

View File

@ -31,6 +31,18 @@
android:contentDescription="RainyToken"
android:adjustViewBounds="true" />
<TextView
android:id="@+id/widget_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OCGO"
android:textColor="#FF85A2"
android:textSize="10sp"
android:textStyle="bold"
android:layout_marginStart="6dp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<ProgressBar
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="0dp"
@ -78,6 +90,7 @@
android:layout_marginEnd="5dp" />
<TextView
android:id="@+id/widget_service_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OpenCode Go · 用量"