Add unread count badge to bottom nav (#3440)

This commit is contained in:
Phil Oliver
2025-10-12 08:22:46 -04:00
committed by GitHub
parent 91470667fb
commit cd1a54f506
8 changed files with 67 additions and 54 deletions

View File

@@ -8,6 +8,7 @@
<ID>CommentWrapping:SignalMetrics.kt$Metric.SNR$/* Selected 12 as the max to get 4 equal vertical sections. */</ID>
<ID>ComposableParamOrder:Channel.kt$ChannelScreen</ID>
<ID>ComposableParamOrder:Channel.kt$EditChannelUrl</ID>
<ID>ComposableParamOrder:ConnectionsNavIcon.kt$ConnectionsNavIcon</ID>
<ID>ComposableParamOrder:DeviceMetrics.kt$DeviceMetricsChart</ID>
<ID>ComposableParamOrder:EmptyStateContent.kt$EmptyStateContent</ID>
<ID>ComposableParamOrder:EnvironmentCharts.kt$ChartContent</ID>
@@ -27,7 +28,6 @@
<ID>ComposableParamOrder:QuickChat.kt$OutlinedTextFieldWithCounter</ID>
<ID>ComposableParamOrder:Share.kt$ShareScreen</ID>
<ID>ComposableParamOrder:SignalMetrics.kt$SignalMetricsChart</ID>
<ID>ComposableParamOrder:TopLevelNavIcon.kt$ConnectionsNavIcon</ID>
<ID>CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }</ID>
<ID>EmptyFunctionBlock:NopInterface.kt$NopInterface${ }</ID>
@@ -135,7 +135,6 @@
<ID>ModifierMissing:Share.kt$ShareScreen</ID>
<ID>ModifierMissing:SharedContactDialog.kt$SharedContactDialog</ID>
<ID>ModifierMissing:SignalMetrics.kt$SignalMetricsScreen</ID>
<ID>ModifierMissing:TopLevelNavIcon.kt$TopLevelNavIcon</ID>
<ID>ModifierNotUsedAtRoot:DeviceMetrics.kt$modifier = modifier.weight(weight = Y_AXIS_WEIGHT)</ID>
<ID>ModifierNotUsedAtRoot:DeviceMetrics.kt$modifier = modifier.width(dp)</ID>
<ID>ModifierNotUsedAtRoot:DeviceMetrics.kt$modifier.width(dp)</ID>

View File

@@ -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<Int> = uiPreferencesDataSource.theme
@@ -209,6 +211,12 @@ constructor(
val channels: StateFlow<AppOnlyProtos.ChannelSet>
get() = _channels
val unreadMessageCount =
packetRepository
.getUnreadCountTotal()
.map { it.coerceAtLeast(0) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), 0)
val quickChatActions
get() =
quickChatActionRepository

View File

@@ -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))
}
}
},

View File

@@ -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<TopLevelDestination> {
override val values: Sequence<TopLevelDestination> = TopLevelDestination.entries.asSequence()
}
class ConnectionStateProvider : PreviewParameterProvider<ConnectionState> {
override val values: Sequence<ConnectionState> =
sequenceOf(ConnectionState.CONNECTED, ConnectionState.DEVICE_SLEEP, ConnectionState.DISCONNECTED)
@@ -118,20 +94,6 @@ class DeviceTypeProvider : PreviewParameterProvider<DeviceType> {
override val values: Sequence<DeviceType> = 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(

View File

@@ -44,6 +44,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: Lazy<Packe
suspend fun getUnreadCount(contact: String): Int = withContext(Dispatchers.IO) { packetDao.getUnreadCount(contact) }
fun getUnreadCountTotal(): Flow<Int> = packetDao.getUnreadCountTotal()
suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(Dispatchers.IO) { packetDao.clearUnreadCount(contact, timestamp) }

View File

@@ -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<Int>
@Query(
"""
UPDATE packet

View File

@@ -12,7 +12,6 @@
<ID>ComposableParamOrder:SwitchPreference.kt$SwitchPreference</ID>
<ID>ContentSlotReused:AdaptiveTwoPane.kt$second</ID>
<ID>LambdaParameterEventTrailing:MainAppBar.kt$onClickChip</ID>
<ID>LongMethod:EditTextPreference.kt$@Composable fun EditTextPreference( title: String, value: String, enabled: Boolean, isError: Boolean, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, onValueChanged: (String) -&gt; Unit, modifier: Modifier = Modifier, summary: String? = null, maxSize: Int = 0, // max_size - 1 (in bytes) onFocusChanged: (FocusState) -&gt; Unit = {}, trailingIcon: (@Composable () -&gt; Unit)? = null, visualTransformation: VisualTransformation = VisualTransformation.None, )</ID>
<ID>MagicNumber:EditIPv4Preference.kt$0xff</ID>
<ID>MagicNumber:EditIPv4Preference.kt$16</ID>
<ID>MagicNumber:EditIPv4Preference.kt$24</ID>
@@ -60,6 +59,5 @@
<ID>PreviewPublic:SignalInfo.kt$SignalInfoPreview</ID>
<ID>PreviewPublic:SignalInfo.kt$SignalInfoSelfPreview</ID>
<ID>PreviewPublic:SignalInfo.kt$SignalInfoSimplePreview</ID>
<ID>UnusedParameter:DropDownPreference.kt$modifier: Modifier = Modifier</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -13,7 +13,6 @@
<ID>CyclomaticComplexMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>LambdaParameterEventTrailing:NodeActionButton.kt$onClick</ID>
<ID>LongMethod:AmbientLightingConfigItemList.kt$@Composable fun AmbientLightingConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
@@ -34,10 +33,8 @@
<ID>MagicNumber:EditChannelDialog.kt$16</ID>
<ID>MagicNumber:EditChannelDialog.kt$32</ID>
<ID>MagicNumber:PacketResponseStateDialog.kt$100</ID>
<ID>MagicNumber:PowerConfigItemList.kt$3600</ID>
<ID>ModifierMissing:ChannelSettingsItemList.kt$ChannelSelection</ID>
<ID>ModifierMissing:CleanNodeDatabaseScreen.kt$CleanNodeDatabaseScreen</ID>
<ID>ModifierMissing:LoRaConfigItemList.kt$LoRaConfigScreen</ID>
<ID>ModifierMissing:MapReportingPreference.kt$MapReportingPreference</ID>
<ID>ModifierMissing:NetworkConfigItemList.kt$NetworkConfigScreen</ID>
<ID>ModifierMissing:PositionConfigItemList.kt$PositionConfigScreen</ID>