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