diff --git a/app/src/main/assets/style.json b/app/src/main/assets/osm_raster_style.json similarity index 100% rename from app/src/main/assets/style.json rename to app/src/main/assets/osm_raster_style.json diff --git a/app/src/main/java/xyz/malkki/neostumbler/constants/PreferenceKeys.kt b/app/src/main/java/xyz/malkki/neostumbler/constants/PreferenceKeys.kt index 53df29e4..02a64213 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/constants/PreferenceKeys.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/constants/PreferenceKeys.kt @@ -23,4 +23,6 @@ object PreferenceKeys { const val DYNAMIC_COLOR_THEME = "dynamic_color_theme" const val DB_PRUNE_DATA_MAX_AGE_DAYS = "db_prune_max_age_days" + + const val MAP_TILE_SOURCE = "map_tile_source" } \ No newline at end of file diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt index 5d128c3f..dc969865 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt @@ -3,12 +3,31 @@ package xyz.malkki.neostumbler.ui.screens import android.Manifest import android.annotation.SuppressLint import android.content.Context +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf @@ -20,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.graphics.ColorUtils @@ -46,6 +66,7 @@ import xyz.malkki.neostumbler.extensions.checkMissingPermissions import xyz.malkki.neostumbler.ui.composables.KeepScreenOn import xyz.malkki.neostumbler.ui.composables.PermissionsDialog import xyz.malkki.neostumbler.ui.viewmodel.MapViewModel +import xyz.malkki.neostumbler.ui.viewmodel.MapViewModel.MapTileSource private val HEAT_LOW = ColorUtils.setAlphaComponent(0xd278ff, 120) private val HEAT_HIGH = ColorUtils.setAlphaComponent(0xaa00ff, 120) @@ -66,12 +87,18 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { mutableStateOf(false) } + val loadedStyle = remember { + mutableStateOf(null) + } + val fillManager = remember { mutableStateOf(null) } val httpClient = mapViewModel.httpClient.collectAsState(initial = null) + val selectedMapTileSource = mapViewModel.mapTileSource.collectAsState(initial = null) + val mapStyle = mapViewModel.mapStyle.collectAsState(initial = null) val latestReportPosition = mapViewModel.latestReportPosition.collectAsState(initial = null) @@ -148,8 +175,13 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { } }) - val styleBuilder = Style.Builder() - .fromJson(mapStyle.value!!) + val styleBuilder = Style.Builder().apply { + if (mapStyle.value!!.styleJson != null) { + fromJson(mapStyle.value!!.styleJson!!) + } else { + fromUri(mapStyle.value!!.styleUrl!!) + } + } map.setStyle(styleBuilder) { style -> map.locationComponent.activateLocationComponent( @@ -194,6 +226,16 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { map.cameraPosition = CameraPosition.Builder().target(LatLng(myLocation.value!!.location.latitude, myLocation.value!!.location.longitude)).build() } } + + //Ugly, but we don't want to update the map style unless it has actually changed + //TODO: think about a better way to do this + if (map.style != null) { + if (mapStyle.value!!.styleUrl != null && map.style!!.uri != mapStyle.value!!.styleUrl) { + map.setStyle(Style.Builder().fromUri(mapStyle.value!!.styleUrl!!)) + } else if (mapStyle.value!!.styleJson != null && map.style!!.json != mapStyle.value!!.styleJson) { + map.setStyle(Style.Builder().fromJson(mapStyle.value!!.styleJson!!)) + } + } } fillManager.value?.let { @@ -211,6 +253,14 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { Box( modifier = Modifier.fillMaxSize().padding(16.dp) ) { + if (selectedMapTileSource.value != null) { + MapTileSourceButton( + modifier = Modifier.size(32.dp).align(Alignment.TopEnd), + selectedMapTileSource = selectedMapTileSource.value!!, + onMapTileSourceSelected = { mapViewModel.setMapTileSource(it) } + ) + } + FilledIconButton( modifier = Modifier .size(48.dp) @@ -246,6 +296,92 @@ private fun createHeatMapFill(tiles: Collection): List } } +@Composable +private fun MapTileSourceButton(modifier: Modifier, selectedMapTileSource: MapTileSource, onMapTileSourceSelected: (MapTileSource) -> Unit) { + val dialogOpen = rememberSaveable { mutableStateOf(false) } + + if (dialogOpen.value) { + BasicAlertDialog( + onDismissRequest = { + dialogOpen.value = false + } + ) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.small, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + style = MaterialTheme.typography.titleLarge, + text = stringResource(id = R.string.map_tile_source), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .selectableGroup() + .padding(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MapTileSource.entries.forEach { mapTileSource -> + Row( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .defaultMinSize(minHeight = 36.dp) + .selectable( + selected = mapTileSource == selectedMapTileSource, + onClick = { + onMapTileSourceSelected(mapTileSource) + + dialogOpen.value = false + }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(top = 4.dp), + selected = mapTileSource == selectedMapTileSource, + onClick = null + ) + + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 16.dp), + text = mapTileSource.title, + style = MaterialTheme.typography.bodyMedium.merge() + ) + } + } + } + } + } + } + } + + FilledTonalIconButton( + modifier = modifier, + onClick = { + dialogOpen.value = true + }, + colors = IconButtonDefaults.filledTonalIconButtonColors() + ) { + Icon( + painter = painterResource(id = R.drawable.layers_18), + contentDescription = stringResource(id = R.string.map_tile_source) + ) + } +} + private class LifecycleAwareMap(context: Context) : MapView(context) { var lifecycle: Lifecycle? = null set(value) { diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt index 149a40ec..397b291f 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt @@ -2,6 +2,7 @@ package xyz.malkki.neostumbler.ui.viewmodel import android.Manifest import android.app.Application +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -25,11 +26,14 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.Call import org.geohex.geohex4j.GeoHex import xyz.malkki.neostumbler.StumblerApplication import xyz.malkki.neostumbler.common.LatLng +import xyz.malkki.neostumbler.constants.PreferenceKeys import xyz.malkki.neostumbler.extensions.checkMissingPermissions +import xyz.malkki.neostumbler.extensions.get import xyz.malkki.neostumbler.extensions.parallelMap import xyz.malkki.neostumbler.location.LocationSourceProvider import kotlin.math.abs @@ -45,18 +49,28 @@ private const val GEOHEX_RESOLUTION_LOW = 7 class MapViewModel(application: Application) : AndroidViewModel(application) { private val locationSource = LocationSourceProvider(getApplication()).getLocationSource() + private val settingsStore = getApplication().settingsStore + private val db = getApplication().reportDb private val _httpClient = MutableStateFlow(null) val httpClient: StateFlow get() = _httpClient.asStateFlow() - val mapStyle: Flow = flow { - application.assets.open("style.json").use { - emit(it.readBytes().decodeToString()) + val mapTileSource: Flow = settingsStore.data + .map { prefs -> + prefs.get(PreferenceKeys.MAP_TILE_SOURCE) ?: MapTileSource.OPENSTREETMAP + } + .distinctUntilChanged() + + val mapStyle: Flow = mapTileSource + .map { mapTileSource -> + if (mapTileSource.sourceAsset != null) { + MapStyle(styleUrl = null, styleJson = readStyleFromAssets(mapTileSource.sourceAsset)) + } else { + MapStyle(styleUrl = mapTileSource.sourceUrl!!, styleJson = null) } } - .flowOn(Dispatchers.IO) .shareIn(viewModelScope, started = SharingStarted.Eagerly, replay = 1) private val showMyLocation = MutableStateFlow(getApplication().checkMissingPermissions(Manifest.permission.ACCESS_COARSE_LOCATION).isEmpty()) @@ -190,8 +204,32 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { mapBounds.trySendBlocking(bounds) } + fun setMapTileSource(mapTileSource: MapTileSource) { + viewModelScope.launch { + settingsStore.updateData { prefs -> + prefs.toMutablePreferences().apply { + set(stringPreferencesKey(PreferenceKeys.MAP_TILE_SOURCE), mapTileSource.name) + } + } + } + } + + private suspend fun readStyleFromAssets(assetName: String): String = withContext(Dispatchers.IO) { + getApplication().assets.open(assetName).use { + it.readBytes().decodeToString() + } + } + /** * @property heatPct From 0.0 to 1.0 */ data class HeatMapTile(val outline: List, val heatPct: Float) + + enum class MapTileSource(val title: String, val sourceUrl: String?, val sourceAsset: String?) { + OPENSTREETMAP("OpenStreetMap", null, "osm_raster_style.json"), + OPENFREEMAP("OpenFreeMap", "https://tiles.openfreemap.org/styles/liberty", null), + VERSATILES("VersaTiles", "https://tiles.versatiles.org/assets/styles/colorful.json", null) + } + + data class MapStyle(val styleUrl: String?, val styleJson: String?) } \ No newline at end of file diff --git a/app/src/main/res/drawable/layers_18.xml b/app/src/main/res/drawable/layers_18.xml new file mode 100644 index 00000000..0d8ddc6b --- /dev/null +++ b/app/src/main/res/drawable/layers_18.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 1b9404bf..292d3a15 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -188,6 +188,7 @@ Kieli + Karttatiilien lähde Näytä oma sijainti diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a565a66..66ee08fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -181,6 +181,7 @@ Language + Map tile source Show my location