refactor: Consolidate UI preference handling (#4895)

This commit is contained in:
James Rich 2026-03-23 14:36:02 -05:00 committed by GitHub
parent b4afe22030
commit 57242d905c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 197 additions and 297 deletions

View file

@ -45,12 +45,12 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Config
@ -84,7 +84,7 @@ class MapViewModel(
radioConfigRepository: RadioConfigRepository,
radioController: RadioController,
private val customTileProviderRepository: CustomTileProviderRepository,
uiPreferencesDataSource: UiPreferencesDataSource,
uiPrefs: UiPrefs,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
@ -125,7 +125,7 @@ class MapViewModel(
),
)
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
val theme: StateFlow<Int> = uiPrefs.theme
private val _errorFlow = MutableSharedFlow<String>()
val errorFlow: SharedFlow<String> = _errorFlow.asSharedFlow()

View file

@ -1,57 +0,0 @@
/*
* Copyright (c) 2026 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 org.meshtastic.core.common
import kotlinx.coroutines.flow.StateFlow
@Suppress("TooManyFunctions")
interface UiPreferences {
val appIntroCompleted: StateFlow<Boolean>
val theme: StateFlow<Int>
val locale: StateFlow<String>
val nodeSort: StateFlow<Int>
val includeUnknown: StateFlow<Boolean>
val excludeInfrastructure: StateFlow<Boolean>
val onlyOnline: StateFlow<Boolean>
val onlyDirect: StateFlow<Boolean>
val showIgnored: StateFlow<Boolean>
val excludeMqtt: StateFlow<Boolean>
fun setLocale(languageTag: String)
fun setAppIntroCompleted(completed: Boolean)
fun setTheme(value: Int)
fun setNodeSort(value: Int)
fun setIncludeUnknown(value: Boolean)
fun setExcludeInfrastructure(value: Boolean)
fun setOnlyOnline(value: Boolean)
fun setOnlyDirect(value: Boolean)
fun setShowIgnored(value: Boolean)
fun setExcludeMqtt(value: Boolean)
fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean>
fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean)
}

View file

@ -33,13 +33,6 @@ import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.datastore.KEY_APP_INTRO_COMPLETED
import org.meshtastic.core.datastore.KEY_INCLUDE_UNKNOWN
import org.meshtastic.core.datastore.KEY_NODE_SORT
import org.meshtastic.core.datastore.KEY_ONLY_DIRECT
import org.meshtastic.core.datastore.KEY_ONLY_ONLINE
import org.meshtastic.core.datastore.KEY_SHOW_IGNORED
import org.meshtastic.core.datastore.KEY_THEME
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
@ -61,23 +54,7 @@ class PreferencesDataStoreModule {
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
listOf(
SharedPreferencesMigration(context = context, sharedPreferencesName = USER_PREFERENCES_NAME),
SharedPreferencesMigration(
context = context,
sharedPreferencesName = "ui-prefs",
keysToMigrate =
setOf(
KEY_APP_INTRO_COMPLETED,
KEY_THEME,
KEY_NODE_SORT,
KEY_INCLUDE_UNKNOWN,
KEY_ONLY_ONLINE,
KEY_ONLY_DIRECT,
KEY_SHOW_IGNORED,
),
),
),
listOf(SharedPreferencesMigration(context = context, sharedPreferencesName = USER_PREFERENCES_NAME)),
scope = scope,
produceFile = { context.preferencesDataStoreFile(USER_PREFERENCES_NAME) },
)

View file

@ -1,146 +0,0 @@
/*
* Copyright (c) 2025-2026 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 org.meshtastic.core.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.common.util.ioDispatcher
const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
const val KEY_THEME = "theme"
const val KEY_LOCALE = "locale"
// Node list filters/sort
const val KEY_NODE_SORT = "node-sort-option"
const val KEY_INCLUDE_UNKNOWN = "include-unknown"
const val KEY_EXCLUDE_INFRASTRUCTURE = "exclude-infrastructure"
const val KEY_ONLY_ONLINE = "only-online"
const val KEY_ONLY_DIRECT = "only-direct"
const val KEY_SHOW_IGNORED = "show-ignored"
const val KEY_EXCLUDE_MQTT = "exclude-mqtt"
@Single
@Suppress("TooManyFunctions") // One setter per preference field — inherently grows with preferences.
open class UiPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) :
UiPreferences {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
// Start this flow eagerly, so app intro doesn't flash (when disabled) on cold app start.
override val appIntroCompleted: StateFlow<Boolean> =
dataStore.prefStateFlow(key = APP_INTRO_COMPLETED, default = false, started = SharingStarted.Eagerly)
// Default value for AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
override val theme: StateFlow<Int> = dataStore.prefStateFlow(key = THEME, default = -1)
/** Persisted language tag (e.g. "de", "pt-BR"). Empty string means system default. */
override val locale: StateFlow<String> =
dataStore.prefStateFlow(key = LOCALE, default = "", started = SharingStarted.Eagerly)
override fun setLocale(languageTag: String) {
dataStore.setPref(key = LOCALE, value = languageTag)
}
override val nodeSort: StateFlow<Int> = dataStore.prefStateFlow(key = NODE_SORT, default = -1)
override val includeUnknown: StateFlow<Boolean> = dataStore.prefStateFlow(key = INCLUDE_UNKNOWN, default = false)
override val excludeInfrastructure: StateFlow<Boolean> =
dataStore.prefStateFlow(key = EXCLUDE_INFRASTRUCTURE, default = false)
override val onlyOnline: StateFlow<Boolean> = dataStore.prefStateFlow(key = ONLY_ONLINE, default = false)
override val onlyDirect: StateFlow<Boolean> = dataStore.prefStateFlow(key = ONLY_DIRECT, default = false)
override val showIgnored: StateFlow<Boolean> = dataStore.prefStateFlow(key = SHOW_IGNORED, default = false)
override val excludeMqtt: StateFlow<Boolean> = dataStore.prefStateFlow(key = EXCLUDE_MQTT, default = false)
override fun setAppIntroCompleted(completed: Boolean) {
dataStore.setPref(key = APP_INTRO_COMPLETED, value = completed)
}
override fun setTheme(value: Int) {
dataStore.setPref(key = THEME, value = value)
}
override fun setNodeSort(value: Int) {
dataStore.setPref(key = NODE_SORT, value = value)
}
override fun setIncludeUnknown(value: Boolean) {
dataStore.setPref(key = INCLUDE_UNKNOWN, value = value)
}
override fun setExcludeInfrastructure(value: Boolean) {
dataStore.setPref(key = EXCLUDE_INFRASTRUCTURE, value = value)
}
override fun setOnlyOnline(value: Boolean) {
dataStore.setPref(key = ONLY_ONLINE, value = value)
}
override fun setOnlyDirect(value: Boolean) {
dataStore.setPref(key = ONLY_DIRECT, value = value)
}
override fun setShowIgnored(value: Boolean) {
dataStore.setPref(key = SHOW_IGNORED, value = value)
}
override fun setExcludeMqtt(value: Boolean) {
dataStore.setPref(key = EXCLUDE_MQTT, value = value)
}
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> =
dataStore.prefStateFlow(key = booleanPreferencesKey("provide-location-$nodeNum"), default = false)
override fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) {
dataStore.setPref(key = booleanPreferencesKey("provide-location-$nodeNum"), value = provide)
}
private fun <T : Any> DataStore<Preferences>.prefStateFlow(
key: Preferences.Key<T>,
default: T,
started: SharingStarted = SharingStarted.Lazily,
): StateFlow<T> = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default)
private fun <T : Any> DataStore<Preferences>.setPref(key: Preferences.Key<T>, value: T) {
scope.launch { edit { it[key] = value } }
}
private companion object {
val APP_INTRO_COMPLETED = booleanPreferencesKey(KEY_APP_INTRO_COMPLETED)
val THEME = intPreferencesKey(KEY_THEME)
val LOCALE = stringPreferencesKey(KEY_LOCALE)
val NODE_SORT = intPreferencesKey(KEY_NODE_SORT)
val INCLUDE_UNKNOWN = booleanPreferencesKey(KEY_INCLUDE_UNKNOWN)
val EXCLUDE_INFRASTRUCTURE = booleanPreferencesKey(KEY_EXCLUDE_INFRASTRUCTURE)
val ONLY_ONLINE = booleanPreferencesKey(KEY_ONLY_ONLINE)
val ONLY_DIRECT = booleanPreferencesKey(KEY_ONLY_DIRECT)
val SHOW_IGNORED = booleanPreferencesKey(KEY_SHOW_IGNORED)
val EXCLUDE_MQTT = booleanPreferencesKey(KEY_EXCLUDE_MQTT)
}
}

View file

@ -17,11 +17,11 @@
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetAppIntroCompletedUseCase constructor(private val uiPreferences: UiPreferences) {
open class SetAppIntroCompletedUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: Boolean) {
uiPreferences.setAppIntroCompleted(value)
uiPrefs.setAppIntroCompleted(value)
}
}

View file

@ -17,11 +17,11 @@
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetLocaleUseCase constructor(private val uiPreferences: UiPreferences) {
open class SetLocaleUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: String) {
uiPreferences.setLocale(value)
uiPrefs.setLocale(value)
}
}

View file

@ -17,11 +17,11 @@
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetProvideLocationUseCase constructor(private val uiPreferences: UiPreferences) {
open class SetProvideLocationUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
uiPreferences.setShouldProvideNodeLocation(myNodeNum, provideLocation)
uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
}
}

View file

@ -17,11 +17,11 @@
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetThemeUseCase constructor(private val uiPreferences: UiPreferences) {
open class SetThemeUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: Int) {
uiPreferences.setTheme(value)
uiPrefs.setTheme(value)
}
}

View file

@ -18,19 +18,19 @@ package org.meshtastic.core.domain.usecase.settings
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.repository.UiPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetAppIntroCompletedUseCaseTest {
private lateinit var uiPreferencesDataSource: UiPreferencesDataSource
private lateinit var uiPrefs: UiPrefs
private lateinit var useCase: SetAppIntroCompletedUseCase
@BeforeTest
fun setUp() {
uiPreferencesDataSource = mock(dev.mokkery.MockMode.autofill)
useCase = SetAppIntroCompletedUseCase(uiPreferencesDataSource)
uiPrefs = mock(dev.mokkery.MockMode.autofill)
useCase = SetAppIntroCompletedUseCase(uiPrefs)
}
@Test
@ -39,6 +39,6 @@ class SetAppIntroCompletedUseCaseTest {
useCase(true)
// Assert
verify { uiPreferencesDataSource.setAppIntroCompleted(true) }
verify { uiPrefs.setAppIntroCompleted(true) }
}
}

