diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6988520..fae179b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.a2i.forwarder" minSdk = 34 targetSdk = 36 - versionCode = 22 - versionName = "1.8.5" + versionCode = 23 + versionName = "1.8.6" } signingConfigs { diff --git a/app/src/main/java/com/a2i/forwarder/ui/nav/AppNav.kt b/app/src/main/java/com/a2i/forwarder/ui/nav/AppNav.kt index 04a3bf0..842f302 100644 --- a/app/src/main/java/com/a2i/forwarder/ui/nav/AppNav.kt +++ b/app/src/main/java/com/a2i/forwarder/ui/nav/AppNav.kt @@ -1,7 +1,20 @@ package com.a2i.forwarder.ui.nav +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.Notifications @@ -14,8 +27,15 @@ import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState 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.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.navigation.NavGraph.Companion.findStartDestination @@ -23,11 +43,13 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.a2i.forwarder.A2iApp import com.a2i.forwarder.ui.screens.AppManageScreen import com.a2i.forwarder.ui.screens.FilterScreen import com.a2i.forwarder.ui.screens.HomeScreen import com.a2i.forwarder.ui.screens.LogScreen import com.a2i.forwarder.ui.screens.SettingsScreen +import com.a2i.forwarder.ui.screens.UpdateDialog private sealed class Tab(val route: String, val label: String, val icon: ImageVector) { object Home : Tab("home", "首页", Icons.Outlined.Notifications) @@ -46,54 +68,104 @@ fun AppRoot() { val current = backStack?.destination?.route val showBar = current in tabRoutes - Scaffold( - containerColor = MaterialTheme.colorScheme.background, - bottomBar = { - if (showBar) { - NavigationBar( - containerColor = MaterialTheme.colorScheme.surface, - tonalElevation = 0.dp, - ) { - tabs.forEach { t -> - NavigationBarItem( - selected = current == t.route, - onClick = { - nav.navigate(t.route) { - popUpTo(nav.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - restoreState = true - } - }, - icon = { Icon(t.icon, t.label) }, - label = { Text(t.label) }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = MaterialTheme.colorScheme.primary, - selectedTextColor = MaterialTheme.colorScheme.primary, - indicatorColor = MaterialTheme.colorScheme.primaryContainer, - unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) + val app = A2iApp.instance + val updateInfo by app.updateChecker.updateInfo.collectAsState() + var showUpdateDialog by remember { mutableStateOf(false) } + + Box { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + bottomBar = { + if (showBar) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + ) { + tabs.forEach { t -> + NavigationBarItem( + selected = current == t.route, + onClick = { + nav.navigate(t.route) { + popUpTo(nav.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(t.icon, t.label) }, + label = { Text(t.label) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + indicatorColor = MaterialTheme.colorScheme.primaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } } } + }, + ) { inner -> + NavHost( + navController = nav, + startDestination = Tab.Home.route, + modifier = Modifier.padding(inner), + ) { + composable(Tab.Home.route) { + HomeScreen( + onOpenLog = { nav.navigate("log") }, + onOpenSettings = { nav.navigate(Tab.Settings.route) }, + ) + } + composable(Tab.Apps.route) { AppManageScreen() } + composable(Tab.Filter.route) { FilterScreen() } + composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) } + composable("log") { LogScreen(onBack = { nav.popBackStack() }) } } - }, - ) { inner -> - NavHost( - navController = nav, - startDestination = Tab.Home.route, - modifier = Modifier.padding(inner), - ) { - composable(Tab.Home.route) { - HomeScreen( - onOpenLog = { nav.navigate("log") }, - onOpenSettings = { nav.navigate(Tab.Settings.route) }, - ) - } - composable(Tab.Apps.route) { AppManageScreen() } - composable(Tab.Filter.route) { FilterScreen() } - composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) } - composable("log") { LogScreen(onBack = { nav.popBackStack() }) } + } + + // 浮动更新提醒小图标(跨所有页面,缓慢闪烁) + if (updateInfo != null) { + UpdateBadge( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { showUpdateDialog = true }, + ) } } + + if (showUpdateDialog && updateInfo != null) { + UpdateDialog(updateInfo!!, onDismiss = { showUpdateDialog = false }) + } +} + +/** 跨页面浮动更新提醒:右上角小圆点 + 缓慢闪烁动画。 */ +@Composable +private fun UpdateBadge(modifier: Modifier = Modifier, onClick: () -> Unit) { + val transition = rememberInfiniteTransition(label = "updateBlink") + val pulseAlpha by transition.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1200), + repeatMode = RepeatMode.Reverse, + ), + label = "pulseAlpha", + ) + + Box( + modifier = modifier + .padding(top = 12.dp, end = 12.dp) + .size(40.dp) + .alpha(pulseAlpha) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.SystemUpdate, + contentDescription = "发现新版本,点击更新", + tint = Color.White, + modifier = Modifier.size(22.dp), + ) + } } \ No newline at end of file