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:
parent
9a7c0284a1
commit
a5ba79ea4b
@ -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)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 · 用量"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user