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