Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for using vector map tiles #416

Merged
merged 1 commit into from
Jan 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
140 changes: 138 additions & 2 deletions app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -66,12 +87,18 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) {
mutableStateOf(false)
}

val loadedStyle = remember {
mutableStateOf<MapViewModel.MapStyle?>(null)
}

val fillManager = remember {
mutableStateOf<FillManager?>(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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -246,6 +296,92 @@ private fun createHeatMapFill(tiles: Collection<MapViewModel.HeatMapTile>): 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<StumblerApplication>().settingsStore

private val db = getApplication<StumblerApplication>().reportDb

private val _httpClient = MutableStateFlow<Call.Factory?>(null)
val httpClient: StateFlow<Call.Factory?>
get() = _httpClient.asStateFlow()

val mapStyle: Flow<String> = flow {
application.assets.open("style.json").use {
emit(it.readBytes().decodeToString())
val mapTileSource: Flow<MapTileSource> = settingsStore.data
.map { prefs ->
prefs.get<MapTileSource>(PreferenceKeys.MAP_TILE_SOURCE) ?: MapTileSource.OPENSTREETMAP
}
.distinctUntilChanged()

val mapStyle: Flow<MapStyle> = 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<StumblerApplication>().checkMissingPermissions(Manifest.permission.ACCESS_COARSE_LOCATION).isEmpty())
Expand Down Expand Up @@ -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<StumblerApplication>().assets.open(assetName).use {
it.readBytes().decodeToString()
}
}

/**
* @property heatPct From 0.0 to 1.0
*/
data class HeatMapTile(val outline: List<LatLng>, 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?)
}
10 changes: 10 additions & 0 deletions app/src/main/res/drawable/layers_18.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,842L120,562L186,512L480,740L774,512L840,562L480,842ZM480,640L120,360L480,80L840,360L480,640ZM480,360L480,360L480,360L480,360ZM480,538L710,360L480,182L250,360L480,538Z"/>
</vector>
1 change: 1 addition & 0 deletions app/src/main/res/values-fi/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@

<string name="app_language">Kieli</string>

<string name="map_tile_source">Karttatiilien lähde</string>
<string name="show_my_location">Näytä oma sijainti</string>

<string name="permission_rationale_fine_location">
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@

<string name="app_language">Language</string>

<string name="map_tile_source">Map tile source</string>
<string name="show_my_location">Show my location</string>

<string name="permission_rationale_fine_location">
Expand Down
Loading