Decouple `ChannelScreen` from `UIViewModel` (#3295)

pull/3298/head
Phil Oliver 2025-10-02 14:25:47 -04:00 zatwierdzone przez GitHub
rodzic 309ec5a6b4
commit a5cd2d6bbc
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
6 zmienionych plików z 157 dodań i 65 usunięć

Wyświetl plik

@ -58,7 +58,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
@ -158,7 +157,6 @@ constructor(
private val serviceRepository: ServiceRepository,
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val quickChatActionRepository: QuickChatActionRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
@ -307,11 +305,6 @@ constructor(
val connectionState
get() = serviceRepository.connectionState
val isConnectedStateFlow =
serviceRepository.connectionState
.map { it.isConnected() }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>
get() = _requestChannelSet
@ -329,22 +322,12 @@ constructor(
_requestChannelSet.value = null
}
var txEnabled: Boolean
get() = config.lora.txEnabled
set(value) {
updateLoraConfig { it.copy { txEnabled = value } }
}
var region: Config.LoRaConfig.RegionCode
get() = config.lora.region
set(value) {
updateLoraConfig { it.copy { region = value } }
}
// managed mode disables all access to configuration
val isManaged: Boolean
get() = config.device.isManaged || config.security.isManaged
override fun onCleared() {
super.onCleared()
Timber.d("ViewModel cleared")

Wyświetl plik

@ -24,7 +24,6 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.settings.radio.components.ChannelConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.LoRaConfigScreen
import com.geeksville.mesh.ui.sharing.ChannelScreen
@ -32,14 +31,13 @@ import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
/** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */
fun NavGraphBuilder.channelsGraph(navController: NavHostController, uiViewModel: UIViewModel) {
fun NavGraphBuilder.channelsGraph(navController: NavHostController) {
navigation<ChannelsRoutes.ChannelsGraph>(startDestination = ChannelsRoutes.Channels) {
composable<ChannelsRoutes.Channels>(
deepLinks = listOf(navDeepLink<ChannelsRoutes.Channels>(basePath = "$DEEP_LINK_BASE_URI/channels")),
) { backStackEntry ->
val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(ChannelsRoutes.ChannelsGraph) }
ChannelScreen(
viewModel = uiViewModel,
radioConfigViewModel = hiltViewModel(parentEntry),
onNavigate = { route -> navController.navigate(route) },
)

Wyświetl plik

@ -40,7 +40,6 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.metrics.DeviceMetricsScreen
import com.geeksville.mesh.ui.metrics.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.metrics.HostMetricsLogScreen
@ -59,19 +58,19 @@ import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.R
fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UIViewModel) {
fun NavGraphBuilder.nodesGraph(navController: NavHostController) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
composable<NodesRoutes.Nodes>(
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
) {
NodeListScreen(navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) })
}
nodeDetailGraph(navController, uiViewModel)
nodeDetailGraph(navController)
}
}
@Suppress("LongMethod")
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewModel: UIViewModel) {
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
navigation<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
composable<NodesRoutes.NodeDetail>(
deepLinks =
@ -96,63 +95,54 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewMode
is NodeDetailRoutes.DeviceMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.DeviceMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.NodeMap ->
addNodeDetailScreenComposable<NodeDetailRoutes.NodeMap>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PositionLog ->
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.EnvironmentMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.SignalMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PowerMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.TracerouteLog ->
addNodeDetailScreenComposable<NodeDetailRoutes.TracerouteLog>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.HostMetricsLog ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PaxMetrics ->
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(
navController,
uiViewModel,
entry,
entry.screenComposable,
)
@ -174,21 +164,15 @@ fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any {
*
* @param R The type of the [Route] object, must be serializable.
* @param navController The [NavHostController] for navigation.
* @param uiViewModel The shared [UIViewModel], passed to the [screenContent].
* @param routeInfo The [NodeDetailRoute] enum entry that defines the path and metadata for this route.
* @param screenContent A lambda that defines the composable content for the screen. It receives the shared
* [MetricsViewModel] and the [UIViewModel].
* [MetricsViewModel].
*/
private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenComposable(
navController: NavHostController,
uiViewModel: UIViewModel,
routeInfo: NodeDetailRoute,
crossinline screenContent:
@Composable (
navController: NavHostController,
metricsViewModel: MetricsViewModel,
passedUiViewModel: UIViewModel,
) -> Unit,
@Composable (navController: NavHostController, metricsViewModel: MetricsViewModel) -> Unit,
) {
composable<R>(
deepLinks =
@ -200,7 +184,7 @@ private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenCompos
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
screenContent(navController, metricsViewModel, uiViewModel)
screenContent(navController, metricsViewModel)
}
}
@ -208,65 +192,60 @@ enum class NodeDetailRoute(
@StringRes val title: Int,
val route: Route,
val icon: ImageVector?,
val screenComposable:
@Composable (
navController: NavHostController,
metricsViewModel: MetricsViewModel,
uiViewModel: UIViewModel,
) -> Unit,
val screenComposable: @Composable (navController: NavHostController, metricsViewModel: MetricsViewModel) -> Unit,
) {
DEVICE(
R.string.device,
NodeDetailRoutes.DeviceMetrics,
Icons.Default.Router,
{ _, metricsVM, _ -> DeviceMetricsScreen(metricsVM) },
{ _, metricsVM -> DeviceMetricsScreen(metricsVM) },
),
NODE_MAP(
R.string.node_map,
NodeDetailRoutes.NodeMap,
Icons.Default.LocationOn,
{ navController, metricsVM, _ -> NodeMapScreen(navController, metricsVM) },
{ navController, metricsVM -> NodeMapScreen(navController, metricsVM) },
),
POSITION_LOG(
R.string.position_log,
NodeDetailRoutes.PositionLog,
Icons.Default.LocationOn,
{ _, metricsVM, _ -> PositionLogScreen(metricsVM) },
{ _, metricsVM -> PositionLogScreen(metricsVM) },
),
ENVIRONMENT(
R.string.environment,
NodeDetailRoutes.EnvironmentMetrics,
Icons.Default.LightMode,
{ _, metricsVM, _ -> EnvironmentMetricsScreen(metricsVM) },
{ _, metricsVM -> EnvironmentMetricsScreen(metricsVM) },
),
SIGNAL(
R.string.signal,
NodeDetailRoutes.SignalMetrics,
Icons.Default.CellTower,
{ _, metricsVM, _ -> SignalMetricsScreen(metricsVM) },
{ _, metricsVM -> SignalMetricsScreen(metricsVM) },
),
TRACEROUTE(
R.string.traceroute,
NodeDetailRoutes.TracerouteLog,
Icons.Default.PermScanWifi,
{ _, metricsVM, _ -> TracerouteLogScreen(viewModel = metricsVM) },
{ _, metricsVM -> TracerouteLogScreen(viewModel = metricsVM) },
),
POWER(
R.string.power,
NodeDetailRoutes.PowerMetrics,
Icons.Default.Power,
{ _, metricsVM, _ -> PowerMetricsScreen(metricsVM) },
{ _, metricsVM -> PowerMetricsScreen(metricsVM) },
),
HOST(
R.string.host,
NodeDetailRoutes.HostMetricsLog,
Icons.Default.Memory,
{ _, metricsVM, _ -> HostMetricsLogScreen(metricsVM) },
{ _, metricsVM -> HostMetricsLogScreen(metricsVM) },
),
PAX(
R.string.pax,
NodeDetailRoutes.PaxMetrics,
Icons.Default.People,
{ _, metricsVM, _ -> PaxMetricsScreen(metricsVM) },
{ _, metricsVM -> PaxMetricsScreen(metricsVM) },
),
}

Wyświetl plik

@ -390,9 +390,9 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding().imePadding(),
) {
contactsGraph(navController)
nodesGraph(navController, uiViewModel = uIViewModel)
nodesGraph(navController)
mapGraph(navController)
channelsGraph(navController, uiViewModel = uIViewModel)
channelsGraph(navController)
connectionsGraph(navController)
settingsGraph(navController)
}

Wyświetl plik

@ -21,6 +21,7 @@ import android.Manifest
import android.content.ClipData
import android.net.Uri
import android.os.RemoteException
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
@ -74,6 +75,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -92,7 +94,6 @@ import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.MeshUtilApplication.Companion.analytics
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
@ -124,7 +125,7 @@ import timber.log.Timber
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun ChannelScreen(
viewModel: UIViewModel = hiltViewModel(),
viewModel: ChannelViewModel = hiltViewModel(),
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
onNavigate: (Route) -> Unit,
) {
@ -175,10 +176,13 @@ fun ChannelScreen(
settings.addAll(result)
}
val context = LocalContext.current
val barcodeLauncher =
rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
viewModel.requestChannelUrl(result.contents.toUri())
viewModel.requestChannelUrl(result.contents.toUri()) {
Toast.makeText(context, R.string.channel_invalid, Toast.LENGTH_SHORT).show()
}
}
}
@ -210,12 +214,12 @@ fun ChannelScreen(
viewModel.setChannels(newChannelSet)
// Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc)
} catch (ex: RemoteException) {
Timber.e("ignoring channel problem", ex)
Timber.e(ex, "ignoring channel problem")
channelSet = channels // Throw away user edits
// Tell the user to try again
viewModel.showSnackBar(R.string.cant_change_no_radio)
Toast.makeText(context, R.string.cant_change_no_radio, Toast.LENGTH_SHORT).show()
}
}
@ -282,7 +286,11 @@ fun ChannelScreen(
EditChannelUrl(
enabled = enabled,
channelUrl = selectedChannelSet.getChannelUrl(shouldAdd = shouldAddChannelsState),
onConfirm = viewModel::requestChannelUrl,
onConfirm = {
viewModel.requestChannelUrl(it) {
Toast.makeText(context, R.string.channel_invalid, Toast.LENGTH_SHORT).show()
}
},
)
}
item {

Wyświetl plik

@ -0,0 +1,124 @@
/*
* 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.ui.sharing
import android.net.Uri
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.getChannelList
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class ChannelViewModel
@Inject
constructor(
private val serviceRepository: ServiceRepository,
private val radioConfigRepository: RadioConfigRepository,
) : ViewModel() {
val connectionState = serviceRepository.connectionState
val localConfig =
radioConfigRepository.localConfigFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
LocalConfig.getDefaultInstance(),
)
val channels =
radioConfigRepository.channelSetFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
channelSet {},
)
// managed mode disables all access to configuration
val isManaged: Boolean
get() = localConfig.value.device.isManaged || localConfig.value.security.isManaged
var txEnabled: Boolean
get() = localConfig.value.lora.txEnabled
set(value) {
updateLoraConfig { it.copy { txEnabled = value } }
}
var region: Config.LoRaConfig.RegionCode
get() = localConfig.value.lora.region
set(value) {
updateLoraConfig { it.copy { region = value } }
}
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>
get() = _requestChannelSet
fun requestChannelUrl(url: Uri, onError: () -> Unit) = runCatching { _requestChannelSet.value = url.toChannelSet() }
.onFailure { ex ->
Timber.e(ex, "Channel url error")
onError()
}
/** Set the radio config (also updates our saved copy in preferences). */
fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch {
getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(channelSet.settingsList)
val newConfig = config { lora = channelSet.loraConfig }
if (localConfig.value.lora != newConfig.lora) setConfig(newConfig)
}
fun setChannel(channel: ChannelProtos.Channel) {
try {
serviceRepository.meshService?.setChannel(channel.toByteArray())
} catch (ex: RemoteException) {
Timber.e(ex, "Set channel error")
}
}
// Set the radio config (also updates our saved copy in preferences)
fun setConfig(config: Config) {
try {
serviceRepository.meshService?.setConfig(config.toByteArray())
} catch (ex: RemoteException) {
Timber.e(ex, "Set config error")
}
}
private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {
val data = body(localConfig.value.lora)
setConfig(config { lora = data })
}
}