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 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 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) @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 -> { ServiceType.COMMANDCODE_GO -> {
// CommandCode Go:暂无官方 logo,用 ⚡ emoji 占位
Box( Box(
modifier = modifier modifier = modifier
.size(size.dp) .size(size.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(8.dp))
.background(Color(0xFF2D3748)), .background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
androidx.compose.material3.Text( Image(
text = "⚡", painter = painterResource(id = R.drawable.ic_opencode_go_logo),
fontSize = (size * 0.5).sp 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.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.CredentialStatus
@ -98,6 +114,17 @@ fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel() viewModel: DashboardViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() 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 据此同步用量数据 // 全局刷新触发器——每次 dashboard 刷新完成后 +1,UsageStatsCard 据此同步用量数据
var usageSyncTrigger by remember { mutableIntStateOf(0) } var usageSyncTrigger by remember { mutableIntStateOf(0) }
@ -130,7 +157,6 @@ fun DashboardScreen(
}, },
actions = { actions = {
// 添加小组件:优先 requestPinAppWidget,不支持则打开系统小组件选择器 // 添加小组件:优先 requestPinAppWidget,不支持则打开系统小组件选择器
val context = LocalContext.current
IconButton(onClick = { IconButton(onClick = {
val appWidgetManager = AppWidgetManager.getInstance(context) val appWidgetManager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java) val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
@ -197,58 +223,41 @@ fun DashboardScreen(
// 容器自身宽度 > 600dp 时开双列(自适应父容器,而非全局窗口) // 容器自身宽度 > 600dp 时开双列(自适应父容器,而非全局窗口)
val wideEnough = maxWidth > 600.dp val wideEnough = maxWidth > 600.dp
val contentPadding = if (wideEnough) 16.dp else 16.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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .onSizeChanged { viewportHeightPx = it.height.coerceAtLeast(1) }
.onGloballyPositioned { viewportTopInWindow = it.positionInWindow().y }
.verticalScroll(scrollState)
.padding(contentPadding), .padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// 用量统计卡片(主页面,独立 ViewModel) val items = rememberDashboardItems(
if (wideEnough) { cards = uiState.cards,
// 面板够宽:两张用量卡片并排 order = cardOrder,
Row( onOpenUsageDetail = onOpenUsageDetail,
modifier = Modifier.fillMaxWidth(), onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
horizontalArrangement = Arrangement.spacedBy(12.dp) onOpenService = onOpenService,
) { refreshTrigger = usageSyncTrigger
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)
) )
if (wideEnough) { DraggableDashboardCards(
// 面板够宽:服务卡片 FlowRow 双列 items = items,
FlowRow( wideEnough = wideEnough,
modifier = Modifier.fillMaxWidth(), scrollState = scrollState,
horizontalArrangement = Arrangement.spacedBy(12.dp), viewportHeightPx = viewportHeightPx,
verticalArrangement = Arrangement.spacedBy(12.dp) viewportTopInWindow = viewportTopInWindow,
) { onOrderChanged = { newOrder ->
uiState.cards.forEach { card -> cardOrder.clear()
Box(modifier = Modifier.fillMaxWidth(0.5f)) { cardOrder.addAll(newOrder)
DashboardCard(card = card, onClick = { onOpenService(card.service) }, context.getSharedPreferences(DASHBOARD_ORDER_PREFS, android.content.Context.MODE_PRIVATE)
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null) .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:填空白 + 提供版本号 // 底部 footer:填空白 + 提供版本号
DashboardFooter() 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 @Composable
private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenCcgoUsageDetail: (() -> Unit)? = null) { private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenCcgoUsageDetail: (() -> Unit)? = null) {
Card( Card(
@ -554,7 +839,7 @@ private fun CodexUsageWindows(balance: com.rainy.token.domain.model.ServiceBalan
.maxOrNull()?.plus(1) ?: 0 .maxOrNull()?.plus(1) ?: 0
val windows = (0 until windowCount).map { i -> 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 remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
Triple(label, remainingPct, resetAt) 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 @Composable
private fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { private fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
Column { Column {

View File

@ -33,13 +33,18 @@ import java.util.Locale
class OpenCodeGoWidgetProvider : AppWidgetProvider() { class OpenCodeGoWidgetProvider : AppWidgetProvider() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if ("miui.appwidget.action.APPWIDGET_UPDATE" == intent.action) { when (intent.action) {
val appWidgetIds = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS) ACTION_SWITCH_SERVICE -> {
if (appWidgetIds != null) { switchDisplayService(context)
onUpdate(context, AppWidgetManager.getInstance(context), appWidgetIds) notifyDataChanged(context)
} }
} else { "miui.appwidget.action.APPWIDGET_UPDATE" -> {
super.onReceive(context, intent) 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, appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray appWidgetIds: IntArray
) { ) {
var hasCachedData = false var selectedHasCachedData = false
for (widgetId in appWidgetIds) { for (widgetId in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
@ -70,32 +75,33 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
) )
views.setOnClickPendingIntent(R.id.widget_refresh, refreshPendingIntent) 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 { runBlocking {
try { try {
val dataStore = context.applicationContext.balanceCacheDataStore val dataStore = context.applicationContext.balanceCacheDataStore
val cache = BalanceCache(dataStore) 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) { if (cached != null) {
hasCachedData = true selectedHasCachedData = true
val bal = cached.balance populateServiceRows(views, selectedService, 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())
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val timeText = "更新 " + sdf.format(Date(cached.fetchedAt)) val timeText = "更新 " + sdf.format(Date(cached.fetchedAt))
views.setTextViewText(R.id.widget_updated, timeText) views.setTextViewText(R.id.widget_updated, timeText)
} else { } else {
setEmptyState(views) setEmptyState(views)
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度")
} }
// DeepSeek 余额 // DeepSeek 余额
@ -116,12 +122,79 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
} }
// 自动刷新:缓存为空 或 超过冷却时间 → 触发后台刷新 // 自动刷新:缓存为空 或 超过冷却时间 → 触发后台刷新
if (!hasCachedData || shouldAutoRefresh(context)) { if (!selectedHasCachedData || shouldAutoRefresh(context)) {
markAutoRefreshTime(context) markAutoRefreshTime(context)
context.sendBroadcast(WidgetRefreshReceiver.createIntent(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( private fun populateRow(
views: RemoteViews, views: RemoteViews,
pctViewId: Int, pctViewId: Int,
@ -175,6 +248,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
private const val AUTO_REFRESH_COOLDOWN_MS = 5 * 60 * 1000L private const val AUTO_REFRESH_COOLDOWN_MS = 5 * 60 * 1000L
private const val PREFS_NAME = "widget_auto_refresh" private const val PREFS_NAME = "widget_auto_refresh"
private const val KEY_LAST_AUTO_REFRESH = "last_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) = private fun autoRefreshPrefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@ -190,6 +266,30 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
.apply() .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。 * APP 内刷新后主动更新 Widget。
* 同时更新自动刷新时间戳,避免后续 onUpdate() 重复触发网络请求。 * 同时更新自动刷新时间戳,避免后续 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 { private fun formatReset(sec: Long): String {
if (sec <= 0) return "" if (sec <= 0) return ""
val days = sec / 86400 val days = sec / 86400

View File

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

View File

@ -31,6 +31,18 @@
android:contentDescription="RainyToken" android:contentDescription="RainyToken"
android:adjustViewBounds="true" /> 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 <ProgressBar
style="@android:style/Widget.ProgressBar.Horizontal" style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="0dp" android:layout_width="0dp"
@ -78,6 +90,7 @@
android:layout_marginEnd="5dp" /> android:layout_marginEnd="5dp" />
<TextView <TextView
android:id="@+id/widget_service_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="OpenCode Go · 用量" android:text="OpenCode Go · 用量"