View file

@ -21,24 +21,24 @@ import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.repository.UiPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetLocaleUseCaseTest {
private val uiPreferences: UiPreferences = mock()
private val uiPrefs: UiPrefs = mock()
private lateinit var useCase: SetLocaleUseCase
@BeforeTest
fun setUp() {
useCase = SetLocaleUseCase(uiPreferences)
useCase = SetLocaleUseCase(uiPrefs)
}
@Test
fun `invoke calls setLocale on uiPreferences`() {
every { uiPreferences.setLocale(any()) } returns Unit
every { uiPrefs.setLocale(any()) } returns Unit
useCase("en")
verify { uiPreferences.setLocale("en") }
verify { uiPrefs.setLocale("en") }
}
}

View file

@ -20,19 +20,19 @@ import dev.mokkery.MockMode
import dev.mokkery.mock
import dev.mokkery.verifySuspend
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.repository.UiPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetProvideLocationUseCaseTest {
private lateinit var uiPreferences: UiPreferences
private lateinit var uiPrefs: UiPrefs
private lateinit var useCase: SetProvideLocationUseCase
@BeforeTest
fun setUp() {
uiPreferences = mock(MockMode.autofill)
useCase = SetProvideLocationUseCase(uiPreferences)
uiPrefs = mock(MockMode.autofill)
useCase = SetProvideLocationUseCase(uiPrefs)
}
@Test
@ -41,6 +41,6 @@ class SetProvideLocationUseCaseTest {
useCase(123, true)
// Assert
verifySuspend { uiPreferences.setShouldProvideNodeLocation(123, true) }
verifySuspend { uiPrefs.setShouldProvideNodeLocation(123, true) }
}
}

View file

@ -18,19 +18,19 @@ package org.meshtastic.core.domain.usecase.settings
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.repository.UiPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetThemeUseCaseTest {
private lateinit var uiPreferencesDataSource: UiPreferencesDataSource
private lateinit var uiPrefs: UiPrefs
private lateinit var useCase: SetThemeUseCase
@BeforeTest
fun setUp() {
uiPreferencesDataSource = mock(dev.mokkery.MockMode.autofill)
useCase = SetThemeUseCase(uiPreferencesDataSource)
uiPrefs = mock(dev.mokkery.MockMode.autofill)
useCase = SetThemeUseCase(uiPrefs)
}
@Test
@ -39,6 +39,6 @@ class SetThemeUseCaseTest {
useCase(1)
// Assert
verify { uiPreferencesDataSource.setTheme(1) }
verify { uiPrefs.setTheme(1) }
}
}

