Skip to content

Commit 78274c7

Browse files
authored
feat(navigation): Implement adaptive list-detail for contacts and nodes (#3850)
Signed-off-by: James Rich <[email protected]>
1 parent d60e84f commit 78274c7

File tree

19 files changed

+633
-225
lines changed

19 files changed

+633
-225
lines changed

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ dependencies {
219219
implementation(projects.feature.firmware)
220220

221221
implementation(libs.androidx.compose.material3.adaptive)
222+
implementation(libs.androidx.compose.material3.adaptive.layout)
223+
implementation(libs.androidx.compose.material3.adaptive.navigation)
222224
implementation(libs.androidx.compose.material3.navigationSuite)
223225
implementation(libs.material)
224226
implementation(libs.androidx.compose.material3)

app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,12 @@ import androidx.navigation.compose.composable
2323
import androidx.navigation.navDeepLink
2424
import androidx.navigation.navigation
2525
import androidx.navigation.toRoute
26-
import com.geeksville.mesh.ui.contact.ContactsScreen
26+
import com.geeksville.mesh.ui.contact.AdaptiveContactsScreen
2727
import com.geeksville.mesh.ui.sharing.ShareScreen
2828
import kotlinx.coroutines.flow.Flow
29-
import org.meshtastic.core.navigation.ChannelsRoutes
3029
import org.meshtastic.core.navigation.ContactsRoutes
3130
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
32-
import org.meshtastic.core.navigation.NodesRoutes
3331
import org.meshtastic.core.ui.component.ScrollToTopEvent
34-
import org.meshtastic.feature.messaging.MessageScreen
3532
import org.meshtastic.feature.messaging.QuickChatScreen
3633

3734
@Suppress("LongMethod")
@@ -40,18 +37,7 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
4037
composable<ContactsRoutes.Contacts>(
4138
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
4239
) {
43-
ContactsScreen(
44-
onClickNodeChip = {
45-
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
46-
launchSingleTop = true
47-
restoreState = true
48-
}
49-
},
50-
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
51-
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
52-
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
53-
scrollToTopEvents = scrollToTopEvents,
54-
)
40+
AdaptiveContactsScreen(navController = navController, scrollToTopEvents = scrollToTopEvents)
5541
}
5642
composable<ContactsRoutes.Messages>(
5743
deepLinks =
@@ -63,13 +49,11 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
6349
),
6450
) { backStackEntry ->
6551
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
66-
MessageScreen(
67-
contactKey = args.contactKey,
68-
message = args.message,
69-
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
70-
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
71-
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
72-
onNavigateBack = navController::navigateUp,
52+
AdaptiveContactsScreen(
53+
navController = navController,
54+
scrollToTopEvents = scrollToTopEvents,
55+
initialContactKey = args.contactKey,
56+
initialMessage = args.message,
7357
)
7458
}
7559
}

app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt

Lines changed: 71 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,22 @@ import androidx.compose.runtime.Composable
3030
import androidx.compose.runtime.remember
3131
import androidx.compose.ui.graphics.vector.ImageVector
3232
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
33-
import androidx.navigation.NavBackStackEntry
3433
import androidx.navigation.NavDestination
3534
import androidx.navigation.NavDestination.Companion.hasRoute
3635
import androidx.navigation.NavGraphBuilder
3736
import androidx.navigation.NavHostController
3837
import androidx.navigation.compose.composable
3938
import androidx.navigation.compose.navigation
4039
import androidx.navigation.navDeepLink
40+
import androidx.navigation.toRoute
41+
import com.geeksville.mesh.ui.node.AdaptiveNodeListScreen
4142
import kotlinx.coroutines.flow.Flow
4243
import org.jetbrains.compose.resources.StringResource
4344
import org.meshtastic.core.navigation.ContactsRoutes
4445
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
4546
import org.meshtastic.core.navigation.NodeDetailRoutes
4647
import org.meshtastic.core.navigation.NodesRoutes
4748
import org.meshtastic.core.navigation.Route
48-
import org.meshtastic.core.strings.R
4949
import org.meshtastic.core.strings.Res
5050
import org.meshtastic.core.strings.device
5151
import org.meshtastic.core.strings.environment
@@ -58,8 +58,6 @@ import org.meshtastic.core.strings.traceroute
5858
import org.meshtastic.core.ui.component.ScrollToTopEvent
5959
import org.meshtastic.feature.map.node.NodeMapScreen
6060
import org.meshtastic.feature.map.node.NodeMapViewModel
61-
import org.meshtastic.feature.node.detail.NodeDetailScreen
62-
import org.meshtastic.feature.node.list.NodeListScreen
6361
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
6462
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
6563
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
@@ -69,23 +67,27 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
6967
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
7068
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
7169
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
70+
import kotlin.reflect.KClass
7271

7372
fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
7473
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
7574
composable<NodesRoutes.Nodes>(
7675
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
7776
) {
78-
NodeListScreen(
79-
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
77+
AdaptiveNodeListScreen(
78+
navController = navController,
8079
scrollToTopEvents = scrollToTopEvents,
80+
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
8181
)
8282
}
83-
nodeDetailGraph(navController)
83+
nodeDetailGraph(navController, scrollToTopEvents)
8484
}
8585
}
8686

8787
@Suppress("LongMethod")
88-
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
88+
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
89+
// We keep this route for deep linking or direct navigation to details,
90+
// but typically users will navigate via the Adaptive screen in NodesRoutes.Nodes
8991
navigation<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
9092
composable<NodesRoutes.NodeDetail>(
9193
deepLinks =
@@ -95,13 +97,14 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
9597
),
9698
),
9799
) { backStackEntry ->
98-
val parentEntry =
99-
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
100-
NodeDetailScreen(
101-
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
102-
onNavigate = { navController.navigate(it) },
103-
onNavigateUp = { navController.navigateUp() },
104-
viewModel = hiltViewModel(parentEntry),
100+
val args = backStackEntry.toRoute<NodesRoutes.NodeDetail>()
101+
// When navigating directly to NodeDetail (e.g. from Map or deep link),
102+
// we use the Adaptive screen initialized with the specific node ID.
103+
AdaptiveNodeListScreen(
104+
navController = navController,
105+
scrollToTopEvents = scrollToTopEvents,
106+
initialNodeId = args.destNum,
107+
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
105108
)
106109
}
107110

@@ -114,88 +117,98 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
114117
) { backStackEntry ->
115118
val parentGraphBackStackEntry =
116119
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
117-
NodeMapScreen(
118-
hiltViewModel<NodeMapViewModel>(parentGraphBackStackEntry),
119-
onNavigateUp = navController::navigateUp,
120-
)
120+
val vm = hiltViewModel<NodeMapViewModel>(parentGraphBackStackEntry)
121+
NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
121122
}
122123

123124
NodeDetailRoute.entries.forEach { entry ->
124-
when (entry.route) {
125-
is NodeDetailRoutes.DeviceMetrics ->
125+
when (entry.routeClass) {
126+
NodeDetailRoutes.DeviceMetrics::class ->
126127
addNodeDetailScreenComposable<NodeDetailRoutes.DeviceMetrics>(
127128
navController,
128129
entry,
129130
entry.screenComposable,
130-
)
131-
is NodeDetailRoutes.PositionLog ->
131+
) {
132+
it.destNum
133+
}
134+
NodeDetailRoutes.PositionLog::class ->
132135
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(
133136
navController,
134137
entry,
135138
entry.screenComposable,
136-
)
137-
is NodeDetailRoutes.EnvironmentMetrics ->
139+
) {
140+
it.destNum
141+
}
142+
NodeDetailRoutes.EnvironmentMetrics::class ->
138143
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(
139144
navController,
140145
entry,
141146
entry.screenComposable,
142-
)
143-
is NodeDetailRoutes.SignalMetrics ->
147+
) {
148+
it.destNum
149+
}
150+
NodeDetailRoutes.SignalMetrics::class ->
144151
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(
145152
navController,
146153
entry,
147154
entry.screenComposable,
148-
)
149-
is NodeDetailRoutes.PowerMetrics ->
155+
) {
156+
it.destNum
157+
}
158+
NodeDetailRoutes.PowerMetrics::class ->
150159
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(
151160
navController,
152161
entry,
153162
entry.screenComposable,
154-
)
155-
is NodeDetailRoutes.TracerouteLog ->
163+
) {
164+
it.destNum
165+
}
166+
NodeDetailRoutes.TracerouteLog::class ->
156167
addNodeDetailScreenComposable<NodeDetailRoutes.TracerouteLog>(
157168
navController,
158169
entry,
159170
entry.screenComposable,
160-
)
161-
is NodeDetailRoutes.HostMetricsLog ->
171+
) {
172+
it.destNum
173+
}
174+
NodeDetailRoutes.HostMetricsLog::class ->
162175
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
163176
navController,
164177
entry,
165178
entry.screenComposable,
166-
)
167-
is NodeDetailRoutes.PaxMetrics ->
179+
) {
180+
it.destNum
181+
}
182+
NodeDetailRoutes.PaxMetrics::class ->
168183
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(
169184
navController,
170185
entry,
171186
entry.screenComposable,
172-
)
187+
) {
188+
it.destNum
189+
}
173190
else -> Unit
174191
}
175192
}
176193
}
177194
}
178195

