Improve map interaction, shelter selection, and offline caching

- Rework selection model so any shelter marker can be tapped to select,
  not just the nearest three in the bottom sheet list
- Highlight selected shelter with a distinct amber marker icon
- Track user map interaction (pan/zoom) to prevent auto-recentering
- Add reset navigation FAB to re-fit map to user + selected shelter
- Add offline map cache prompt (OK/Skip) with warning banner and retry
- Rewrite MapCacheManager to use passive tile caching via map panning
- Respect system status bar with fitsSystemWindows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-08 17:41:15 +01:00
commit 92531ee971
9 changed files with 373 additions and 112 deletions

View file

@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import no.naiv.tilfluktsrom.data.MapCacheManager
@ -56,11 +57,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private var currentLocation: Location? = null
private var allShelters: List<Shelter> = emptyList()
private var nearestShelters: List<ShelterWithDistance> = emptyList()
private var selectedShelterIndex = 0
private var deviceHeading = 0f
private var isCompassMode = false
private var locationJob: Job? = null
private var shelterMarkers: MutableList<Marker> = mutableListOf()
private var cachingJob: Job? = null
// Map from shelter lokalId to its map marker, for icon swapping on selection
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf()
private var highlightedMarkerId: String? = null
// The currently selected shelter — can be any shelter, not just one from nearestShelters
private var selectedShelter: ShelterWithDistance? = null
// When true, location updates won't auto-select the nearest shelter
private var userSelectedShelter = false
// When true, location updates won't auto-zoom the map
private var userHasInteractedWithMap = false
private val locationPermissionRequest = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@ -99,6 +109,16 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// Default center: roughly central Norway
controller.setCenter(GeoPoint(59.9, 10.7))
// Detect user touch interaction (pan/zoom) to suppress auto-zoom
setOnTouchListener { v, _ ->
if (!userHasInteractedWithMap) {
userHasInteractedWithMap = true
binding.resetNavigationFab.visibility = View.VISIBLE
}
v.performClick()
false // Don't consume the event
}
// Add user location overlay
myLocationOverlay = MyLocationNewOverlay(
GpsMyLocationProvider(this@MainActivity), this
@ -110,12 +130,10 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
private fun setupShelterList() {
shelterAdapter = ShelterListAdapter { selected ->
val idx = nearestShelters.indexOf(selected)
if (idx >= 0) {
selectedShelterIndex = idx
updateSelectedShelter()
}
shelterAdapter = ShelterListAdapter { swd ->
userSelectedShelter = true
userHasInteractedWithMap = false
selectShelter(swd)
}
binding.shelterList.apply {
@ -138,9 +156,26 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
}
// Reset to navigation: re-fit map to show user + selected shelter
binding.resetNavigationFab.setOnClickListener {
userHasInteractedWithMap = false
binding.resetNavigationFab.visibility = View.GONE
selectedShelter?.let { highlightShelterOnMap(it) }
}
binding.refreshButton.setOnClickListener {
forceRefresh()
}
binding.cacheRetryButton.setOnClickListener {
currentLocation?.let { loc ->
if (isNetworkAvailable()) {
startCaching(loc.latitude, loc.longitude)
} else {
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
}
}
}
}
private fun loadData() {
@ -231,16 +266,87 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
allShelters, location.latitude, location.longitude, NEAREST_COUNT
)
// Highlight which nearest-list item matches the current selection
val selectedIdx = if (selectedShelter != null) {
nearestShelters.indexOfFirst { it.shelter.lokalId == selectedShelter!!.shelter.lokalId }
} else -1
shelterAdapter.submitList(nearestShelters)
selectedShelterIndex = 0
shelterAdapter.selectPosition(0)
updateSelectedShelter()
shelterAdapter.selectPosition(selectedIdx)
if (userSelectedShelter && selectedShelter != null) {
// Recalculate distance/bearing for the user's picked shelter
refreshSelectedShelterDistance(location)
} else {
// Auto-select nearest
if (nearestShelters.isNotEmpty()) {
selectShelter(nearestShelters[0])
}
}
updateSelectedShelterUI()
}
private fun updateSelectedShelter() {
if (nearestShelters.isEmpty()) return
/**
* Select a specific shelter (from list tap, marker tap, or auto-select).
* Recalculates distance/bearing from current location.
*/
private fun selectShelter(swd: ShelterWithDistance) {
selectedShelter = swd
currentLocation?.let { refreshSelectedShelterDistance(it) }
val selected = nearestShelters[selectedShelterIndex]
// Update list highlight
val idx = nearestShelters.indexOfFirst { it.shelter.lokalId == swd.shelter.lokalId }
shelterAdapter.selectPosition(idx)
updateSelectedShelterUI()
}
/**
* Select a shelter by its data object (e.g. from a marker tap).
* Computes a fresh ShelterWithDistance from the current location.
*/
private fun selectShelterByData(shelter: Shelter) {
val loc = currentLocation
val swd = if (loc != null) {
ShelterWithDistance(
shelter = shelter,
distanceMeters = DistanceUtils.distanceMeters(
loc.latitude, loc.longitude, shelter.latitude, shelter.longitude
),
bearingDegrees = DistanceUtils.bearingDegrees(
loc.latitude, loc.longitude, shelter.latitude, shelter.longitude
)
)
} else {
ShelterWithDistance(shelter = shelter, distanceMeters = 0.0, bearingDegrees = 0.0)
}
userSelectedShelter = true
userHasInteractedWithMap = false
binding.resetNavigationFab.visibility = View.GONE
selectShelter(swd)
}
/** Recalculate distance/bearing for the currently selected shelter. */
private fun refreshSelectedShelterDistance(location: Location) {
val current = selectedShelter ?: return
selectedShelter = ShelterWithDistance(
shelter = current.shelter,
distanceMeters = DistanceUtils.distanceMeters(
location.latitude, location.longitude,
current.shelter.latitude, current.shelter.longitude
),
bearingDegrees = DistanceUtils.bearingDegrees(
location.latitude, location.longitude,
current.shelter.latitude, current.shelter.longitude
)
)
}
/** Update all UI elements for the currently selected shelter. */
private fun updateSelectedShelterUI() {
val selected = selectedShelter ?: return
val distanceText = DistanceUtils.formatDistance(selected.distanceMeters)
// Update bottom sheet
@ -260,28 +366,61 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
binding.compassAddressText.text = selected.shelter.adresse
binding.directionArrow.setDirection(bearing - deviceHeading)
// Center map on shelter if in map mode
if (!isCompassMode) {
// Emphasize the selected marker on the map
highlightSelectedMarker(selected.shelter.lokalId)
// Only auto-zoom the map if the user hasn't manually panned/zoomed
if (!isCompassMode && !userHasInteractedWithMap) {
highlightShelterOnMap(selected)
}
}
/** Swap marker icons so the selected shelter stands out. */
private fun highlightSelectedMarker(lokalId: String) {
if (lokalId == highlightedMarkerId) return
val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter)
val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected)
// Reset previous
highlightedMarkerId?.let { prevId ->
shelterMarkerMap[prevId]?.icon = normalIcon
}
// Highlight new
shelterMarkerMap[lokalId]?.icon = selectedIcon
highlightedMarkerId = lokalId
binding.mapView.invalidate()
}
private fun updateShelterMarkers() {
// Remove old markers
shelterMarkers.forEach { binding.mapView.overlays.remove(it) }
shelterMarkers.clear()
shelterMarkerMap.values.forEach { binding.mapView.overlays.remove(it) }
shelterMarkerMap.clear()
highlightedMarkerId = null
// Add markers for all shelters
val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter)
val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected)
val currentSelectedId = selectedShelter?.shelter?.lokalId
// Add markers for all shelters — tapping any marker selects it
allShelters.forEach { shelter ->
val isSelected = shelter.lokalId == currentSelectedId
val marker = Marker(binding.mapView).apply {
position = GeoPoint(shelter.latitude, shelter.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = shelter.adresse
snippet = getString(R.string.shelter_capacity, shelter.plasser) +
" - " + getString(R.string.shelter_room_nr, shelter.romnr)
icon = ContextCompat.getDrawable(this@MainActivity, R.drawable.ic_shelter)
icon = if (isSelected) selectedIcon else normalIcon
setOnMarkerClickListener { _, _ ->
selectShelterByData(shelter)
true
}
}
shelterMarkers.add(marker)
if (isSelected) highlightedMarkerId = shelter.lokalId
shelterMarkerMap[shelter.lokalId] = marker
binding.mapView.overlays.add(marker)
}
@ -305,18 +444,42 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
private fun cacheMapTiles(latitude: Double, longitude: Double) {
lifecycleScope.launch {
binding.statusText.text = getString(R.string.status_caching_map)
// Show prompt with OK / Skip choice
showLoading(getString(R.string.loading_map_explanation))
binding.loadingProgress.visibility = View.GONE
binding.loadingButtonRow.visibility = View.VISIBLE
binding.loadingOkButton.setOnClickListener {
startCaching(latitude, longitude)
}
binding.loadingSkipButton.setOnClickListener {
hideLoading()
showNoCacheBanner()
}
}
private fun startCaching(latitude: Double, longitude: Double) {
binding.noCacheBanner.visibility = View.GONE
showLoading(getString(R.string.loading_map))
binding.loadingButtonRow.visibility = View.GONE
cachingJob = lifecycleScope.launch {
mapCacheManager.cacheMapArea(
binding.mapView, latitude, longitude
) { progress ->
binding.statusText.text = getString(R.string.status_caching_map) +
binding.loadingText.text = getString(R.string.loading_map) +
" (${(progress * 100).toInt()}%)"
}
hideLoading()
binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size)
}
}
private fun showNoCacheBanner() {
binding.noCacheBanner.visibility = View.VISIBLE
}
private fun forceRefresh() {
if (!isNetworkAvailable()) {
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
@ -337,6 +500,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private fun showLoading(message: String) {
binding.loadingOverlay.visibility = View.VISIBLE
binding.loadingText.text = message
binding.loadingProgress.visibility = View.VISIBLE
binding.loadingButtonRow.visibility = View.GONE
}
private fun hideLoading() {
@ -413,8 +578,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
private fun updateDirectionArrows() {
if (nearestShelters.isEmpty()) return
val selected = nearestShelters[selectedShelterIndex]
val selected = selectedShelter ?: return
val bearing = selected.bearingDegrees.toFloat()
val arrowAngle = bearing - deviceHeading

View file

@ -3,18 +3,20 @@ package no.naiv.tilfluktsrom.data
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.osmdroid.tileprovider.cachemanager.CacheManager
import org.osmdroid.tileprovider.modules.SqlTileWriter
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
/**
* Manages offline map tile caching for the surrounding area.
*
* On first launch, downloads map tiles for a region around the user's location.
* On subsequent launches, checks if the current location is within the cached area.
* OSMDroid's SqlTileWriter automatically caches every tile the MapView loads.
* We exploit this by programmatically panning the map across the surrounding area
* at multiple zoom levels, which causes tiles to be fetched and cached passively.
*
* This approach respects OSM's tile usage policy (no bulk download) while still
* building up an offline cache for the user's area.
*/
class MapCacheManager(private val context: Context) {
@ -26,10 +28,13 @@ class MapCacheManager(private val context: Context) {
private const val KEY_CACHE_RADIUS = "cache_radius_km"
private const val KEY_CACHE_COMPLETE = "cache_complete"
// Cache tiles for ~15km radius at useful zoom levels
private const val CACHE_RADIUS_DEGREES = 0.15 // ~15km
private const val MIN_ZOOM = 10
private const val MAX_ZOOM = 16
// Zoom levels to cache: overview down to street level
private val CACHE_ZOOM_LEVELS = intArrayOf(10, 12, 14, 16)
// Grid points per axis per zoom level for panning
private const val GRID_SIZE = 3
}
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@ -46,14 +51,15 @@ class MapCacheManager(private val context: Context) {
if (radius == 0.0) return false
// Check if current location is within the cached region (with some margin)
val margin = radius * 0.3
return Math.abs(latitude - cachedLat) < (radius - margin) &&
Math.abs(longitude - cachedLon) < (radius - margin)
}
/**
* Download map tiles for the area around the given location.
* Seed the tile cache by panning the map across the area around the location.
* OSMDroid's built-in SqlTileWriter caches each tile as it's loaded.
*
* Reports progress via callback (0.0 to 1.0).
*/
suspend fun cacheMapArea(
@ -63,93 +69,51 @@ class MapCacheManager(private val context: Context) {
onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.Main) {
try {
Log.i(TAG, "Starting map tile cache for area around $latitude, $longitude")
Log.i(TAG, "Seeding tile cache for area around $latitude, $longitude")
val boundingBox = BoundingBox(
latitude + CACHE_RADIUS_DEGREES,
longitude + CACHE_RADIUS_DEGREES,
latitude - CACHE_RADIUS_DEGREES,
longitude - CACHE_RADIUS_DEGREES
)
val totalSteps = CACHE_ZOOM_LEVELS.size * GRID_SIZE * GRID_SIZE
var step = 0
val cacheManager = CacheManager(mapView)
var complete = false
var success = false
for (zoom in CACHE_ZOOM_LEVELS) {
mapView.controller.setZoom(zoom.toDouble())
cacheManager.downloadAreaAsync(
context,
boundingBox,
MIN_ZOOM,
MAX_ZOOM,
object : CacheManager.CacheManagerCallback {
override fun onTaskComplete() {
Log.i(TAG, "Map cache download complete")
success = true
complete = true
// Pan across a grid of points covering the area
for (row in 0 until GRID_SIZE) {
for (col in 0 until GRID_SIZE) {
val lat = latitude - CACHE_RADIUS_DEGREES +
(2 * CACHE_RADIUS_DEGREES * row) / (GRID_SIZE - 1)
val lon = longitude - CACHE_RADIUS_DEGREES +
(2 * CACHE_RADIUS_DEGREES * col) / (GRID_SIZE - 1)
mapView.controller.setCenter(GeoPoint(lat, lon))
// Force a layout pass so tiles are requested
mapView.invalidate()
step++
onProgress(step.toFloat() / totalSteps)
// Brief delay to allow tile loading to start
delay(300)
}
override fun onTaskFailed(errors: Int) {
Log.w(TAG, "Map cache download completed with $errors errors")
// Consider partial success if most tiles downloaded
success = errors < 50
complete = true
}
override fun updateProgress(
progress: Int,
currentZoomLevel: Int,
zoomMin: Int,
zoomMax: Int
) {
val totalZoomLevels = zoomMax - zoomMin + 1
val zoomProgress = (currentZoomLevel - zoomMin).toFloat() / totalZoomLevels
onProgress(zoomProgress + (progress / 100f) / totalZoomLevels)
}
override fun downloadStarted() {
Log.i(TAG, "Map cache download started")
}
override fun setPossibleTilesInArea(total: Int) {
Log.i(TAG, "Total tiles to download: $total")
}
}
)
// Wait for completion (the async callback runs on main thread)
withContext(Dispatchers.IO) {
while (!complete) {
Thread.sleep(500)
}
}
if (success) {
prefs.edit()
.putLong(KEY_CACHED_LAT, latitude.toBits())
.putLong(KEY_CACHED_LON, longitude.toBits())
.putFloat(KEY_CACHE_RADIUS, CACHE_RADIUS_DEGREES.toFloat())
.putBoolean(KEY_CACHE_COMPLETE, true)
.apply()
}
// Restore to user's location
mapView.controller.setZoom(14.0)
mapView.controller.setCenter(GeoPoint(latitude, longitude))
success
prefs.edit()
.putLong(KEY_CACHED_LAT, latitude.toBits())
.putLong(KEY_CACHED_LON, longitude.toBits())
.putFloat(KEY_CACHE_RADIUS, CACHE_RADIUS_DEGREES.toFloat())
.putBoolean(KEY_CACHE_COMPLETE, true)
.apply()
Log.i(TAG, "Tile cache seeding complete")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to cache map tiles", e)
Log.e(TAG, "Failed to seed tile cache", e)
false
}
}
/**
* Get the approximate number of cached tiles.
*/
fun getCachedTileCount(): Long {
return try {
val writer = SqlTileWriter()
val count = writer.getRowCount(null)
writer.onDetach()
count
} catch (e: Exception) {
0L
}
}
}