View file

@ -20,6 +20,8 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.atomicfu.atomic
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
@ -36,6 +38,7 @@ import org.meshtastic.core.prefs.cachedFlow
import org.meshtastic.core.repository.UiPrefs
@Single
@Suppress("TooManyFunctions")
class UiPrefsImpl(
@Named("UiDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
@ -45,6 +48,76 @@ class UiPrefsImpl(
// Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref
private val provideNodeLocationFlows = atomic(persistentMapOf<Int, StateFlow<Boolean>>())
override val appIntroCompleted: StateFlow<Boolean> =
dataStore.data.map { it[KEY_APP_INTRO_COMPLETED] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setAppIntroCompleted(completed: Boolean) {
scope.launch { dataStore.edit { it[KEY_APP_INTRO_COMPLETED] = completed } }
}
override val theme: StateFlow<Int> =
dataStore.data.map { it[KEY_THEME] ?: -1 }.stateIn(scope, SharingStarted.Lazily, -1)
override fun setTheme(value: Int) {
scope.launch { dataStore.edit { it[KEY_THEME] = value } }
}
override val locale: StateFlow<String> =
dataStore.data.map { it[KEY_LOCALE] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "")
override fun setLocale(languageTag: String) {
scope.launch { dataStore.edit { it[KEY_LOCALE] = languageTag } }
}
override val nodeSort: StateFlow<Int> =
dataStore.data.map { it[KEY_NODE_SORT] ?: -1 }.stateIn(scope, SharingStarted.Lazily, -1)
override fun setNodeSort(value: Int) {
scope.launch { dataStore.edit { it[KEY_NODE_SORT] = value } }
}
override val includeUnknown: StateFlow<Boolean> =
dataStore.data.map { it[KEY_INCLUDE_UNKNOWN] ?: false }.stateIn(scope, SharingStarted.Lazily, false)
override fun setIncludeUnknown(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_INCLUDE_UNKNOWN] = value } }
}
override val excludeInfrastructure: StateFlow<Boolean> =
dataStore.data.map { it[KEY_EXCLUDE_INFRASTRUCTURE] ?: false }.stateIn(scope, SharingStarted.Lazily, false)
override fun setExcludeInfrastructure(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_EXCLUDE_INFRASTRUCTURE] = value } }
}
override val onlyOnline: StateFlow<Boolean> =
dataStore.data.map { it[KEY_ONLY_ONLINE] ?: false }.stateIn(scope, SharingStarted.Lazily, false)
override fun setOnlyOnline(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_ONLY_ONLINE] = value } }
}
override val onlyDirect: StateFlow<Boolean> =
dataStore.data.map { it[KEY_ONLY_DIRECT] ?: false }.stateIn(scope, SharingStarted.Lazily, false)
override fun setOnlyDirect(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_ONLY_DIRECT] = value } }
}
override val showIgnored: StateFlow<Boolean> =
dataStore.data.map { it[KEY_SHOW_IGNORED] ?: false }.stateIn(scope, SharingStarted.Lazily, false)
override fun setShowIgnored(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_SHOW_IGNORED] = value } }
}
override val excludeMqtt: StateFlow<Boolean> =
dataStore.data.map { it[KEY_EXCLUDE_MQTT] ?: false }.stateIn(scope, SharingStarted.Lazily, false)
override fun setExcludeMqtt(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_EXCLUDE_MQTT] = value } }
}
override val hasShownNotPairedWarning: StateFlow<Boolean> =
dataStore.data
.map { it[KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF] ?: false }
@ -76,5 +149,16 @@ class UiPrefsImpl(
companion object {
val KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF = booleanPreferencesKey("has_shown_not_paired_warning")
val KEY_SHOW_QUICK_CHAT_PREF = booleanPreferencesKey("show-quick-chat")
val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed")
val KEY_THEME = intPreferencesKey("theme")
val KEY_LOCALE = stringPreferencesKey("locale")
val KEY_NODE_SORT = intPreferencesKey("node-sort-option")
val KEY_INCLUDE_UNKNOWN = booleanPreferencesKey("include-unknown")
val KEY_EXCLUDE_INFRASTRUCTURE = booleanPreferencesKey("exclude-infrastructure")
val KEY_ONLY_ONLINE = booleanPreferencesKey("only-online")
val KEY_ONLY_DIRECT = booleanPreferencesKey("only-direct")
val KEY_SHOW_IGNORED = booleanPreferencesKey("show-ignored")
val KEY_EXCLUDE_MQTT = booleanPreferencesKey("exclude-mqtt")
}
}

