mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Complete app module thinning and feature module extraction (#4844)
This commit is contained in:
parent
dcbbc0823b
commit
1b0dc75dfe
54 changed files with 439 additions and 119 deletions
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.navigation
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.nodes
|
||||
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Nodes
|
||||
import org.meshtastic.feature.node.compass.CompassViewModel
|
||||
import org.meshtastic.feature.node.detail.NodeDetailScreen
|
||||
import org.meshtastic.feature.node.detail.NodeDetailViewModel
|
||||
import org.meshtastic.feature.node.list.NodeListScreen
|
||||
import org.meshtastic.feature.node.list.NodeListViewModel
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun AdaptiveNodeListScreen(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||
initialNodeId: Int? = null,
|
||||
onNavigateToMessages: (String) -> Unit = {},
|
||||
) {
|
||||
val nodeListViewModel: NodeListViewModel = koinViewModel()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
|
||||
val scope = rememberCoroutineScope()
|
||||
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
|
||||
|
||||
val handleBack: () -> Unit = {
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val isNodesRoute = currentKey is NodesRoutes.Nodes || currentKey is NodesRoutes.NodesGraph
|
||||
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null
|
||||
val isFromDifferentGraph =
|
||||
previousKey != null && previousKey !is NodesRoutes.NodesGraph && previousKey !is NodesRoutes.Nodes
|
||||
|
||||
if (isFromDifferentGraph && !isNodesRoute) {
|
||||
// Navigate back via NavController to return to the previous screen
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
// Close the detail pane within the adaptive scaffold
|
||||
scope.launch { navigator.navigateBack(backNavigationBehavior) }
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
|
||||
|
||||
LaunchedEffect(initialNodeId) {
|
||||
if (initialNodeId != null) {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollToTopEvents) {
|
||||
scrollToTopEvents.collect { event ->
|
||||
if (
|
||||
event is ScrollToTopEvent.NodesTabPressed &&
|
||||
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail
|
||||
) {
|
||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
||||
navigator.navigateBack(backNavigationBehavior)
|
||||
} else {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
val focusManager = LocalFocusManager.current
|
||||
// Prevent TextFields from auto-focusing when pane animates in
|
||||
LaunchedEffect(Unit) { focusManager.clearFocus() }
|
||||
NodeListScreen(
|
||||
viewModel = nodeListViewModel,
|
||||
navigateToNodeDetails = { nodeId ->
|
||||
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
|
||||
},
|
||||
onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
activeNodeId = navigator.currentDestination?.contentKey,
|
||||
)
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
val focusManager = LocalFocusManager.current
|
||||
// Prevent TextFields from auto-focusing when pane animates in
|
||||
navigator.currentDestination?.contentKey?.let { nodeId ->
|
||||
key(nodeId) {
|
||||
LaunchedEffect(nodeId) { focusManager.clearFocus() }
|
||||
val nodeDetailViewModel: NodeDetailViewModel = koinViewModel()
|
||||
val compassViewModel: CompassViewModel = koinViewModel()
|
||||
NodeDetailScreen(
|
||||
nodeId = nodeId,
|
||||
viewModel = nodeDetailViewModel,
|
||||
compassViewModel = compassViewModel,
|
||||
navigateToMessages = onNavigateToMessages,
|
||||
onNavigate = { route -> backStack.add(route) },
|
||||
onNavigateUp = handleBack,
|
||||
)
|
||||
}
|
||||
} ?: EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CellTower
|
||||
import androidx.compose.material.icons.rounded.Groups
|
||||
import androidx.compose.material.icons.rounded.LightMode
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Memory
|
||||
import androidx.compose.material.icons.rounded.People
|
||||
import androidx.compose.material.icons.rounded.PermScanWifi
|
||||
import androidx.compose.material.icons.rounded.Power
|
||||
import androidx.compose.material.icons.rounded.Router
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.device
|
||||
import org.meshtastic.core.resources.environment
|
||||
import org.meshtastic.core.resources.host
|
||||
import org.meshtastic.core.resources.neighbor_info
|
||||
import org.meshtastic.core.resources.pax
|
||||
import org.meshtastic.core.resources.position_log
|
||||
import org.meshtastic.core.resources.power
|
||||
import org.meshtastic.core.resources.signal
|
||||
import org.meshtastic.core.resources.traceroute
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
|
||||
import org.meshtastic.feature.node.metrics.MetricsViewModel
|
||||
import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen
|
||||
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.PositionLogScreen
|
||||
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
|
||||
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
fun EntryProviderScope<NavKey>.nodesGraph(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit,
|
||||
) {
|
||||
entry<NodesRoutes.NodesGraph> {
|
||||
AdaptiveNodeListScreen(
|
||||
backStack = backStack,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<NodesRoutes.Nodes> {
|
||||
AdaptiveNodeListScreen(
|
||||
backStack = backStack,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
||||
)
|
||||
}
|
||||
|
||||
nodeDetailGraph(backStack, scrollToTopEvents, nodeMapScreen)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
fun EntryProviderScope<NavKey>.nodeDetailGraph(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit,
|
||||
) {
|
||||
entry<NodesRoutes.NodeDetailGraph> { args ->
|
||||
AdaptiveNodeListScreen(
|
||||
backStack = backStack,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
initialNodeId = args.destNum,
|
||||
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<NodesRoutes.NodeDetail> { args ->
|
||||
AdaptiveNodeListScreen(
|
||||
backStack = backStack,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
initialNodeId = args.destNum,
|
||||
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<NodeDetailRoutes.NodeMap> { args -> nodeMapScreen(args.destNum) { backStack.removeLastOrNull() } }
|
||||
|
||||
entry<NodeDetailRoutes.TracerouteLog> { args ->
|
||||
val metricsViewModel =
|
||||
koinViewModel<MetricsViewModel>(key = "metrics-${args.destNum}") { parametersOf(args.destNum) }
|
||||
metricsViewModel.setNodeId(args.destNum)
|
||||
|
||||
TracerouteLogScreen(
|
||||
viewModel = metricsViewModel,
|
||||
onNavigateUp = { backStack.removeLastOrNull() },
|
||||
onViewOnMap = { requestId, responseLogUuid ->
|
||||
backStack.add(
|
||||
NodeDetailRoutes.TracerouteMap(
|
||||
destNum = args.destNum,
|
||||
requestId = requestId,
|
||||
logUuid = responseLogUuid,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<NodeDetailRoutes.TracerouteMap> { args ->
|
||||
val metricsViewModel =
|
||||
koinViewModel<MetricsViewModel>(key = "metrics-${args.destNum}") { parametersOf(args.destNum) }
|
||||
metricsViewModel.setNodeId(args.destNum)
|
||||
|
||||
TracerouteMapScreen(
|
||||
metricsViewModel = metricsViewModel,
|
||||
requestId = args.requestId,
|
||||
logUuid = args.logUuid,
|
||||
onNavigateUp = { backStack.removeLastOrNull() },
|
||||
)
|
||||
}
|
||||
|
||||
NodeDetailRoute.entries.forEach { routeInfo ->
|
||||
when (routeInfo.routeClass) {
|
||||
NodeDetailRoutes.DeviceMetrics::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.DeviceMetrics>(backStack, routeInfo) { it.destNum }
|
||||
NodeDetailRoutes.PositionLog::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(backStack, routeInfo) { it.destNum }
|
||||
NodeDetailRoutes.EnvironmentMetrics::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(backStack, routeInfo) { it.destNum }
|
||||
NodeDetailRoutes.SignalMetrics::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(backStack, routeInfo) { it.destNum }
|
||||
NodeDetailRoutes.PowerMetrics::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(backStack, routeInfo) { it.destNum }
|
||||
NodeDetailRoutes.HostMetricsLog::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(backStack, routeInfo) { it.destNum }
|
||||
NodeDetailRoutes.PaxMetrics::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(backStack, routeInfo) { it.destNum }
|
||||
NodeDetailRoutes.NeighborInfoLog::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.NeighborInfoLog>(backStack, routeInfo) { it.destNum }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass }
|
||||
|
||||
private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailScreenComposable(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
routeInfo: NodeDetailRoute,
|
||||
crossinline getDestNum: (R) -> Int,
|
||||
) {
|
||||
entry<R> { args ->
|
||||
val destNum = getDestNum(args)
|
||||
val metricsViewModel = koinViewModel<MetricsViewModel>(key = "metrics-$destNum") { parametersOf(destNum) }
|
||||
metricsViewModel.setNodeId(destNum)
|
||||
|
||||
routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() }
|
||||
}
|
||||
}
|
||||
|
||||
enum class NodeDetailRoute(
|
||||
val title: StringResource,
|
||||
val routeClass: KClass<out Route>,
|
||||
val icon: ImageVector?,
|
||||
val screenComposable: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
|
||||
) {
|
||||
DEVICE(
|
||||
Res.string.device,
|
||||
NodeDetailRoutes.DeviceMetrics::class,
|
||||
Icons.Rounded.Router,
|
||||
{ metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) },
|
||||
),
|
||||
POSITION_LOG(
|
||||
Res.string.position_log,
|
||||
NodeDetailRoutes.PositionLog::class,
|
||||
Icons.Rounded.LocationOn,
|
||||
{ metricsVM, onNavigateUp -> PositionLogScreen(metricsVM, onNavigateUp) },
|
||||
),
|
||||
ENVIRONMENT(
|
||||
Res.string.environment,
|
||||
NodeDetailRoutes.EnvironmentMetrics::class,
|
||||
Icons.Rounded.LightMode,
|
||||
{ metricsVM, onNavigateUp -> EnvironmentMetricsScreen(metricsVM, onNavigateUp) },
|
||||
),
|
||||
SIGNAL(
|
||||
Res.string.signal,
|
||||
NodeDetailRoutes.SignalMetrics::class,
|
||||
Icons.Rounded.CellTower,
|
||||
{ metricsVM, onNavigateUp -> SignalMetricsScreen(metricsVM, onNavigateUp) },
|
||||
),
|
||||
TRACEROUTE(
|
||||
Res.string.traceroute,
|
||||
NodeDetailRoutes.TracerouteLog::class,
|
||||
Icons.Rounded.PermScanWifi,
|
||||
{ metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
|
||||
),
|
||||
NEIGHBOR_INFO(
|
||||
Res.string.neighbor_info,
|
||||
NodeDetailRoutes.NeighborInfoLog::class,
|
||||
Icons.Rounded.Groups,
|
||||
{ metricsVM, onNavigateUp -> NeighborInfoLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
|
||||
),
|
||||
POWER(
|
||||
Res.string.power,
|
||||
NodeDetailRoutes.PowerMetrics::class,
|
||||
Icons.Rounded.Power,
|
||||
{ metricsVM, onNavigateUp -> PowerMetricsScreen(metricsVM, onNavigateUp) },
|
||||
),
|
||||
HOST(
|
||||
Res.string.host,
|
||||
NodeDetailRoutes.HostMetricsLog::class,
|
||||
Icons.Rounded.Memory,
|
||||
{ metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) },
|
||||
),
|
||||
PAX(
|
||||
Res.string.pax,
|
||||
NodeDetailRoutes.PaxMetrics::class,
|
||||
Icons.Rounded.People,
|
||||
{ metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
|
||||
),
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue