refactor: migrate to Compose navigation (#1835)

Co-authored-by: andrekir <andrekir@pm.me>
This commit is contained in:
James Rich 2025-05-15 08:05:30 -05:00 committed by GitHub
parent 79c77ab1d5
commit 8cde47bdf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 2576 additions and 3427 deletions

View file

@ -15,109 +15,306 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/*
* Copyright (c) 2025 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 com.geeksville.mesh.navigation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.ui.ScreenFragment
import com.geeksville.mesh.ui.components.BaseScaffold
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
import com.geeksville.mesh.ui.theme.AppTheme
import dagger.hilt.android.AndroidEntryPoint
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.ChannelScreen
import com.geeksville.mesh.ui.ContactsScreen
import com.geeksville.mesh.ui.DebugScreen
import com.geeksville.mesh.ui.NodeScreen
import com.geeksville.mesh.ui.QuickChatScreen
import com.geeksville.mesh.ui.SettingsScreen
import com.geeksville.mesh.ui.ShareScreen
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
import com.geeksville.mesh.ui.map.MapView
import com.geeksville.mesh.ui.message.MessageScreen
import kotlinx.serialization.Serializable
internal fun FragmentManager.navigateToNavGraph(
destNum: Int? = null,
startDestination: String = "RadioConfig",
) {
val radioConfigFragment = NavGraphFragment().apply {
arguments = bundleOf("destNum" to destNum, "startDestination" to startDestination)
}
beginTransaction()
.replace(R.id.mainActivityLayout, radioConfigFragment)
.addToBackStack(null)
.commit()
enum class AdminRoute(@StringRes val title: Int) {
REBOOT(R.string.reboot),
SHUTDOWN(R.string.shutdown),
FACTORY_RESET(R.string.factory_reset),
NODEDB_RESET(R.string.nodedb_reset),
}
@AndroidEntryPoint
class NavGraphFragment : ScreenFragment("NavGraph"), Logging {
const val DEEP_LINK_BASE_URI = "meshtastic://meshtastic"
private val model: RadioConfigViewModel by viewModels()
@Serializable
sealed interface Graph : Route {
@Serializable
data class NodeDetailGraph(val destNum: Int) : Graph
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
@Suppress("DEPRECATION")
val destNum = arguments?.getSerializable("destNum") as? Int
val startDestination: Any = when (arguments?.getString("startDestination")) {
"NodeDetails" -> Route.NodeDetail(destNum!!)
else -> Route.RadioConfig(destNum)
@Serializable
data class RadioConfigGraph(val destNum: Int? = null) : Graph
}
@Serializable
sealed interface Route {
@Serializable
data object Contacts : Route
@Serializable
data object Nodes : Route
@Serializable
data object Map : Route
@Serializable
data object Channels : Route
@Serializable
data object Settings : Route
@Serializable
data object DebugPanel : Route
@Serializable
data class Messages(val contactKey: String, val message: String = "") : Route
@Serializable
data object QuickChat : Route
@Serializable
data class Share(val message: String) : Route
@Serializable
data class RadioConfig(val destNum: Int? = null) : Route
@Serializable
data object User : Route
@Serializable
data object ChannelConfig : Route
@Serializable
data object Device : Route
@Serializable
data object Position : Route
@Serializable
data object Power : Route
@Serializable
data object Network : Route
@Serializable
data object Display : Route
@Serializable
data object LoRa : Route
@Serializable
data object Bluetooth : Route
@Serializable
data object Security : Route
@Serializable
data object MQTT : Route
@Serializable
data object Serial : Route
@Serializable
data object ExtNotification : Route
@Serializable
data object StoreForward : Route
@Serializable
data object RangeTest : Route
@Serializable
data object Telemetry : Route
@Serializable
data object CannedMessage : Route
@Serializable
data object Audio : Route
@Serializable
data object RemoteHardware : Route
@Serializable
data object NeighborInfo : Route
@Serializable
data object AmbientLighting : Route
@Serializable
data object DetectionSensor : Route
@Serializable
data object Paxcounter : Route
@Serializable
data class NodeDetail(val destNum: Int? = null) : Route
@Serializable
data object DeviceMetrics : Route
@Serializable
data object NodeMap : Route
@Serializable
data object PositionLog : Route
@Serializable
data object EnvironmentMetrics : Route
@Serializable
data object SignalMetrics : Route
@Serializable
data object PowerMetrics : Route
@Serializable
data object TracerouteLog : Route
}
fun NavDestination.isConfigRoute(): Boolean {
return ConfigRoute.entries.any { hasRoute(it.route::class) } ||
ModuleRoute.entries.any { hasRoute(it.route::class) }
}
fun NavDestination.isNodeDetailRoute(): Boolean {
return NodeDetailRoute.entries.any { hasRoute(it.route::class) }
}
fun NavDestination.showLongNameTitle(): Boolean {
return !this.isTopLevel() && (
this.hasRoute<Route.Messages>() ||
this.hasRoute<Route.RadioConfig>() ||
this.hasRoute<Route.NodeDetail>() ||
this.isConfigRoute() ||
this.isNodeDetailRoute()
)
}
@Suppress("LongMethod")
@Composable
fun NavGraph(
modifier: Modifier = Modifier,
uIViewModel: UIViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
) {
NavHost(
navController = navController,
startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) {
Route.Settings
} else {
Route.Contacts
},
modifier = modifier,
) {
composable<Route.Contacts> {
ContactsScreen(
uIViewModel,
onNavigate = { navController.navigate(Route.Messages(it)) }
)
}
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val node by model.destNode.collectAsStateWithLifecycle()
AppTheme {
val navController: NavHostController = rememberNavController()
BaseScaffold(
title = node?.user?.longName
?: stringResource(R.string.unknown_username),
canNavigateBack = true,
navigateUp = {
if (navController.previousBackStackEntry != null) {
navController.navigateUp()
} else {
parentFragmentManager.popBackStack()
}
},
) {
NavGraph(
navController = navController,
startDestination = startDestination,
)
composable<Route.Nodes> {
NodeScreen(
model = uIViewModel,
navigateToMessages = { navController.navigate(Route.Messages(it)) },
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
)
}
composable<Route.Map> {
MapView(uIViewModel)
}
composable<Route.Channels> {
ChannelScreen(uIViewModel)
}
composable<Route.Settings>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/settings"
action = "android.intent.action.VIEW"
}
)
) { backStackEntry ->
SettingsScreen {
navController.navigate(Route.RadioConfig()) {
popUpTo(Route.Settings) {
inclusive = false
}
}
}
}
composable<Route.DebugPanel> {
DebugScreen()
}
composable<Route.Messages>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}"
action = "android.intent.action.VIEW"
},
)
) { backStackEntry ->
val args = backStackEntry.toRoute<Route.Messages>()
MessageScreen(
contactKey = args.contactKey,
message = args.message,
viewModel = uIViewModel,
navigateToMessages = { navController.navigate(Route.Messages(it)) },
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
onNavigateBack = navController::navigateUp
)
}
composable<Route.QuickChat> {
QuickChatScreen()
}
nodeDetailGraph(navController, uIViewModel)
radioConfigGraph(navController, uIViewModel)
composable<Route.Share>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}"
action = "android.intent.action.VIEW"
}
)
) { backStackEntry ->
val message = backStackEntry.toRoute<Route.Share>().message
ShareScreen(uIViewModel) {
navController.navigate(Route.Messages(it, message)) {
popUpTo<Route.Share> { inclusive = true }
}
}
}
}
}
@Composable
fun NavGraph(
navController: NavHostController = rememberNavController(),
startDestination: Any,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
addNodDetailSection(navController)
addRadioConfigSection(navController)
shareScreen(
navigateUp = navController::navigateUp,
onConfirm = navController::navigateToSharedMessage,
)
}
}