diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index 9ff0608..4e837cc 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -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) diff --git a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt index 427944e..c05ee53 100644 --- a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt +++ b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt @@ -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) ) } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 4fee470..cb24bb8 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -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() } + 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, + order: List, + onOpenUsageDetail: () -> Unit, + onOpenCcgoUsageDetail: () -> Unit, + onOpenService: (ServiceType) -> Unit, + refreshTrigger: Int +): List { + 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, + wideEnough: Boolean, + scrollState: ScrollState, + viewportHeightPx: Int, + viewportTopInWindow: Float, + onOrderChanged: (List) -> Unit +) { + val columns = if (wideEnough) 2 else 1 + val gestureKey = remember(items) { items.joinToString("|") { it.id } } + var draggingId by remember { mutableStateOf(null) } + val displayOrder = remember { mutableStateListOf() } + 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() } + + 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 { diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 507380c..941aeca 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -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 diff --git a/app/src/main/java/com/rainy/token/ui/widget/WidgetRefreshReceiver.kt b/app/src/main/java/com/rainy/token/ui/widget/WidgetRefreshReceiver.kt index 3e91cc7..4d40023 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/WidgetRefreshReceiver.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/WidgetRefreshReceiver.kt @@ -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 = diff --git a/app/src/main/res/layout/widget_opencode_go.xml b/app/src/main/res/layout/widget_opencode_go.xml index b4a1722..7b56ce8 100644 --- a/app/src/main/res/layout/widget_opencode_go.xml +++ b/app/src/main/res/layout/widget_opencode_go.xml @@ -31,6 +31,18 @@ android:contentDescription="RainyToken" android:adjustViewBounds="true" /> + +