View file

@ -17,7 +17,6 @@
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.common.UiPreferences
/** Reactive interface for analytics-related preferences. */
interface AnalyticsPrefs {
@ -71,7 +70,48 @@ interface CustomEmojiPrefs {
}
/** Reactive interface for general UI preferences. */
@Suppress("TooManyFunctions")
interface UiPrefs {
val appIntroCompleted: StateFlow<Boolean>
fun setAppIntroCompleted(completed: Boolean)
val theme: StateFlow<Int>
fun setTheme(value: Int)
val locale: StateFlow<String>
fun setLocale(languageTag: String)
val nodeSort: StateFlow<Int>
fun setNodeSort(value: Int)
val includeUnknown: StateFlow<Boolean>
fun setIncludeUnknown(value: Boolean)
val excludeInfrastructure: StateFlow<Boolean>
fun setExcludeInfrastructure(value: Boolean)
val onlyOnline: StateFlow<Boolean>
fun setOnlyOnline(value: Boolean)
val onlyDirect: StateFlow<Boolean>
fun setOnlyDirect(value: Boolean)
val showIgnored: StateFlow<Boolean>
fun setShowIgnored(value: Boolean)
val excludeMqtt: StateFlow<Boolean>
fun setExcludeMqtt(value: Boolean)
val hasShownNotPairedWarning: StateFlow<Boolean>
fun setHasShownNotPairedWarning(shown: Boolean)
@ -181,7 +221,6 @@ interface AppPreferences {
val meshLog: MeshLogPrefs
val emoji: CustomEmojiPrefs
val ui: UiPrefs
val uiPrefs: UiPreferences
val map: MapPrefs
val mapConsent: MapConsentPrefs
val mapTileProvider: MapTileProviderPrefs

View file

@ -37,7 +37,6 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.RadioController
@ -51,6 +50,7 @@ import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.compromised_keys
@ -76,7 +76,7 @@ class UIViewModel(
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val uiPrefs: UiPrefs,
private val notificationManager: NotificationManager,
packetRepository: PacketRepository,
val alertManager: AlertManager,
@ -99,7 +99,7 @@ class UIViewModel(
)
}
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
val theme: StateFlow<Int> = uiPrefs.theme
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
@ -257,9 +257,9 @@ class UIViewModel(
serviceRepository.clearNeighborInfoResponse()
}
val appIntroCompleted: StateFlow<Boolean> = uiPreferencesDataSource.appIntroCompleted
val appIntroCompleted: StateFlow<Boolean> = uiPrefs.appIntroCompleted
fun onAppIntroCompleted() {
uiPreferencesDataSource.setAppIntroCompleted(true)
uiPrefs.setAppIntroCompleted(true)
}
}

View file

@ -63,10 +63,10 @@ import okio.Path.Companion.toPath
import org.jetbrains.skia.Image
import org.koin.core.context.startKoin
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.viewmodel.UIViewModel
@ -151,7 +151,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
onDispose { meshServiceController.stop() }
}
val uiPrefs = remember { koinApp.koin.get<UiPreferencesDataSource>() }
val uiPrefs = remember { koinApp.koin.get<UiPrefs>() }
val themePref by uiPrefs.theme.collectAsState(initial = -1) // -1 is SYSTEM usually
val localePref by uiPrefs.locale.collectAsState(initial = "")

View file

@ -37,13 +37,13 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.feature.map.model.CustomTileProviderConfig
import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.feature.map.repository.CustomTileProviderRepository
@ -61,7 +61,7 @@ class MapViewModelTest {
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val radioController = mock<RadioController>(MockMode.autofill)
private val customTileProviderRepository = mock<CustomTileProviderRepository>(MockMode.autofill)
private val uiPreferencesDataSource = mock<UiPreferencesDataSource>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null))
private val testDispatcher = StandardTestDispatcher()
@ -89,7 +89,7 @@ class MapViewModelTest {
every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList())
every { radioConfigRepository.deviceProfileFlow } returns flowOf(mock(MockMode.autofill))
every { uiPreferencesDataSource.theme } returns MutableStateFlow(1)
every { uiPrefs.theme } returns MutableStateFlow(1)
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
every { nodeRepository.myId } returns MutableStateFlow(null)
@ -108,7 +108,7 @@ class MapViewModelTest {
radioConfigRepository,
radioController,
customTileProviderRepository,
uiPreferencesDataSource,
uiPrefs,
savedStateHandle,
)
}

