Skip to content
4 changes: 2 additions & 2 deletions GEMINI.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
Expand Down Expand Up @@ -75,7 +75,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO` → `org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`.
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
Expand Down
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

Expand Down
224 changes: 112 additions & 112 deletions app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
Expand All @@ -78,8 +77,7 @@ import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.must_update
import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
Expand Down Expand Up @@ -111,13 +109,6 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()

MeshtasticCommonAppSetup(
uiViewModel = uIViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
},
)

AndroidAppVersionCheck(uIViewModel)
val navSuiteType =
NavigationSuiteScaffoldDefaults.navigationSuiteType(
Expand All @@ -129,118 +120,127 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
// State for determining the connection type icon to display
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()

NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
TopLevelDestination.entries.forEach { destination ->
val isSelected = destination == topLevelDestination
val isConnectionsRoute = destination == TopLevelDestination.Connections
item(
icon = {
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep ->
stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
},
MeshtasticAppShell(
backStack = backStack,
uiViewModel = uIViewModel,
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
) {
NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
TopLevelDestination.entries.forEach { destination ->
val isSelected = destination == topLevelDestination
val isConnectionsRoute = destination == TopLevelDestination.Connections
item(
icon = {
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep ->
stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected ->
stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
},
)
}
},
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice),
meshActivityFlow = uIViewModel.meshActivity,
colorScheme = colorScheme,
)
}
},
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice),
meshActivityFlow = uIViewModel.meshActivity,
colorScheme = colorScheme,
)
} 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()) }
} 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()) }
}
}
},
) {
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint =
if (isSelectedState) {
colorScheme.primary
} else {
LocalContentColor.current
},
)
}
},
) {
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint =
if (isSelectedState) colorScheme.primary else LocalContentColor.current,
)
}
}
}
}
},
selected = isSelected,
label = {
Text(
text = stringResource(destination.label),
modifier =
if (navSuiteType == NavigationSuiteType.ShortNavigationBarCompact) {
Modifier.width(1.dp)
.height(1.dp) // hide on phone - min 1x1 or talkback won't see it.
} else {
Modifier
},
)
},
onClick = {
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
},
selected = isSelected,
label = {
Text(
text = stringResource(destination.label),
modifier =
if (navSuiteType == NavigationSuiteType.ShortNavigationBarCompact) {
Modifier.width(1.dp)
.height(1.dp) // hide on phone - min 1x1 or talkback won't see it.
} else {
Modifier
},
)
},
onClick = {
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
else -> Unit
}
else -> Unit
} else {
backStack.navigateTopLevel(destination.route)
}
} else {
backStack.navigateTopLevel(destination.route)
}
},
)
}
},
) {
MeshtasticSnackbarProvider(
snackbarManager = uIViewModel.snackbarManager,
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
},
)
}
},
) {
val provider =
entryProvider<NavKey> {
Expand Down
Loading