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:
parent
27cad094e7
commit
92531ee971
9 changed files with 373 additions and 112 deletions
|
|
@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import no.naiv.tilfluktsrom.data.MapCacheManager
|
import no.naiv.tilfluktsrom.data.MapCacheManager
|
||||||
|
|
@ -56,11 +57,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
private var currentLocation: Location? = null
|
private var currentLocation: Location? = null
|
||||||
private var allShelters: List<Shelter> = emptyList()
|
private var allShelters: List<Shelter> = emptyList()
|
||||||
private var nearestShelters: List<ShelterWithDistance> = emptyList()
|
private var nearestShelters: List<ShelterWithDistance> = emptyList()
|
||||||
private var selectedShelterIndex = 0
|
|
||||||
private var deviceHeading = 0f
|
private var deviceHeading = 0f
|
||||||
private var isCompassMode = false
|
private var isCompassMode = false
|
||||||
private var locationJob: Job? = null
|
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(
|
private val locationPermissionRequest = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
|
@ -99,6 +109,16 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
// Default center: roughly central Norway
|
// Default center: roughly central Norway
|
||||||
controller.setCenter(GeoPoint(59.9, 10.7))
|
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
|
// Add user location overlay
|
||||||
myLocationOverlay = MyLocationNewOverlay(
|
myLocationOverlay = MyLocationNewOverlay(
|
||||||
GpsMyLocationProvider(this@MainActivity), this
|
GpsMyLocationProvider(this@MainActivity), this
|
||||||
|
|
@ -110,12 +130,10 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupShelterList() {
|
private fun setupShelterList() {
|
||||||
shelterAdapter = ShelterListAdapter { selected ->
|
shelterAdapter = ShelterListAdapter { swd ->
|
||||||
val idx = nearestShelters.indexOf(selected)
|
userSelectedShelter = true
|
||||||
if (idx >= 0) {
|
userHasInteractedWithMap = false
|
||||||
selectedShelterIndex = idx
|
selectShelter(swd)
|
||||||
updateSelectedShelter()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.shelterList.apply {
|
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 {
|
binding.refreshButton.setOnClickListener {
|
||||||
forceRefresh()
|
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() {
|
private fun loadData() {
|
||||||
|
|
@ -231,16 +266,87 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
allShelters, location.latitude, location.longitude, NEAREST_COUNT
|
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)
|
shelterAdapter.submitList(nearestShelters)
|
||||||
selectedShelterIndex = 0
|
shelterAdapter.selectPosition(selectedIdx)
|
||||||
shelterAdapter.selectPosition(0)
|
|
||||||
updateSelectedShelter()
|
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)
|
val distanceText = DistanceUtils.formatDistance(selected.distanceMeters)
|
||||||
|
|
||||||
// Update bottom sheet
|
// Update bottom sheet
|
||||||
|
|
@ -260,28 +366,61 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
binding.compassAddressText.text = selected.shelter.adresse
|
binding.compassAddressText.text = selected.shelter.adresse
|
||||||
binding.directionArrow.setDirection(bearing - deviceHeading)
|
binding.directionArrow.setDirection(bearing - deviceHeading)
|
||||||
|
|
||||||
// Center map on shelter if in map mode
|
// Emphasize the selected marker on the map
|
||||||
if (!isCompassMode) {
|
highlightSelectedMarker(selected.shelter.lokalId)
|
||||||
|
|
||||||
|
// Only auto-zoom the map if the user hasn't manually panned/zoomed
|
||||||
|
if (!isCompassMode && !userHasInteractedWithMap) {
|
||||||
highlightShelterOnMap(selected)
|
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() {
|
private fun updateShelterMarkers() {
|
||||||
// Remove old markers
|
// Remove old markers
|
||||||
shelterMarkers.forEach { binding.mapView.overlays.remove(it) }
|
shelterMarkerMap.values.forEach { binding.mapView.overlays.remove(it) }
|
||||||
shelterMarkers.clear()
|
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 ->
|
allShelters.forEach { shelter ->
|
||||||
|
val isSelected = shelter.lokalId == currentSelectedId
|
||||||
val marker = Marker(binding.mapView).apply {
|
val marker = Marker(binding.mapView).apply {
|
||||||
position = GeoPoint(shelter.latitude, shelter.longitude)
|
position = GeoPoint(shelter.latitude, shelter.longitude)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
title = shelter.adresse
|
title = shelter.adresse
|
||||||
snippet = getString(R.string.shelter_capacity, shelter.plasser) +
|
snippet = getString(R.string.shelter_capacity, shelter.plasser) +
|
||||||
" - " + getString(R.string.shelter_room_nr, shelter.romnr)
|
" - " + 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)
|
binding.mapView.overlays.add(marker)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,18 +444,42 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cacheMapTiles(latitude: Double, longitude: Double) {
|
private fun cacheMapTiles(latitude: Double, longitude: Double) {
|
||||||
lifecycleScope.launch {
|
// Show prompt with OK / Skip choice
|
||||||
binding.statusText.text = getString(R.string.status_caching_map)
|
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(
|
mapCacheManager.cacheMapArea(
|
||||||
binding.mapView, latitude, longitude
|
binding.mapView, latitude, longitude
|
||||||
) { progress ->
|
) { progress ->
|
||||||
binding.statusText.text = getString(R.string.status_caching_map) +
|
binding.loadingText.text = getString(R.string.loading_map) +
|
||||||
" (${(progress * 100).toInt()}%)"
|
" (${(progress * 100).toInt()}%)"
|
||||||
}
|
}
|
||||||
|
hideLoading()
|
||||||
binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size)
|
binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showNoCacheBanner() {
|
||||||
|
binding.noCacheBanner.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
private fun forceRefresh() {
|
private fun forceRefresh() {
|
||||||
if (!isNetworkAvailable()) {
|
if (!isNetworkAvailable()) {
|
||||||
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
|
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) {
|
private fun showLoading(message: String) {
|
||||||
binding.loadingOverlay.visibility = View.VISIBLE
|
binding.loadingOverlay.visibility = View.VISIBLE
|
||||||
binding.loadingText.text = message
|
binding.loadingText.text = message
|
||||||
|
binding.loadingProgress.visibility = View.VISIBLE
|
||||||
|
binding.loadingButtonRow.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideLoading() {
|
private fun hideLoading() {
|
||||||
|
|
@ -413,8 +578,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateDirectionArrows() {
|
private fun updateDirectionArrows() {
|
||||||
if (nearestShelters.isEmpty()) return
|
val selected = selectedShelter ?: return
|
||||||
val selected = nearestShelters[selectedShelterIndex]
|
|
||||||
val bearing = selected.bearingDegrees.toFloat()
|
val bearing = selected.bearingDegrees.toFloat()
|
||||||
val arrowAngle = bearing - deviceHeading
|
val arrowAngle = bearing - deviceHeading
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,20 @@ package no.naiv.tilfluktsrom.data
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
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.util.GeoPoint
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages offline map tile caching for the surrounding area.
|
* Manages offline map tile caching for the surrounding area.
|
||||||
*
|
*
|
||||||
* On first launch, downloads map tiles for a region around the user's location.
|
* OSMDroid's SqlTileWriter automatically caches every tile the MapView loads.
|
||||||
* On subsequent launches, checks if the current location is within the cached area.
|
* 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) {
|
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_RADIUS = "cache_radius_km"
|
||||||
private const val KEY_CACHE_COMPLETE = "cache_complete"
|
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 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)
|
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
|
if (radius == 0.0) return false
|
||||||
|
|
||||||
// Check if current location is within the cached region (with some margin)
|
|
||||||
val margin = radius * 0.3
|
val margin = radius * 0.3
|
||||||
return Math.abs(latitude - cachedLat) < (radius - margin) &&
|
return Math.abs(latitude - cachedLat) < (radius - margin) &&
|
||||||
Math.abs(longitude - cachedLon) < (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).
|
* Reports progress via callback (0.0 to 1.0).
|
||||||
*/
|
*/
|
||||||
suspend fun cacheMapArea(
|
suspend fun cacheMapArea(
|
||||||
|
|
@ -63,93 +69,51 @@ class MapCacheManager(private val context: Context) {
|
||||||
onProgress: (Float) -> Unit = {}
|
onProgress: (Float) -> Unit = {}
|
||||||
): Boolean = withContext(Dispatchers.Main) {
|
): Boolean = withContext(Dispatchers.Main) {
|
||||||
try {
|
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(
|
val totalSteps = CACHE_ZOOM_LEVELS.size * GRID_SIZE * GRID_SIZE
|
||||||
latitude + CACHE_RADIUS_DEGREES,
|
var step = 0
|
||||||
longitude + CACHE_RADIUS_DEGREES,
|
|
||||||
latitude - CACHE_RADIUS_DEGREES,
|
|
||||||
longitude - CACHE_RADIUS_DEGREES
|
|
||||||
)
|
|
||||||
|
|
||||||
val cacheManager = CacheManager(mapView)
|
for (zoom in CACHE_ZOOM_LEVELS) {
|
||||||
var complete = false
|
mapView.controller.setZoom(zoom.toDouble())
|
||||||
var success = false
|
|
||||||
|
|
||||||
cacheManager.downloadAreaAsync(
|
// Pan across a grid of points covering the area
|
||||||
context,
|
for (row in 0 until GRID_SIZE) {
|
||||||
boundingBox,
|
for (col in 0 until GRID_SIZE) {
|
||||||
MIN_ZOOM,
|
val lat = latitude - CACHE_RADIUS_DEGREES +
|
||||||
MAX_ZOOM,
|
(2 * CACHE_RADIUS_DEGREES * row) / (GRID_SIZE - 1)
|
||||||
object : CacheManager.CacheManagerCallback {
|
val lon = longitude - CACHE_RADIUS_DEGREES +
|
||||||
override fun onTaskComplete() {
|
(2 * CACHE_RADIUS_DEGREES * col) / (GRID_SIZE - 1)
|
||||||
Log.i(TAG, "Map cache download complete")
|
|
||||||
success = true
|
mapView.controller.setCenter(GeoPoint(lat, lon))
|
||||||
complete = true
|
// 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) {
|
// Restore to user's location
|
||||||
prefs.edit()
|
mapView.controller.setZoom(14.0)
|
||||||
.putLong(KEY_CACHED_LAT, latitude.toBits())
|
mapView.controller.setCenter(GeoPoint(latitude, longitude))
|
||||||
.putLong(KEY_CACHED_LON, longitude.toBits())
|
|
||||||
.putFloat(KEY_CACHE_RADIUS, CACHE_RADIUS_DEGREES.toFloat())
|
|
||||||
.putBoolean(KEY_CACHE_COMPLETE, true)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to cache map tiles", e)
|
Log.e(TAG, "Failed to seed tile cache", e)
|
||||||
false
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
app/src/main/res/drawable/ic_my_location.xml
Normal file
11
app/src/main/res/drawable/ic_my_location.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Crosshair / recenter icon for "reset to navigation" -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94V1h-2v2.06C6.83,3.52 3.52,6.83 3.06,11H1v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94V23h2v-2.06c4.17,-0.46 7.48,-3.77 7.94,-7.94H23v-2h-2.06zM12,19c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z" />
|
||||||
|
</vector>
|
||||||
23
app/src/main/res/drawable/ic_shelter_selected.xml
Normal file
23
app/src/main/res/drawable/ic_shelter_selected.xml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Selected shelter marker: larger, amber fill with white outline for emphasis -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="36dp"
|
||||||
|
android:height="36dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- White outline (slightly larger path) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,1 L2,6.5 L2,12 C2,18 6.2,23.5 12,25 C17.8,23.5 22,18 22,12 L22,6.5 Z" />
|
||||||
|
|
||||||
|
<!-- Shield body (amber) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFC107"
|
||||||
|
android:pathData="M12,2 L3,7 L3,12 C3,17.5 6.8,22.7 12,24 C17.2,22.7 21,17.5 21,12 L21,7 Z" />
|
||||||
|
|
||||||
|
<!-- Inner cross/plus symbol -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1A1A2E"
|
||||||
|
android:pathData="M11,8 L13,8 L13,11 L16,11 L16,13 L13,13 L13,16 L11,16 L11,13 L8,13 L8,11 L11,11 Z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
android:fitsSystemWindows="true"
|
||||||
android:background="@color/background"
|
android:background="@color/background"
|
||||||
tools:context=".MainActivity">
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
|
@ -84,6 +85,22 @@
|
||||||
tools:text="Storgata 1" />
|
tools:text="Storgata 1" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- Reset to navigation mode (re-fit map to user + selected shelter) -->
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/resetNavigationFab"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:contentDescription="@string/action_reset_navigation"
|
||||||
|
android:src="@drawable/ic_my_location"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:fabSize="mini"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/bottomSheet"
|
||||||
|
app:backgroundTint="@color/status_bar_bg"
|
||||||
|
app:tint="@color/white" />
|
||||||
|
|
||||||
<!-- Toggle button: map <-> compass -->
|
<!-- Toggle button: map <-> compass -->
|
||||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
android:id="@+id/toggleViewFab"
|
android:id="@+id/toggleViewFab"
|
||||||
|
|
@ -97,6 +114,38 @@
|
||||||
app:backgroundTint="@color/shelter_primary"
|
app:backgroundTint="@color/shelter_primary"
|
||||||
app:tint="@color/white" />
|
app:tint="@color/white" />
|
||||||
|
|
||||||
|
<!-- Warning banner: no offline map cache -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/noCacheBanner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/warning_bg"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingVertical="6dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/bottomSheet">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/warning_no_map_cache"
|
||||||
|
android:textColor="@color/warning_text"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cacheRetryButton"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:text="@string/action_cache_now"
|
||||||
|
android:textColor="@color/warning_text"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:minHeight="0dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Bottom sheet with shelter info -->
|
<!-- Bottom sheet with shelter info -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/bottomSheet"
|
android:id="@+id/bottomSheet"
|
||||||
|
|
@ -183,9 +232,38 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
|
android:paddingHorizontal="32dp"
|
||||||
|
android:textAlignment="center"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
tools:text="@string/loading_shelters" />
|
tools:text="@string/loading_shelters" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/loadingButtonRow"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/loadingSkipButton"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:text="@string/action_skip"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
app:strokeColor="@color/white" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/loadingOkButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/action_cache_ok"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
app:backgroundTint="@color/shelter_primary" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<!-- Lasteskjerm -->
|
<!-- Lasteskjerm -->
|
||||||
<string name="loading_shelters">Laster ned tilfluktsromdata…</string>
|
<string name="loading_shelters">Laster ned tilfluktsromdata…</string>
|
||||||
<string name="loading_map">Lagrer kartfliser…</string>
|
<string name="loading_map">Lagrer kartfliser…</string>
|
||||||
|
<string name="loading_map_explanation">Forbereder frakoblet kart.\nKartet vil rulle kort for å lagre omgivelsene dine.</string>
|
||||||
<string name="loading_first_time">Gjør klar for første gangs bruk…</string>
|
<string name="loading_first_time">Gjør klar for første gangs bruk…</string>
|
||||||
|
|
||||||
<!-- Tilfluktsrominfo -->
|
<!-- Tilfluktsrominfo -->
|
||||||
|
|
@ -25,6 +26,11 @@
|
||||||
<!-- Handlinger -->
|
<!-- Handlinger -->
|
||||||
<string name="action_refresh">Oppdater data</string>
|
<string name="action_refresh">Oppdater data</string>
|
||||||
<string name="action_toggle_view">Bytt mellom kart og kompassvisning</string>
|
<string name="action_toggle_view">Bytt mellom kart og kompassvisning</string>
|
||||||
|
<string name="action_skip">Hopp over</string>
|
||||||
|
<string name="action_cache_ok">Lagre kart</string>
|
||||||
|
<string name="action_cache_now">Lagre nå</string>
|
||||||
|
<string name="action_reset_navigation">Tilbakestill navigasjonsvisning</string>
|
||||||
|
<string name="warning_no_map_cache">Ingen frakoblet kart lagret. Kartet krever internett.</string>
|
||||||
|
|
||||||
<!-- Tillatelser -->
|
<!-- Tillatelser -->
|
||||||
<string name="permission_location_title">Posisjonstillatelse kreves</string>
|
<string name="permission_location_title">Posisjonstillatelse kreves</string>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<!-- Lasteskjerm -->
|
<!-- Lasteskjerm -->
|
||||||
<string name="loading_shelters">Lastar ned tilfluktsromdata…</string>
|
<string name="loading_shelters">Lastar ned tilfluktsromdata…</string>
|
||||||
<string name="loading_map">Lagrar kartfliser…</string>
|
<string name="loading_map">Lagrar kartfliser…</string>
|
||||||
|
<string name="loading_map_explanation">Førebur fråkopla kart.\nKartet vil rulle kort for å lagre omgjevnadene dine.</string>
|
||||||
<string name="loading_first_time">Gjer klar for fyrste gongs bruk…</string>
|
<string name="loading_first_time">Gjer klar for fyrste gongs bruk…</string>
|
||||||
|
|
||||||
<!-- Tilfluktsrominfo -->
|
<!-- Tilfluktsrominfo -->
|
||||||
|
|
@ -25,6 +26,11 @@
|
||||||
<!-- Handlingar -->
|
<!-- Handlingar -->
|
||||||
<string name="action_refresh">Oppdater data</string>
|
<string name="action_refresh">Oppdater data</string>
|
||||||
<string name="action_toggle_view">Byt mellom kart og kompassvising</string>
|
<string name="action_toggle_view">Byt mellom kart og kompassvising</string>
|
||||||
|
<string name="action_skip">Hopp over</string>
|
||||||
|
<string name="action_cache_ok">Lagre kart</string>
|
||||||
|
<string name="action_cache_now">Lagre no</string>
|
||||||
|
<string name="action_reset_navigation">Tilbakestill navigasjonsvising</string>
|
||||||
|
<string name="warning_no_map_cache">Ingen fråkopla kart lagra. Kartet krev internett.</string>
|
||||||
|
|
||||||
<!-- Løyve -->
|
<!-- Løyve -->
|
||||||
<string name="permission_location_title">Posisjonsløyve krevst</string>
|
<string name="permission_location_title">Posisjonsløyve krevst</string>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@
|
||||||
<color name="text_primary">#ECEFF1</color>
|
<color name="text_primary">#ECEFF1</color>
|
||||||
<color name="text_secondary">#90A4AE</color>
|
<color name="text_secondary">#90A4AE</color>
|
||||||
|
|
||||||
|
<color name="warning_bg">#E65100</color>
|
||||||
|
<color name="warning_text">#FFFFFF</color>
|
||||||
|
|
||||||
<color name="white">#FFFFFF</color>
|
<color name="white">#FFFFFF</color>
|
||||||
<color name="black">#000000</color>
|
<color name="black">#000000</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<!-- Loading screen -->
|
<!-- Loading screen -->
|
||||||
<string name="loading_shelters">Downloading shelter data…</string>
|
<string name="loading_shelters">Downloading shelter data…</string>
|
||||||
<string name="loading_map">Caching map tiles…</string>
|
<string name="loading_map">Caching map tiles…</string>
|
||||||
|
<string name="loading_map_explanation">Preparing offline map.\nThe map will scroll briefly to cache your surroundings.</string>
|
||||||
<string name="loading_first_time">Setting up for first use…</string>
|
<string name="loading_first_time">Setting up for first use…</string>
|
||||||
|
|
||||||
<!-- Shelter info -->
|
<!-- Shelter info -->
|
||||||
|
|
@ -25,6 +26,11 @@
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<string name="action_refresh">Refresh data</string>
|
<string name="action_refresh">Refresh data</string>
|
||||||
<string name="action_toggle_view">Toggle map/compass view</string>
|
<string name="action_toggle_view">Toggle map/compass view</string>
|
||||||
|
<string name="action_skip">Skip</string>
|
||||||
|
<string name="action_cache_ok">Cache map</string>
|
||||||
|
<string name="action_cache_now">Cache now</string>
|
||||||
|
<string name="action_reset_navigation">Reset navigation view</string>
|
||||||
|
<string name="warning_no_map_cache">No offline map cached. Map requires internet.</string>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<string name="permission_location_title">Location permission required</string>
|
<string name="permission_location_title">Location permission required</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue