From 57242d905c1d82e37e9ba4ba980a590a34cced25 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:36:02 -0500 Subject: [PATCH] refactor: Consolidate UI preference handling (#4895) --- .../org/meshtastic/app/map/MapViewModel.kt | 6 +- .../meshtastic/core/common/UiPreferences.kt | 57 ------- .../di/CoreDatastoreAndroidModule.kt | 25 +-- .../core/datastore/UiPreferencesDataSource.kt | 146 ------------------ .../settings/SetAppIntroCompletedUseCase.kt | 6 +- .../usecase/settings/SetLocaleUseCase.kt | 6 +- .../settings/SetProvideLocationUseCase.kt | 6 +- .../usecase/settings/SetThemeUseCase.kt | 6 +- .../SetAppIntroCompletedUseCaseTest.kt | 10 +- .../usecase/settings/SetLocaleUseCaseTest.kt | 10 +- .../settings/SetProvideLocationUseCaseTest.kt | 10 +- .../usecase/settings/SetThemeUseCaseTest.kt | 10 +- .../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 84 ++++++++++ .../core/repository/AppPreferences.kt | 43 +++++- .../core/ui/viewmodel/UIViewModel.kt | 10 +- .../kotlin/org/meshtastic/desktop/Main.kt | 4 +- .../feature/map/MapViewModelTest.kt | 8 +- .../node/list/NodeFilterPreferences.kt | 32 ++-- .../feature/settings/SettingsViewModelTest.kt | 15 +- 19 files changed, 197 insertions(+), 297 deletions(-) delete mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/UiPreferences.kt delete mode 100644 core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index f4b8f775a..35057485f 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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 = uiPreferencesDataSource.theme + val theme: StateFlow = uiPrefs.theme private val _errorFlow = MutableSharedFlow() val errorFlow: SharedFlow = _errorFlow.asSharedFlow() diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/UiPreferences.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/UiPreferences.kt deleted file mode 100644 index 71e4321fc..000000000 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/UiPreferences.kt +++ /dev/null @@ -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 . - */ -package org.meshtastic.core.common - -import kotlinx.coroutines.flow.StateFlow - -@Suppress("TooManyFunctions") -interface UiPreferences { - val appIntroCompleted: StateFlow - val theme: StateFlow - val locale: StateFlow - val nodeSort: StateFlow - val includeUnknown: StateFlow - val excludeInfrastructure: StateFlow - val onlyOnline: StateFlow - val onlyDirect: StateFlow - val showIgnored: StateFlow - val excludeMqtt: StateFlow - - 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 - - fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) -} diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt index 61a991207..94ef1c605 100644 --- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt +++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt @@ -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 = 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) }, ) diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt deleted file mode 100644 index 5277feb8f..000000000 --- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt +++ /dev/null @@ -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 . - */ -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) : - 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 = - dataStore.prefStateFlow(key = APP_INTRO_COMPLETED, default = false, started = SharingStarted.Eagerly) - - // Default value for AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - override val theme: StateFlow = dataStore.prefStateFlow(key = THEME, default = -1) - - /** Persisted language tag (e.g. "de", "pt-BR"). Empty string means system default. */ - override val locale: StateFlow = - dataStore.prefStateFlow(key = LOCALE, default = "", started = SharingStarted.Eagerly) - - override fun setLocale(languageTag: String) { - dataStore.setPref(key = LOCALE, value = languageTag) - } - - override val nodeSort: StateFlow = dataStore.prefStateFlow(key = NODE_SORT, default = -1) - override val includeUnknown: StateFlow = dataStore.prefStateFlow(key = INCLUDE_UNKNOWN, default = false) - override val excludeInfrastructure: StateFlow = - dataStore.prefStateFlow(key = EXCLUDE_INFRASTRUCTURE, default = false) - override val onlyOnline: StateFlow = dataStore.prefStateFlow(key = ONLY_ONLINE, default = false) - override val onlyDirect: StateFlow = dataStore.prefStateFlow(key = ONLY_DIRECT, default = false) - override val showIgnored: StateFlow = dataStore.prefStateFlow(key = SHOW_IGNORED, default = false) - override val excludeMqtt: StateFlow = 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 = - 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 DataStore.prefStateFlow( - key: Preferences.Key, - default: T, - started: SharingStarted = SharingStarted.Lazily, - ): StateFlow = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default) - - private fun DataStore.setPref(key: Preferences.Key, 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) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt index a4c1996f1..0db1a11c6 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt @@ -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) } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt index b33d721d2..ff44ad24b 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt @@ -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) } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt index 1eb8562b5..6d5d2dad8 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt @@ -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) } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt index e66318339..63f860aef 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt @@ -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) } } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt index 1f8ab6479..78a678f19 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt @@ -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) } } } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCaseTest.kt index d1bb0ee6d..b91217e9e 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCaseTest.kt @@ -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") } } } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt index 06dc1ecd3..15b25e52f 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt @@ -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) } } } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt index f8baf1408..a8d58e503 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt @@ -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) } } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index 905458f67..33f688389 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -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, 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>()) + override val appIntroCompleted: StateFlow = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = 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") } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index ae7789ffc..d4b2f680f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -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 + + fun setAppIntroCompleted(completed: Boolean) + + val theme: StateFlow + + fun setTheme(value: Int) + + val locale: StateFlow + + fun setLocale(languageTag: String) + + val nodeSort: StateFlow + + fun setNodeSort(value: Int) + + val includeUnknown: StateFlow + + fun setIncludeUnknown(value: Boolean) + + val excludeInfrastructure: StateFlow + + fun setExcludeInfrastructure(value: Boolean) + + val onlyOnline: StateFlow + + fun setOnlyOnline(value: Boolean) + + val onlyDirect: StateFlow + + fun setOnlyDirect(value: Boolean) + + val showIgnored: StateFlow + + fun setShowIgnored(value: Boolean) + + val excludeMqtt: StateFlow + + fun setExcludeMqtt(value: Boolean) + val hasShownNotPairedWarning: StateFlow 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 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 16ee48573..9ff6239c8 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -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 = uiPreferencesDataSource.theme + val theme: StateFlow = uiPrefs.theme val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition } @@ -257,9 +257,9 @@ class UIViewModel( serviceRepository.clearNeighborInfoResponse() } - val appIntroCompleted: StateFlow = uiPreferencesDataSource.appIntroCompleted + val appIntroCompleted: StateFlow = uiPrefs.appIntroCompleted fun onAppIntroCompleted() { - uiPreferencesDataSource.setAppIntroCompleted(true) + uiPrefs.setAppIntroCompleted(true) } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 9c9876c6d..96b121524 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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) = application(exitProcessOnExit = false) { onDispose { meshServiceController.stop() } } - val uiPrefs = remember { koinApp.koin.get() } + val uiPrefs = remember { koinApp.koin.get() } val themePref by uiPrefs.theme.collectAsState(initial = -1) // -1 is SYSTEM usually val localePref by uiPrefs.locale.collectAsState(initial = "") diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt index 2047283bb..7681195dd 100644 --- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -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(MockMode.autofill) private val radioController = mock(MockMode.autofill) private val customTileProviderRepository = mock(MockMode.autofill) - private val uiPreferencesDataSource = mock(MockMode.autofill) + private val uiPrefs = mock(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, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt index a1ca566e5..44a06c1dc 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt @@ -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) } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index d594d23fb..4e705f2a2 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -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)