Decouple SettingsScreen from UiViewModel (#3137)

This commit is contained in:
Phil Oliver 2025-09-18 07:40:33 -04:00 committed by GitHub
parent 48da34ce1a
commit eedc3ef963
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 364 additions and 251 deletions

View file

@ -397,7 +397,7 @@ fun MainScreen(
mapGraph(navController, uiViewModel = uIViewModel)
channelsGraph(navController, uiViewModel = uIViewModel)
connectionsGraph(navController, bluetoothViewModel)
settingsGraph(navController, uiViewModel = uIViewModel)
settingsGraph(navController)
}
}
}

View file

@ -40,7 +40,6 @@ import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -55,12 +54,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog
import com.geeksville.mesh.ui.common.components.TitledCard
import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC
import com.geeksville.mesh.ui.node.components.NodeMenuAction
@ -84,15 +82,15 @@ import kotlin.time.Duration.Companion.seconds
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun SettingsScreen(
settingsViewModel: SettingsViewModel = hiltViewModel(),
viewModel: RadioConfigViewModel = hiltViewModel(),
uiViewModel: UIViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigate: (Route) -> Unit = {},
) {
val excludedModulesUnlocked by uiViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by uiViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
@ -166,6 +164,19 @@ fun SettingsScreen(
)
}
var showLanguagePickerDialog by remember { mutableStateOf(false) }
if (showLanguagePickerDialog) {
LanguagePickerDialog { showLanguagePickerDialog = false }
}
var showThemePickerDialog by remember { mutableStateOf(false) }
if (showThemePickerDialog) {
ThemePickerDialog(
onClickTheme = { settingsViewModel.setTheme(it) },
onDismiss = { showThemePickerDialog = false },
)
}
Scaffold(
topBar = {
MainAppBar(
@ -227,22 +238,27 @@ fun SettingsScreen(
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
val provideLocation by uiViewModel.provideLocation.collectAsState(false)
val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
uiViewModel.meshService?.startProvideLocation()
settingsViewModel.meshService?.startProvideLocation()
} else {
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
Toast.makeText(
context,
context.getString(R.string.location_disabled),
Toast.LENGTH_LONG,
)
.show()
}
} else {
// Request permissions if not granted and user wants to provide location
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
uiViewModel.meshService?.stopProvideLocation()
settingsViewModel.meshService?.stopProvideLocation()
}
}
@ -252,51 +268,30 @@ fun SettingsScreen(
enabled = !isGpsDisabled,
checked = provideLocation,
) {
uiViewModel.setProvideLocation(!provideLocation)
settingsViewModel.setProvideLocation(!provideLocation)
}
val languageTags = remember { LanguageUtils.getLanguageTags(context) }
SettingsItem(
text = stringResource(R.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = null,
) {
val lang = LanguageUtils.getLocale()
debug("Lang from prefs: $lang")
val langMap = languageTags.mapValues { (_, value) -> { LanguageUtils.setLocale(value) } }
uiViewModel.showAlert(
title = context.getString(R.string.preferences_language),
message = "",
choices = langMap,
)
showLanguagePickerDialog = true
}
val themeMap = remember {
mapOf(
context.getString(R.string.dynamic) to MODE_DYNAMIC,
context.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO,
context.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES,
context.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
)
}
SettingsItem(
text = stringResource(R.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
uiViewModel.showAlert(
title = context.getString(R.string.choose_theme),
message = "",
choices = themeMap.mapValues { (_, value) -> { uiViewModel.setTheme(value) } },
)
showThemePickerDialog = true
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> uiViewModel.saveDataCsv(uri) }
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
SettingsItem(
@ -316,7 +311,7 @@ fun SettingsScreen(
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> uiViewModel.saveDataCsv(uri) }
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
SettingsItem(
@ -338,10 +333,10 @@ fun SettingsScreen(
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
uiViewModel.showAppIntro()
settingsViewModel.showAppIntro()
}
AppVersionButton(excludedModulesUnlocked) { uiViewModel.unlockExcludedModules() }
AppVersionButton(excludedModulesUnlocked) { settingsViewModel.unlockExcludedModules() }
}
}
}
@ -384,3 +379,39 @@ private fun AppVersionButton(excludedModulesUnlocked: Boolean, onUnlockExcludedM
}
}
}
@Composable
private fun LanguagePickerDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val languages = remember {
LanguageUtils.getLanguageTags(context).mapValues { (_, value) -> { LanguageUtils.setLocale(value) } }
}
MultipleChoiceAlertDialog(
title = stringResource(R.string.preferences_language),
message = "",
choices = languages,
onDismissRequest = onDismiss,
)
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
val context = LocalContext.current
val themeMap = remember {
mapOf(
context.getString(R.string.dynamic) to MODE_DYNAMIC,
context.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO,
context.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES,
context.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
)
.mapValues { (_, value) -> { onClickTheme(value) } }
}
MultipleChoiceAlertDialog(
title = stringResource(R.string.choose_theme),
message = "",
choices = themeMap,
onDismissRequest = onDismiss,
)
}

