feat(ui): add mesh activity modem lights (#2656)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-07 14:17:01 -05:00 committed by GitHub
parent da42d67486
commit ad59edd8d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 206 additions and 84 deletions

View file

@ -21,7 +21,11 @@ import android.Manifest
import android.os.Build
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
@ -43,6 +47,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.PlainTooltip
@ -57,13 +62,16 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@ -98,12 +106,14 @@ import com.geeksville.mesh.navigation.NodesRoutes
import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.showLongNameTitle
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusOrange
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
import com.geeksville.mesh.ui.debug.DebugMenuActions
@ -114,6 +124,8 @@ import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, val route: Route) {
Contacts(R.string.contacts, Icons.AutoMirrored.TwoTone.Chat, ContactsRoutes.ContactsGraph),
@ -222,6 +234,39 @@ fun MainScreen(
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
// State for managing the glow animation around the Connections icon
var currentGlowColor by remember { mutableStateOf(Color.Transparent) }
val animatedGlowAlpha = remember { Animatable(0f) }
val coroutineScope = rememberCoroutineScope()
val capturedColorScheme = colorScheme // Capture current colorScheme instance for LaunchedEffect
val sendColor = capturedColorScheme.StatusOrange
val receiveColor = capturedColorScheme.StatusYellow
LaunchedEffect(uIViewModel.meshActivity, capturedColorScheme) {
uIViewModel.meshActivity.collectLatest { activity ->
debug("MeshActivity Event: $activity, Current Alpha: ${animatedGlowAlpha.value}")
val newTargetColor =
when (activity) {
is MeshActivity.Send -> sendColor
is MeshActivity.Receive -> receiveColor
}
currentGlowColor = newTargetColor
// Stop any existing animation and launch a new one.
// Launching in a new coroutine ensures the collect block is not suspended.
coroutineScope.launch {
animatedGlowAlpha.stop() // Stop before snapping/animating
animatedGlowAlpha.snapTo(1.0f) // Show glow instantly
animatedGlowAlpha.animateTo(
targetValue = 0.0f, // Fade out
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
)
}
}
}
NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
@ -245,7 +290,39 @@ fun MainScreen(
},
state = rememberTooltipState(),
) {
TopLevelNavIcon(destination, connectionState)
val iconModifier =
if (isConnectionsRoute) {
Modifier.drawWithCache {
onDrawWithContent {
drawContent()
if (animatedGlowAlpha.value > 0f) {
val glowRadius = size.minDimension
drawCircle(
brush =
Brush.radialGradient(
colors =
listOf(
currentGlowColor.copy(
alpha = 0.8f * animatedGlowAlpha.value,
),
currentGlowColor.copy(
alpha = 0.4f * animatedGlowAlpha.value,
),
Color.Transparent,
),
center = center,
radius = glowRadius,
),
radius = glowRadius,
blendMode = BlendMode.Screen,
)
}
}
}
} else {
Modifier
}
Box(modifier = iconModifier) { TopLevelNavIcon(destination, connectionState) }
}
},
selected = isSelected,
@ -309,6 +386,25 @@ fun MainScreen(
}
}
@Composable
private fun TopLevelNavIcon(destination: TopLevelDestination, connectionState: MeshService.ConnectionState) {
val iconTint =
when {
destination == TopLevelDestination.Connections -> connectionState.getConnectionColor()
else -> LocalContentColor.current
}
Icon(
imageVector =
if (destination == TopLevelDestination.Connections) {
connectionState.getConnectionIcon()
} else {
destination.icon
},
contentDescription = stringResource(id = destination.label),
tint = iconTint,
)
}
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun VersionChecks(viewModel: UIViewModel) {
@ -322,7 +418,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
val currentDeviceHardware by viewModel.deviceHardware.collectAsStateWithLifecycle(null)
val latestStableFirmwareRelease by viewModel.latestStableFirmwareRelease.collectAsState(DeviceVersion("2.6.4"))
val latestStableFirmwareRelease by
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
LaunchedEffect(connectionState, firmwareEdition) {
if (connectionState == MeshService.ConnectionState.CONNECTED) {
firmwareEdition?.let { edition ->
@ -419,7 +516,7 @@ private fun MainAppBar(
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
TopAppBar(
title = {
val title =
val titleText =
when {
currentDestination == null || currentDestination.isTopLevel() ->
stringResource(id = R.string.app_name)
@ -435,7 +532,7 @@ private fun MainAppBar(
else -> stringResource(id = R.string.app_name)
}
Text(
text = title,
text = titleText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
@ -514,7 +611,7 @@ private fun MainMenuActions(isManaged: Boolean, onAction: (MainMenuAction) -> Un
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)),
modifier = Modifier.background(colorScheme.background.copy(alpha = 1f)),
) {
MainMenuAction.entries.forEach { action ->
DropdownMenuItem(
@ -552,17 +649,3 @@ private fun MeshService.ConnectionState.getTooltipString(): String = when (this)
MeshService.ConnectionState.DEVICE_SLEEP -> stringResource(R.string.device_sleeping)
MeshService.ConnectionState.DISCONNECTED -> stringResource(R.string.disconnected)
}
@Composable
private fun TopLevelNavIcon(dest: TopLevelDestination, connectionState: MeshService.ConnectionState) {
when (dest) {
TopLevelDestination.Connections ->
Icon(
imageVector = connectionState.getConnectionIcon(),
contentDescription = stringResource(id = dest.label),
tint = connectionState.getConnectionColor(),
)
else -> Icon(imageVector = dest.icon, contentDescription = stringResource(id = dest.label))
}
}