View file

@ -18,46 +18,46 @@ package org.meshtastic.feature.node.list
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.UiPrefs
@Single
open class NodeFilterPreferences constructor(private val uiPreferences: UiPreferences) {
open val includeUnknown = uiPreferences.includeUnknown
open val excludeInfrastructure = uiPreferences.excludeInfrastructure
open val onlyOnline = uiPreferences.onlyOnline
open val onlyDirect = uiPreferences.onlyDirect
open val showIgnored = uiPreferences.showIgnored
open val excludeMqtt = uiPreferences.excludeMqtt
open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
open val includeUnknown = uiPrefs.includeUnknown
open val excludeInfrastructure = uiPrefs.excludeInfrastructure
open val onlyOnline = uiPrefs.onlyOnline
open val onlyDirect = uiPrefs.onlyDirect
open val showIgnored = uiPrefs.showIgnored
open val excludeMqtt = uiPrefs.excludeMqtt
open val nodeSortOption =
uiPreferences.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
uiPrefs.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
open fun setNodeSort(option: NodeSortOption) {
uiPreferences.setNodeSort(option.ordinal)
uiPrefs.setNodeSort(option.ordinal)
}
open fun toggleIncludeUnknown() {
uiPreferences.setIncludeUnknown(!includeUnknown.value)
uiPrefs.setIncludeUnknown(!includeUnknown.value)
}
open fun toggleExcludeInfrastructure() {
uiPreferences.setExcludeInfrastructure(!excludeInfrastructure.value)
uiPrefs.setExcludeInfrastructure(!excludeInfrastructure.value)
}
open fun toggleOnlyOnline() {
uiPreferences.setOnlyOnline(!onlyOnline.value)
uiPrefs.setOnlyOnline(!onlyOnline.value)
}
open fun toggleOnlyDirect() {
uiPreferences.setOnlyDirect(!onlyDirect.value)
uiPrefs.setOnlyDirect(!onlyDirect.value)
}
open fun toggleShowIgnored() {
uiPreferences.setShowIgnored(!showIgnored.value)
uiPrefs.setShowIgnored(!showIgnored.value)
}
open fun toggleExcludeMqtt() {
uiPreferences.setExcludeMqtt(!excludeMqtt.value)
uiPrefs.setExcludeMqtt(!excludeMqtt.value)
}
}

View file

@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
@ -42,6 +41,7 @@ import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCas
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
@ -63,7 +63,6 @@ class SettingsViewModelTest {
private lateinit var radioController: FakeRadioController
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
private val uiPreferences: UiPreferences = mock(MockMode.autofill)
private val buildConfigProvider: BuildConfigProvider = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val meshLogPrefs: MeshLogPrefs = mock(MockMode.autofill)
@ -88,10 +87,14 @@ class SettingsViewModelTest {
val isOtaCapableUseCase: IsOtaCapableUseCase = mock(MockMode.autofill)
every { isOtaCapableUseCase() } returns flowOf(true)
val setThemeUseCase = SetThemeUseCase(uiPreferences)
val setLocaleUseCase = SetLocaleUseCase(uiPreferences)
val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPreferences)
val setProvideLocationUseCase = SetProvideLocationUseCase(uiPreferences)
val setThemeUseCase = SetThemeUseCase(uiPrefs)
val setLocaleUseCase = SetLocaleUseCase(uiPrefs)
val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs)
val appPreferences: AppPreferences = mock(MockMode.autofill)
every { appPreferences.ui } returns uiPrefs
val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs)
val setDatabaseCacheLimitUseCase = SetDatabaseCacheLimitUseCase(databaseManager)
val setMeshLogSettingsUseCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs)
val setNotificationSettingsUseCase = SetNotificationSettingsUseCase(notificationPrefs)