- 新增 AppTips.kt:13条操作技巧,Dashboard随机展示一条hint,设置页TipsScreen查看全部 - 小组件左上角加 › 箭头提示可点击进APP,其他区域点切换服务 - 图表默认选中最后一根柱子,tooltip首次可见 - 添加小组件二次确认弹窗 + Manifest权限 + MIUI AppOps检测 - Dashboard首次进入显示长按拖拽排序提示横幅 - NavHost新增Tips路由 - 同步AGENTS.md和README.md
459 lines
19 KiB
Kotlin
459 lines
19 KiB
Kotlin
package com.rainy.token.ui
|
||
|
||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||
import androidx.compose.animation.ExitTransition
|
||
import androidx.compose.animation.core.tween
|
||
import androidx.compose.animation.fadeIn
|
||
import androidx.compose.animation.fadeOut
|
||
import androidx.compose.foundation.background
|
||
import androidx.compose.foundation.layout.Box
|
||
import androidx.compose.foundation.layout.Column
|
||
import androidx.compose.foundation.layout.Row
|
||
import androidx.compose.foundation.layout.fillMaxHeight
|
||
import androidx.compose.foundation.layout.fillMaxSize
|
||
import androidx.compose.foundation.layout.fillMaxWidth
|
||
import androidx.compose.material3.MaterialTheme
|
||
import androidx.compose.material3.Text
|
||
import androidx.compose.material3.VerticalDivider
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.LaunchedEffect
|
||
import androidx.compose.runtime.getValue
|
||
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.graphics.Color
|
||
import androidx.compose.ui.text.style.TextAlign
|
||
import androidx.compose.ui.unit.dp
|
||
import androidx.hilt.navigation.compose.hiltViewModel
|
||
import androidx.navigation.NavType
|
||
import androidx.navigation.compose.NavHost
|
||
import androidx.navigation.compose.composable
|
||
import androidx.navigation.compose.rememberNavController
|
||
import androidx.navigation.navArgument
|
||
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
||
import com.rainy.token.domain.service.ServiceType
|
||
import com.rainy.token.ui.components.rememberWindowSizeClass
|
||
import com.rainy.token.ui.components.TipsScreen
|
||
import com.rainy.token.ui.dashboard.DashboardScreen
|
||
import com.rainy.token.ui.dashboard.UsageChartViewModel
|
||
import com.rainy.token.ui.dashboard.UsageDataScreen
|
||
import com.rainy.token.ui.dashboard.UsageDataViewModel
|
||
import com.rainy.token.ui.dashboard.UsageDetailScreen
|
||
import com.rainy.token.ui.dashboard.UsageOverviewScreen
|
||
import com.rainy.token.ui.dashboard.UsageViewModel
|
||
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
||
import com.rainy.token.ui.settings.CredentialEditScreen
|
||
import com.rainy.token.ui.settings.SettingsScreen
|
||
import com.rainy.token.ui.theme.inkMuted
|
||
import com.rainy.token.ui.theme.StrawberryPink
|
||
import com.rainy.token.ui.webview.WebViewLoginScreen
|
||
|
||
/**
|
||
* 应用导航图。
|
||
*
|
||
* 自适应策略:
|
||
* - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(200ms)防连点栈损坏,
|
||
* enter/exit/popEnter/popExit 显式使用同一套滑动过渡,避免默认淡入淡出与返回动画混用。
|
||
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换,
|
||
* 用量子路由用局部 NavHost。
|
||
*/
|
||
object Routes {
|
||
const val DASHBOARD = "dashboard"
|
||
const val SETTINGS = "settings"
|
||
const val TIPS = "tips"
|
||
const val CREDENTIAL_EDIT = "credential_edit/{type}"
|
||
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
|
||
const val WEBVIEW_LOGIN = "webview_login/{type}"
|
||
fun webviewLogin(type: ServiceType) = "webview_login/${type.name}"
|
||
const val SERVICE_DETAIL = "service/{type}"
|
||
fun serviceDetail(type: ServiceType) = "service/${type.name}"
|
||
const val USAGE_DETAIL = "usage_detail"
|
||
const val USAGE_OVERVIEW = "usage_overview"
|
||
const val USAGE_DATA = "usage_data"
|
||
const val CCGO_USAGE_DETAIL = "ccgo_usage_detail"
|
||
const val CCGO_USAGE_OVERVIEW = "ccgo_usage_overview"
|
||
const val CCGO_USAGE_DATA = "ccgo_usage_data"
|
||
}
|
||
|
||
private fun parseServiceType(typeName: String?): ServiceType =
|
||
typeName?.let {
|
||
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
||
} ?: ServiceType.DEEPSEEK
|
||
|
||
private sealed class DetailPane {
|
||
object Empty : DetailPane()
|
||
data class ServiceDetail(val type: ServiceType) : DetailPane()
|
||
object OCGOUsage : DetailPane()
|
||
object CCGOUsage : DetailPane()
|
||
object Settings : DetailPane()
|
||
}
|
||
|
||
@Composable
|
||
fun RainyTokenNavHost() {
|
||
val windowSize = rememberWindowSizeClass()
|
||
val isExpanded = windowSize.widthSizeClass ==
|
||
androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Expanded
|
||
|
||
if (isExpanded) {
|
||
ExpandedLayout()
|
||
} else {
|
||
CompactNavHost()
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// Compact:单栈 NavHost —— guardedPop 围栏 + popExit/popEnter=None
|
||
// ═══════════════════════════════════════════════
|
||
|
||
/**
|
||
* 非 Compose State 的时间戳围栏。
|
||
*
|
||
* 为什么不用 mutableStateOf:
|
||
* mutableStateOf 写入会触发 CompactNavHost → NavHost 重组。若重组发生在
|
||
* AnimatedContent 过渡动画期间,会干扰内部 MutableTransitionState 状态机,
|
||
* 导致旧 composable 已移除、新 composable 进入动画被中断后未正确启动 → 空白页。
|
||
*
|
||
* 连点保护:cooldownMs 内只放行一次 pop。popBackStack() 返回 false 时 reset。
|
||
*/
|
||
private class PopGuard(
|
||
private val cooldownMs: Long = 200
|
||
) {
|
||
private var lastPopTime: Long = 0L
|
||
|
||
fun tryAcquire(): Boolean {
|
||
val now = System.currentTimeMillis()
|
||
if (now - lastPopTime < cooldownMs) return false
|
||
lastPopTime = now
|
||
return true
|
||
}
|
||
|
||
fun reset() {
|
||
lastPopTime = 0L
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun CompactNavHost() {
|
||
val navController = rememberNavController()
|
||
// 围栏:200ms 内只允许一次 pop;普通对象持有时间戳,不触发 NavHost 重组
|
||
val popGuard = remember { PopGuard() }
|
||
val guardedPop: () -> Unit = {
|
||
// 起始页无 previousBackStackEntry,直接跳过,避免无意义的 popBackStack() 调用
|
||
if (navController.previousBackStackEntry != null && popGuard.tryAcquire()) {
|
||
// popBackStack() 返回 false = 已在起始页,reset 围栏不浪费冷却
|
||
if (!navController.popBackStack()) {
|
||
popGuard.reset()
|
||
}
|
||
}
|
||
}
|
||
NavHost(
|
||
navController = navController,
|
||
startDestination = Routes.DASHBOARD,
|
||
enterTransition = {
|
||
slideIntoContainer(
|
||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||
animationSpec = tween(220)
|
||
) + fadeIn(animationSpec = tween(90))
|
||
},
|
||
exitTransition = {
|
||
slideOutOfContainer(
|
||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||
animationSpec = tween(220)
|
||
) + fadeOut(animationSpec = tween(90))
|
||
},
|
||
// Navigation Compose 会把 pop 过渡绑定到 Android 13+ predictive back 手势进度,
|
||
// 因此这里使用同一套滑动动画,避免默认淡入淡出与返回动画混用。
|
||
popExitTransition = {
|
||
slideOutOfContainer(
|
||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||
animationSpec = tween(220)
|
||
) + fadeOut(animationSpec = tween(90))
|
||
},
|
||
popEnterTransition = {
|
||
slideIntoContainer(
|
||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||
animationSpec = tween(220)
|
||
) + fadeIn(animationSpec = tween(90))
|
||
}
|
||
) {
|
||
composable(Routes.DASHBOARD) {
|
||
DashboardScreen(
|
||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
|
||
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
||
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) }
|
||
)
|
||
}
|
||
composable(Routes.USAGE_DETAIL) {
|
||
UsageDetailScreen(
|
||
onBack = guardedPop,
|
||
onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) },
|
||
onOpenData = { navController.navigate(Routes.USAGE_DATA) }
|
||
)
|
||
}
|
||
composable(Routes.USAGE_OVERVIEW) {
|
||
UsageOverviewScreen(
|
||
onBack = guardedPop
|
||
)
|
||
}
|
||
composable(Routes.USAGE_DATA) {
|
||
UsageDataScreen(
|
||
onBack = guardedPop
|
||
)
|
||
}
|
||
composable(Routes.CCGO_USAGE_DETAIL) {
|
||
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
|
||
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
|
||
LaunchedEffect(Unit) {
|
||
usageVm.setWorkspace(wid)
|
||
chartVm.setWorkspace(wid)
|
||
}
|
||
UsageDetailScreen(
|
||
onBack = guardedPop,
|
||
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
|
||
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
|
||
viewModel = chartVm,
|
||
clearViewModel = usageVm
|
||
)
|
||
}
|
||
composable(Routes.CCGO_USAGE_OVERVIEW) {
|
||
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
|
||
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
||
LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
|
||
UsageOverviewScreen(
|
||
onBack = guardedPop,
|
||
viewModel = ovVm,
|
||
autoLoad = false
|
||
)
|
||
}
|
||
composable(Routes.CCGO_USAGE_DATA) {
|
||
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
|
||
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
||
LaunchedEffect(Unit) { dataVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
|
||
UsageDataScreen(
|
||
onBack = guardedPop,
|
||
viewModel = dataVm,
|
||
autoLoad = false
|
||
)
|
||
}
|
||
composable(Routes.SETTINGS) {
|
||
SettingsScreen(
|
||
onBack = guardedPop,
|
||
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) },
|
||
onOpenTips = { navController.navigate(Routes.TIPS) }
|
||
)
|
||
}
|
||
composable(Routes.TIPS) {
|
||
TipsScreen(onBack = guardedPop)
|
||
}
|
||
composable(
|
||
route = Routes.CREDENTIAL_EDIT,
|
||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||
) { backStackEntry ->
|
||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||
CredentialEditScreen(
|
||
service = type,
|
||
onBack = guardedPop,
|
||
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
||
onWebViewLoginSuccess = { }
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.WEBVIEW_LOGIN,
|
||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||
) { backStackEntry ->
|
||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||
WebViewLoginScreen(
|
||
service = type,
|
||
onBack = guardedPop,
|
||
onLoginSucceeded = { guardedPop() }
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.SERVICE_DETAIL,
|
||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||
) { backStackEntry ->
|
||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||
ServiceDetailScreen(
|
||
service = type,
|
||
onBack = guardedPop,
|
||
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
|
||
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// Expanded:手动双窗格
|
||
// ═══════════════════════════════════════════════
|
||
|
||
@Composable
|
||
private fun ExpandedLayout() {
|
||
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
|
||
|
||
Row(modifier = Modifier.fillMaxSize()) {
|
||
Box(
|
||
modifier = Modifier
|
||
.weight(0.35f)
|
||
.fillMaxHeight()
|
||
) {
|
||
DashboardScreen(
|
||
onOpenSettings = { detailPane = DetailPane.Settings },
|
||
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
|
||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }
|
||
)
|
||
}
|
||
|
||
VerticalDivider(
|
||
thickness = 1.dp,
|
||
color = MaterialTheme.colorScheme.outlineVariant
|
||
)
|
||
|
||
Box(
|
||
modifier = Modifier
|
||
.weight(0.65f)
|
||
.fillMaxHeight()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
) {
|
||
ExpandedDetailPane(
|
||
pane = detailPane,
|
||
onClose = { detailPane = DetailPane.Empty }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ExpandedDetailPane(
|
||
pane: DetailPane,
|
||
onClose: () -> Unit
|
||
) {
|
||
when (pane) {
|
||
is DetailPane.Empty -> {
|
||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||
Text("👈 选择服务查看详情", style = MaterialTheme.typography.titleMedium, color = inkMuted())
|
||
Text("点击左侧卡片即可", style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||
}
|
||
}
|
||
}
|
||
is DetailPane.ServiceDetail -> {
|
||
ServiceDetailScreen(
|
||
service = pane.type,
|
||
onBack = onClose,
|
||
onConfigureCredential = { onClose() },
|
||
onStartWebViewLogin = { }
|
||
)
|
||
}
|
||
is DetailPane.OCGOUsage -> {
|
||
val navController = rememberNavController()
|
||
NavHost(
|
||
navController = navController,
|
||
startDestination = "chart",
|
||
popExitTransition = { ExitTransition.None }
|
||
) {
|
||
composable("chart") {
|
||
UsageDetailScreen(
|
||
onBack = onClose,
|
||
onOpenOverview = { navController.navigate("overview") },
|
||
onOpenData = { navController.navigate("data") }
|
||
)
|
||
}
|
||
composable("overview") {
|
||
UsageOverviewScreen(onBack = { navController.popBackStack() }, autoLoad = true)
|
||
}
|
||
composable("data") {
|
||
UsageDataScreen(onBack = { navController.popBackStack() }, autoLoad = true)
|
||
}
|
||
}
|
||
}
|
||
is DetailPane.CCGOUsage -> {
|
||
val navController = rememberNavController()
|
||
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
|
||
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
|
||
LaunchedEffect(Unit) {
|
||
usageVm.setWorkspace(wid)
|
||
chartVm.setWorkspace(wid)
|
||
}
|
||
NavHost(
|
||
navController = navController,
|
||
startDestination = "chart",
|
||
popExitTransition = { ExitTransition.None }
|
||
) {
|
||
composable("chart") {
|
||
UsageDetailScreen(
|
||
onBack = onClose,
|
||
onOpenOverview = { navController.navigate("overview") },
|
||
onOpenData = { navController.navigate("data") },
|
||
viewModel = chartVm,
|
||
clearViewModel = usageVm
|
||
)
|
||
}
|
||
composable("overview") {
|
||
val key = "ccgo_$wid"
|
||
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
||
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
|
||
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
|
||
}
|
||
composable("data") {
|
||
val key = "ccgo_$wid"
|
||
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
||
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
|
||
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
|
||
}
|
||
}
|
||
}
|
||
is DetailPane.Settings -> {
|
||
val settingsNavController = rememberNavController()
|
||
NavHost(
|
||
navController = settingsNavController,
|
||
startDestination = "settings_main",
|
||
popExitTransition = { ExitTransition.None }
|
||
) {
|
||
composable("settings_main") {
|
||
SettingsScreen(
|
||
onBack = onClose,
|
||
onEditCredential = { type ->
|
||
settingsNavController.navigate(Routes.credentialEdit(type))
|
||
},
|
||
onOpenTips = { settingsNavController.navigate("tips") }
|
||
)
|
||
}
|
||
composable("tips") {
|
||
TipsScreen(onBack = { settingsNavController.popBackStack() })
|
||
}
|
||
composable(
|
||
route = Routes.CREDENTIAL_EDIT,
|
||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||
) { entry ->
|
||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||
CredentialEditScreen(
|
||
service = type,
|
||
onBack = { settingsNavController.popBackStack() },
|
||
onStartWebViewLogin = { svc ->
|
||
settingsNavController.navigate(Routes.webviewLogin(svc))
|
||
},
|
||
onWebViewLoginSuccess = { }
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.WEBVIEW_LOGIN,
|
||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||
) { entry ->
|
||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||
WebViewLoginScreen(
|
||
service = type,
|
||
onBack = { settingsNavController.popBackStack() },
|
||
onLoginSucceeded = { settingsNavController.popBackStack() }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} |