Decouple ChannelScreen from UIViewModel (#3295)

This commit is contained in:
Phil Oliver 2025-10-02 14:25:47 -04:00 committed by GitHub
parent 309ec5a6b4
commit a5cd2d6bbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 157 additions and 65 deletions

View file

@ -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)
}

View file

@ -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 {

View file

@ -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 })
}
}