179-
fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.route::class) }
196+
fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.routeClass) }
180197

181198
/**
182199
* Helper to define a composable route for a screen within the node detail graph.
183200
*
184-
* This function simplifies adding screens by handling common tasks like:
185-
* - Setting up deep links based on the [NodeDetailRoute] definition.
186-
* - Retrieving the parent [NavBackStackEntry] for the [NodesRoutes.NodeDetailGraph].
187-
* - Providing the [MetricsViewModel] scoped to the parent graph.
188-
*
189201
* @param R The type of the [Route] object, must be serializable.
190202
* @param navController The [NavHostController] for navigation.
191203
* @param routeInfo The [NodeDetailRoute] enum entry that defines the path and metadata for this route.
192-
* @param screenContent A lambda that defines the composable content for the screen. It receives the shared
193-
* [MetricsViewModel].
204+
* @param screenContent A lambda that defines the composable content for the screen.
205+
* @param getDestNum A lambda to extract the destination number from the route arguments.
194206
*/
195207
private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenComposable(
196208
navController: NavHostController,
197209
routeInfo: NodeDetailRoute,
198210
crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
211+
crossinline getDestNum: (R) -> Int,
199212
) {
200213
composable<R>(
201214
deepLinks =
@@ -207,61 +220,66 @@ private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenCompos
207220
val parentGraphBackStackEntry =
208221
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
209222
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
223+
224+
val args = backStackEntry.toRoute<R>()
225+
val destNum = getDestNum(args)
226+
metricsViewModel.setNodeId(destNum)
227+
210228
screenContent(metricsViewModel, navController::navigateUp)
211229
}
212230
}
213231

214232
enum class NodeDetailRoute(
215233
val title: StringResource,
216-
val route: Route,
234+
val routeClass: KClass<out Route>,
217235
val icon: ImageVector?,
218236
val screenComposable: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
219237
) {
220238
DEVICE(
221239
Res.string.device,
222-
NodeDetailRoutes.DeviceMetrics,
240+
NodeDetailRoutes.DeviceMetrics::class,
223241
Icons.Default.Router,
224242
{ metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) },
225243
),
226244
POSITION_LOG(
227245
Res.string.position_log,
228-
NodeDetailRoutes.PositionLog,
246+
NodeDetailRoutes.PositionLog::class,
229247
Icons.Default.LocationOn,
230248
{ metricsVM, onNavigateUp -> PositionLogScreen(metricsVM, onNavigateUp) },
231249
),
232250
ENVIRONMENT(
233251
Res.string.environment,
234-
NodeDetailRoutes.EnvironmentMetrics,
252+
NodeDetailRoutes.EnvironmentMetrics::class,
235253
Icons.Default.LightMode,
236254
{ metricsVM, onNavigateUp -> EnvironmentMetricsScreen(metricsVM, onNavigateUp) },
237255
),
238256
SIGNAL(
239257
Res.string.signal,
240-
NodeDetailRoutes.SignalMetrics,
258+
NodeDetailRoutes.SignalMetrics::class,
241259
Icons.Default.CellTower,
242260
{ metricsVM, onNavigateUp -> SignalMetricsScreen(metricsVM, onNavigateUp) },
243261
),
244262
TRACEROUTE(
245263
Res.string.traceroute,
246-
NodeDetailRoutes.TracerouteLog,
264+
NodeDetailRoutes.TracerouteLog::class,
247265
Icons.Default.PermScanWifi,
248266
{ metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
249267
),
250268
POWER(
251269
Res.string.power,
252-
NodeDetailRoutes.PowerMetrics,
270+
NodeDetailRoutes.PowerMetrics::class,
253271
Icons.Default.Power,
254272
{ metricsVM, onNavigateUp -> PowerMetricsScreen(metricsVM, onNavigateUp) },
255273
),
256274
HOST(
257275
Res.string.host,
258-
NodeDetailRoutes.HostMetricsLog,
276+
NodeDetailRoutes.HostMetricsLog::class,
259277
Icons.Default.Memory,
260278
{ metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) },
261279
),
262280
PAX(
263281
Res.string.pax,
264-
NodeDetailRoutes.PaxMetrics,
282+
NodeDetailRoutes.PaxMetrics::class,
265283
Icons.Default.People,
266284
{ metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
267285
),

0 commit comments

Comments
 (0)