refactor: migrate preferences to DataStore and decouple core:domain for KMP (#4731)

This commit is contained in:
James Rich 2026-03-05 20:37:35 -06:00 committed by GitHub
parent 87fdaa26ff
commit b9b68d2779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
113 changed files with 1790 additions and 1320 deletions

View file

@ -26,7 +26,7 @@ import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@ -52,9 +52,9 @@ constructor(
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
var mapStyleId: Int
get() = mapPrefs.mapStyle
get() = mapPrefs.mapStyle.value
set(value) {
mapPrefs.mapStyle = value
mapPrefs.setMapStyle(value)
}
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())

View file

@ -49,7 +49,7 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@ -96,9 +96,9 @@ constructor(
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
private val targetLatLng =
googleMapsPrefs.cameraTargetLat
googleMapsPrefs.cameraTargetLat.value
.takeIf { it != 0.0 }
?.let { lat -> googleMapsPrefs.cameraTargetLng.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
?: ourNodeInfo.value?.position?.toLatLng()
?: LatLng(0.0, 0.0)
@ -107,9 +107,9 @@ constructor(
position =
CameraPosition(
targetLatLng,
googleMapsPrefs.cameraZoom,
googleMapsPrefs.cameraTilt,
googleMapsPrefs.cameraBearing,
googleMapsPrefs.cameraZoom.value,
googleMapsPrefs.cameraTilt.value,
googleMapsPrefs.cameraBearing.value,
),
)
@ -222,7 +222,7 @@ constructor(
) {
_selectedCustomTileProviderUrl.value = null
// Also clear from prefs
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.setSelectedCustomTileUrl(null)
}
if (configToRemove.localUri != null) {
@ -238,28 +238,28 @@ constructor(
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.setSelectedCustomTileUrl(null)
return
}
// Use localUri if present, otherwise urlTemplate
val selectedUrl = config.localUri ?: config.urlTemplate
_selectedCustomTileProviderUrl.value = selectedUrl
_selectedGoogleMapType.value = MapType.NONE
googleMapsPrefs.selectedCustomTileUrl = selectedUrl
googleMapsPrefs.selectedGoogleMapType = null
googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl)
googleMapsPrefs.setSelectedGoogleMapType(null)
} else {
_selectedCustomTileProviderUrl.value = null
_selectedGoogleMapType.value = MapType.NORMAL
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.selectedGoogleMapType = MapType.NORMAL.name
googleMapsPrefs.setSelectedCustomTileUrl(null)
googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
}
}
fun setSelectedGoogleMapType(mapType: MapType) {
_selectedGoogleMapType.value = mapType
_selectedCustomTileProviderUrl.value = null // Clear custom selection
googleMapsPrefs.selectedGoogleMapType = mapType.name
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.setSelectedGoogleMapType(mapType.name)
googleMapsPrefs.setSelectedCustomTileUrl(null)
}
private var currentTileProvider: TileProvider? = null
@ -354,16 +354,16 @@ constructor(
fun saveCameraPosition(cameraPosition: CameraPosition) {
viewModelScope.launch {
googleMapsPrefs.cameraTargetLat = cameraPosition.target.latitude
googleMapsPrefs.cameraTargetLng = cameraPosition.target.longitude
googleMapsPrefs.cameraZoom = cameraPosition.zoom
googleMapsPrefs.cameraTilt = cameraPosition.tilt
googleMapsPrefs.cameraBearing = cameraPosition.bearing
googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude)
googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude)
googleMapsPrefs.setCameraZoom(cameraPosition.zoom)
googleMapsPrefs.setCameraTilt(cameraPosition.tilt)
googleMapsPrefs.setCameraBearing(cameraPosition.bearing)
}
}
private fun loadPersistedMapType() {
val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl
val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value
if (savedCustomUrl != null) {
// Check if this custom provider still exists
if (
@ -375,18 +375,18 @@ constructor(
MapType.NONE // MapType.NONE to hide google basemap when using custom provider
} else {
// The saved custom URL is no longer valid or doesn't exist, remove preference
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.setSelectedCustomTileUrl(null)
// Fallback to default Google Map type
_selectedGoogleMapType.value = MapType.NORMAL
}
} else {
val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType
val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value
try {
_selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" }
_selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
googleMapsPrefs.selectedGoogleMapType = null
googleMapsPrefs.setSelectedGoogleMapType(null)
}
}
}
@ -399,7 +399,7 @@ constructor(
val persistedLayerFiles = layersDir.listFiles()
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
@ -429,7 +429,7 @@ constructor(
}
val networkItems =
googleMapsPrefs.networkMapLayers.mapNotNull { networkString ->
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
try {
val parts = networkString.split("|:|")
if (parts.size == 3) {
@ -532,7 +532,7 @@ constructor(
_mapLayers.value = _mapLayers.value + newItem
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
googleMapsPrefs.networkMapLayers = googleMapsPrefs.networkMapLayers + networkLayerString
googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
} catch (e: Exception) {
_errorFlow.emit("Invalid URL.")
}
@ -572,9 +572,9 @@ constructor(
toggledLayer?.let {
if (it.isVisible) {
googleMapsPrefs.hiddenLayerUrls -= it.uri.toString()
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
} else {
googleMapsPrefs.hiddenLayerUrls += it.uri.toString()
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
}
}
}
@ -584,12 +584,13 @@ constructor(
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.uri?.let { uri ->
if (layerToRemove.isNetwork) {
googleMapsPrefs.networkMapLayers =
googleMapsPrefs.networkMapLayers.filterNot { it.startsWith("$layerId|:|") }.toSet()
googleMapsPrefs.setNetworkMapLayers(
googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
)
} else {
deleteFileToInternalStorage(uri)
}
googleMapsPrefs.hiddenLayerUrls -= uri.toString()
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}

View file

@ -30,7 +30,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.resources.Res
@ -90,47 +90,48 @@ abstract class BaseMapViewModel(
}
.stateInWhileSubscribed(initialValue = emptyMap())
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites)
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)
val showOnlyFavoritesOnMap = showOnlyFavorites
fun toggleOnlyFavorites() {
val newValue = !showOnlyFavorites.value
showOnlyFavorites.value = newValue
mapPrefs.showOnlyFavorites = newValue
mapPrefs.setShowOnlyFavorites(newValue)
}
private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap)
private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap.value)
val showWaypointsOnMap = showWaypoints
fun toggleShowWaypointsOnMap() {
val newValue = !showWaypoints.value
showWaypoints.value = newValue
mapPrefs.showWaypointsOnMap = newValue
mapPrefs.setShowWaypointsOnMap(newValue)
}
private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap)
private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value)
val showPrecisionCircleOnMap = showPrecisionCircle
fun toggleShowPrecisionCircleOnMap() {
val newValue = !showPrecisionCircle.value
showPrecisionCircle.value = newValue
mapPrefs.showPrecisionCircleOnMap = newValue
mapPrefs.setShowPrecisionCircleOnMap(newValue)
}
private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter))
private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value))
val lastHeardFilter = lastHeardFilterValue
fun setLastHeardFilter(filter: LastHeardFilter) {
lastHeardFilterValue.value = filter
mapPrefs.lastHeardFilter = filter.seconds
mapPrefs.setLastHeardFilter(filter.seconds)
}
private val lastHeardTrackFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter))
private val lastHeardTrackFilterValue =
MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value))
val lastHeardTrackFilter = lastHeardTrackFilterValue
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
lastHeardTrackFilterValue.value = filter
mapPrefs.lastHeardTrackFilter = filter.seconds
mapPrefs.setLastHeardTrackFilter(filter.seconds)
}
abstract fun getUser(userId: String?): org.meshtastic.proto.User

View file

@ -28,10 +28,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@ -81,5 +81,5 @@ constructor(
.stateInWhileSubscribed(initialValue = emptyList())
val tileSource
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle)
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle.value)
}

View file

@ -44,7 +44,7 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@ -72,6 +72,22 @@ class MapViewModelTest {
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
every { mapPrefs.mapStyle } returns MutableStateFlow(0)
every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(true)
every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(true)
every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
every { googleMapsPrefs.cameraTargetLat } returns MutableStateFlow(0.0)
every { googleMapsPrefs.cameraTargetLng } returns MutableStateFlow(0.0)
every { googleMapsPrefs.cameraZoom } returns MutableStateFlow(0f)
every { googleMapsPrefs.cameraTilt } returns MutableStateFlow(0f)
every { googleMapsPrefs.cameraBearing } returns MutableStateFlow(0f)
every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null)
every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null)
every { googleMapsPrefs.hiddenLayerUrls } returns MutableStateFlow(emptySet())
every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList())
every { radioConfigRepository.deviceProfileFlow } returns flowOf(mockk(relaxed = true))
every { uiPreferencesDataSource.theme } returns MutableStateFlow(1)