View file

@ -0,0 +1,256 @@
/*
* 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.settings
import android.app.Application
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.Position
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.prefs.UiPrefs
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.util.positionToMeter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
private val app: Application,
private val radioConfigRepository: RadioConfigRepository,
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val uiPrefs: UiPrefs,
) : ViewModel(),
Logging {
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
val isConnected =
radioConfigRepository.connectionState
.map { it.isConnected() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false)
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000L),
LocalConfig.getDefaultInstance(),
)
val meshService: IMeshService?
get() = radioConfigRepository.meshService
val provideLocation: StateFlow<Boolean> =
myNodeInfo
.flatMapLatest { myNodeEntity ->
// When myNodeInfo changes, set up emissions for the "provide-location-nodeNum" pref.
if (myNodeEntity == null) {
flowOf(false)
} else {
uiPrefs.shouldProvideNodeLocation(myNodeEntity.myNodeNum)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
fun setProvideLocation(value: Boolean) {
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
}
fun setTheme(theme: Int) {
uiPrefs.theme = theme
}
fun showAppIntro() {
uiPrefs.appIntroCompleted = false
}
fun unlockExcludedModules() {
_excludedModulesUnlocked.update { true }
}
/**
* Export all persisted packet data to a CSV file at the given URI.
*
* The CSV will include all packets, or only those matching the given port number if specified. Each row contains:
* date, time, sender node number, sender name, sender latitude, sender longitude, receiver latitude, receiver
* longitude, receiver elevation, received SNR, distance, hop limit, and payload.
*
* @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch(Dispatchers.Main) {
// Extract distances to this device from position messages and put (node,SNR,distance)
// in the file_uri
val myNodeNum = myNodeNum ?: return@launch
// Capture the current node value while we're still on main thread
val nodes = nodeRepository.nodeDBbyNum.value
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }.takeIf { it?.isValid() == true }
}
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
)
// Packets are ordered by time, we keep most recent position of
// our device in localNodePosition.
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
// If we get a NodeInfo packet, use it to update our position data (if valid)
packet.nodeInfo?.let { nodeInfo ->
positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
}
packet.meshPacket?.let { proto ->
// If the packet contains position data then use it to update, if valid
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
nodePositions[
proto.from.takeIf { it != 0 } ?: myNodeNum,
] = position
}
}
// packets must have rxSNR, and optionally match the filter given as a param.
if (
(filterPortnum == null || proto.decoded.portnumValue == filterPortnum) &&
proto.rxSnr != 0.0f
) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodes[proto.from]?.user?.longName ?: ""
// sender lat & long
val senderPosition = nodePositions[proto.from]
val senderPos = positionToPos.invoke(senderPosition)
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
// rx lat, long, and elevation
val rxPosition = nodePositions[myNodeNum]
val rxPos = positionToPos.invoke(rxPosition)
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = proto.rxSnr
// Calculate the distance if both positions are valid
val dist =
if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(
Position(rxPosition!!), // Use rxPosition but only if rxPos was
// valid
Position(senderPosition!!), // Use senderPosition but only if
// senderPos was valid
)
.roundToInt()
.toString()
}
val hopLimit = proto.hopLimit
val payload =
when {
proto.decoded.portnumValue !in
setOf(
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
Portnums.PortNum.RANGE_TEST_APP_VALUE,
) -> "<${proto.decoded.portnum}>"
proto.hasDecoded() -> proto.decoded.payload.toStringUtf8().replace("\"", "\"\"")
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
else -> ""
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
// elevation,rx
// snr,distance,hop limit,payload
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
)
}
}
}
}
}
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) {
withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
}
}
} catch (ex: FileNotFoundException) {
errormsg("Can't write file error: ${ex.message}")
}
}
}
}