From 92531ee97185cdffe0f6441d3a3f13344df97e74 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sun, 8 Mar 2026 17:41:15 +0100 Subject: [PATCH] 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 --- .../java/no/naiv/tilfluktsrom/MainActivity.kt | 216 +++++++++++++++--- .../naiv/tilfluktsrom/data/MapCacheManager.kt | 138 +++++------ app/src/main/res/drawable/ic_my_location.xml | 11 + .../main/res/drawable/ic_shelter_selected.xml | 23 ++ app/src/main/res/layout/activity_main.xml | 78 +++++++ app/src/main/res/values-nb/strings.xml | 6 + app/src/main/res/values-nn/strings.xml | 6 + app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 6 + 9 files changed, 374 insertions(+), 113 deletions(-) create mode 100644 app/src/main/res/drawable/ic_my_location.xml create mode 100644 app/src/main/res/drawable/ic_shelter_selected.xml diff --git a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt index edc8bba..287c1c7 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt @@ -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 = emptyList() private var nearestShelters: List = emptyList() - private var selectedShelterIndex = 0 private var deviceHeading = 0f private var isCompassMode = false private var locationJob: Job? = null - private var shelterMarkers: MutableList = mutableListOf() + private var cachingJob: Job? = null + // Map from shelter lokalId to its map marker, for icon swapping on selection + private var shelterMarkerMap: MutableMap = 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 diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt index cce3634..8e22efa 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt @@ -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 - } - } } diff --git a/app/src/main/res/drawable/ic_my_location.xml b/app/src/main/res/drawable/ic_my_location.xml new file mode 100644 index 0000000..85f8e76 --- /dev/null +++ b/app/src/main/res/drawable/ic_my_location.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_shelter_selected.xml b/app/src/main/res/drawable/ic_shelter_selected.xml new file mode 100644 index 0000000..e7db590 --- /dev/null +++ b/app/src/main/res/drawable/ic_shelter_selected.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 779481f..121dc34 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,6 +5,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" android:background="@color/background" tools:context=".MainActivity"> @@ -84,6 +85,22 @@ tools:text="Storgata 1" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index dd4080a..02c141e 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -14,6 +14,7 @@ Laster ned tilfluktsromdata… Lagrer kartfliser… + Forbereder frakoblet kart.\nKartet vil rulle kort for å lagre omgivelsene dine. Gjør klar for første gangs bruk… @@ -25,6 +26,11 @@ Oppdater data Bytt mellom kart og kompassvisning + Hopp over + Lagre kart + Lagre nå + Tilbakestill navigasjonsvisning + Ingen frakoblet kart lagret. Kartet krever internett. Posisjonstillatelse kreves diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 92b361d..977e36c 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -14,6 +14,7 @@ Lastar ned tilfluktsromdata… Lagrar kartfliser… + Førebur fråkopla kart.\nKartet vil rulle kort for å lagre omgjevnadene dine. Gjer klar for fyrste gongs bruk… @@ -25,6 +26,11 @@ Oppdater data Byt mellom kart og kompassvising + Hopp over + Lagre kart + Lagre no + Tilbakestill navigasjonsvising + Ingen fråkopla kart lagra. Kartet krev internett. Posisjonsløyve krevst diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index cb10124..0a9d1a1 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -15,6 +15,9 @@ #ECEFF1 #90A4AE + #E65100 + #FFFFFF + #FFFFFF #000000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b357285..c381ca1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ Downloading shelter data… Caching map tiles… + Preparing offline map.\nThe map will scroll briefly to cache your surroundings. Setting up for first use… @@ -25,6 +26,11 @@ Refresh data Toggle map/compass view + Skip + Cache map + Cache now + Reset navigation view + No offline map cached. Map requires internet. Location permission required