From cd1a54f506bc18b14ea3f2b45faf24523aaaaa31 Mon Sep 17 00:00:00 2001
From: Phil Oliver <3497406+poliver@users.noreply.github.com>
Date: Sun, 12 Oct 2025 08:22:46 -0400
Subject: [PATCH] Add unread count badge to bottom nav (#3440)
---
app/detekt-baseline.xml | 3 +-
.../java/com/geeksville/mesh/model/UIState.kt | 8 +++
.../main/java/com/geeksville/mesh/ui/Main.kt | 54 ++++++++++++++++---
...pLevelNavIcon.kt => ConnectionsNavIcon.kt} | 40 +-------------
.../core/data/repository/PacketRepository.kt | 2 +
.../meshtastic/core/database/dao/PacketDao.kt | 9 ++++
core/ui/detekt-baseline.xml | 2 -
feature/settings/detekt-baseline.xml | 3 --
8 files changed, 67 insertions(+), 54 deletions(-)
rename app/src/main/java/com/geeksville/mesh/ui/connections/components/{TopLevelNavIcon.kt => ConnectionsNavIcon.kt} (77%)
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 266ea012c..294449788 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -8,6 +8,7 @@
CommentWrapping:SignalMetrics.kt$Metric.SNR$/* Selected 12 as the max to get 4 equal vertical sections. */
ComposableParamOrder:Channel.kt$ChannelScreen
ComposableParamOrder:Channel.kt$EditChannelUrl
+ ComposableParamOrder:ConnectionsNavIcon.kt$ConnectionsNavIcon
ComposableParamOrder:DeviceMetrics.kt$DeviceMetricsChart
ComposableParamOrder:EmptyStateContent.kt$EmptyStateContent
ComposableParamOrder:EnvironmentCharts.kt$ChartContent
@@ -27,7 +28,6 @@
ComposableParamOrder:QuickChat.kt$OutlinedTextFieldWithCounter
ComposableParamOrder:Share.kt$ShareScreen
ComposableParamOrder:SignalMetrics.kt$SignalMetricsChart
- ComposableParamOrder:TopLevelNavIcon.kt$ConnectionsNavIcon
CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)
EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }
EmptyFunctionBlock:NopInterface.kt$NopInterface${ }
@@ -135,7 +135,6 @@
ModifierMissing:Share.kt$ShareScreen
ModifierMissing:SharedContactDialog.kt$SharedContactDialog
ModifierMissing:SignalMetrics.kt$SignalMetricsScreen
- ModifierMissing:TopLevelNavIcon.kt$TopLevelNavIcon
ModifierNotUsedAtRoot:DeviceMetrics.kt$modifier = modifier.weight(weight = Y_AXIS_WEIGHT)
ModifierNotUsedAtRoot:DeviceMetrics.kt$modifier = modifier.width(dp)
ModifierNotUsedAtRoot:DeviceMetrics.kt$modifier.width(dp)
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index b4d8f8adf..3eb3e1a7a 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -52,6 +52,7 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
@@ -134,6 +135,7 @@ constructor(
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val meshServiceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
+ packetRepository: PacketRepository,
) : ViewModel() {
val theme: StateFlow = uiPreferencesDataSource.theme
@@ -209,6 +211,12 @@ constructor(
val channels: StateFlow
get() = _channels
+ val unreadMessageCount =
+ packetRepository
+ .getUnreadCountTotal()
+ .map { it.coerceAtLeast(0) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), 0)
+
val quickChatActions
get() =
quickChatActionRepository
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index 3167b4370..2ce8596aa 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -22,9 +22,14 @@ package com.geeksville.mesh.ui
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.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -35,7 +40,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Wifi
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
@@ -52,6 +61,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -85,7 +95,7 @@ import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.connections.DeviceType
-import com.geeksville.mesh.ui.connections.components.TopLevelNavIcon
+import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
import com.geeksville.mesh.ui.metrics.annotateTraceroute
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -137,6 +147,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
+ val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
@@ -279,8 +290,9 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
},
state = rememberTooltipState(),
) {
- val iconModifier =
- if (isConnectionsRoute) {
+ if (isConnectionsRoute) {
+ Box(
+ modifier =
Modifier.drawWithCache {
onDrawWithContent {
drawContent()
@@ -307,12 +319,38 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
)
}
}
- }
- } else {
- Modifier
+ },
+ ) {
+ ConnectionsNavIcon(
+ connectionState = connectionState,
+ deviceType = DeviceType.fromAddress(selectedDevice),
+ )
+ }
+ } else {
+ BadgedBox(
+ badge = {
+ if (destination == TopLevelDestination.Conversations) {
+ // Keep track of the last non-zero count for display during exit animation
+ var lastNonZeroCount by remember { mutableIntStateOf(unreadMessageCount) }
+ if (unreadMessageCount > 0) {
+ lastNonZeroCount = unreadMessageCount
+ }
+ AnimatedVisibility(
+ visible = unreadMessageCount > 0,
+ enter = scaleIn() + fadeIn(),
+ exit = scaleOut() + fadeOut(),
+ ) {
+ Badge { Text(lastNonZeroCount.toString()) }
+ }
+ }
+ },
+ ) {
+ Icon(
+ imageVector = destination.icon,
+ contentDescription = stringResource(id = destination.label),
+ tint = LocalContentColor.current,
+ )
}
- Box(modifier = iconModifier) {
- TopLevelNavIcon(destination, connectionState, DeviceType.fromAddress(selectedDevice))
}
}
},
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/TopLevelNavIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
similarity index 77%
rename from app/src/main/java/com/geeksville/mesh/ui/connections/components/TopLevelNavIcon.kt
rename to app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
index 3d298a991..7d79d2a84 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/TopLevelNavIcon.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
@@ -23,7 +23,6 @@ import androidx.compose.material.icons.rounded.Snooze
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.Icon
-import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -31,12 +30,10 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
-import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import com.geeksville.mesh.ui.TopLevelDestination
import com.geeksville.mesh.ui.connections.DeviceType
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.icon.Device
@@ -48,24 +45,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Composable
-fun TopLevelNavIcon(destination: TopLevelDestination, connectionState: ConnectionState, deviceType: DeviceType?) {
- if (destination == TopLevelDestination.Connections) {
- ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType)
- } else {
- Icon(
- imageVector = destination.icon,
- contentDescription = stringResource(id = destination.label),
- tint = LocalContentColor.current,
- )
- }
-}
-
-@Composable
-private fun ConnectionsNavIcon(
- modifier: Modifier = Modifier,
- connectionState: ConnectionState,
- deviceType: DeviceType?,
-) {
+fun ConnectionsNavIcon(modifier: Modifier = Modifier, connectionState: ConnectionState, deviceType: DeviceType?) {
val tint =
when (connectionState) {
ConnectionState.DISCONNECTED -> colorScheme.StatusRed
@@ -105,10 +85,6 @@ private fun ConnectionsNavIcon(
)
}
-class TopLevelDestinationProvider : PreviewParameterProvider {
- override val values: Sequence = TopLevelDestination.entries.asSequence()
-}
-
class ConnectionStateProvider : PreviewParameterProvider {
override val values: Sequence =
sequenceOf(ConnectionState.CONNECTED, ConnectionState.DEVICE_SLEEP, ConnectionState.DISCONNECTED)
@@ -118,20 +94,6 @@ class DeviceTypeProvider : PreviewParameterProvider {
override val values: Sequence = sequenceOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
}
-@PreviewLightDark
-@Composable
-private fun TopLevelNavIconPreviewConnectionStates(
- @PreviewParameter(TopLevelDestinationProvider::class) destination: TopLevelDestination,
-) {
- AppTheme {
- TopLevelNavIcon(
- destination = destination,
- connectionState = ConnectionState.CONNECTED,
- deviceType = DeviceType.BLE,
- )
- }
-}
-
@PreviewLightDark
@Composable
private fun ConnectionsNavIconPreviewConnectionStates(
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt
index 999f3d9c4..87e24182f 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt
@@ -44,6 +44,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: Lazy = packetDao.getUnreadCountTotal()
+
suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(Dispatchers.IO) { packetDao.clearUnreadCount(contact, timestamp) }
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index 2552a409e..05b240412 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -79,6 +79,15 @@ interface PacketDao {
)
suspend fun getUnreadCount(contact: String): Int
+ @Query(
+ """
+ SELECT COUNT(*) FROM packet
+ WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ AND port_num = 1 AND read = 0
+ """,
+ )
+ fun getUnreadCountTotal(): Flow
+
@Query(
"""
UPDATE packet
diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml
index 3af4a1e99..a50dd0eb0 100644
--- a/core/ui/detekt-baseline.xml
+++ b/core/ui/detekt-baseline.xml
@@ -12,7 +12,6 @@
ComposableParamOrder:SwitchPreference.kt$SwitchPreference
ContentSlotReused:AdaptiveTwoPane.kt$second
LambdaParameterEventTrailing:MainAppBar.kt$onClickChip
- LongMethod:EditTextPreference.kt$@Composable fun EditTextPreference( title: String, value: String, enabled: Boolean, isError: Boolean, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, onValueChanged: (String) -> Unit, modifier: Modifier = Modifier, summary: String? = null, maxSize: Int = 0, // max_size - 1 (in bytes) onFocusChanged: (FocusState) -> Unit = {}, trailingIcon: (@Composable () -> Unit)? = null, visualTransformation: VisualTransformation = VisualTransformation.None, )
MagicNumber:EditIPv4Preference.kt$0xff
MagicNumber:EditIPv4Preference.kt$16
MagicNumber:EditIPv4Preference.kt$24
@@ -60,6 +59,5 @@
PreviewPublic:SignalInfo.kt$SignalInfoPreview
PreviewPublic:SignalInfo.kt$SignalInfoSelfPreview
PreviewPublic:SignalInfo.kt$SignalInfoSimplePreview
- UnusedParameter:DropDownPreference.kt$modifier: Modifier = Modifier
diff --git a/feature/settings/detekt-baseline.xml b/feature/settings/detekt-baseline.xml
index 8d75a8103..fdf5301fa 100644
--- a/feature/settings/detekt-baseline.xml
+++ b/feature/settings/detekt-baseline.xml
@@ -13,7 +13,6 @@
CyclomaticComplexMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())
CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)
LambdaParameterEventTrailing:NodeActionButton.kt$onClick
- LongMethod:AmbientLightingConfigItemList.kt$@Composable fun AmbientLightingConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())
LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())
LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())
LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())
@@ -34,10 +33,8 @@
MagicNumber:EditChannelDialog.kt$16
MagicNumber:EditChannelDialog.kt$32
MagicNumber:PacketResponseStateDialog.kt$100
- MagicNumber:PowerConfigItemList.kt$3600
ModifierMissing:ChannelSettingsItemList.kt$ChannelSelection
ModifierMissing:CleanNodeDatabaseScreen.kt$CleanNodeDatabaseScreen
- ModifierMissing:LoRaConfigItemList.kt$LoRaConfigScreen
ModifierMissing:MapReportingPreference.kt$MapReportingPreference
ModifierMissing:NetworkConfigItemList.kt$NetworkConfigScreen
ModifierMissing:PositionConfigItemList.kt$PositionConfigScreen