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.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])
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSelectedShelter() {
|
||||
if (nearestShelters.isEmpty()) return
|
||||
updateSelectedShelterUI()
|
||||
}
|
||||
|
||||
val selected = nearestShelters[selectedShelterIndex]
|
||||
/**
|
||||
* 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) }
|
||||
|
||||
// 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// Restore to user's location
|
||||
mapView.controller.setZoom(14.0)
|
||||
mapView.controller.setCenter(GeoPoint(latitude, longitude))
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
success
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
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" />
|
||||
</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 -->
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/toggleViewFab"
|
||||
|
|
@ -97,6 +114,38 @@
|
|||
app:backgroundTint="@color/shelter_primary"
|
||||
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 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bottomSheet"
|
||||
|
|
@ -183,9 +232,38 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
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>
|
||||
</FrameLayout>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<!-- Lasteskjerm -->
|
||||
<string name="loading_shelters">Laster ned tilfluktsromdata…</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>
|
||||
|
||||
<!-- Tilfluktsrominfo -->
|
||||
|
|
@ -25,6 +26,11 @@
|
|||
<!-- Handlinger -->
|
||||
<string name="action_refresh">Oppdater data</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 -->
|
||||
<string name="permission_location_title">Posisjonstillatelse kreves</string>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<!-- Lasteskjerm -->
|
||||
<string name="loading_shelters">Lastar ned tilfluktsromdata…</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>
|
||||
|
||||
<!-- Tilfluktsrominfo -->
|
||||
|
|
@ -25,6 +26,11 @@
|
|||
<!-- Handlingar -->
|
||||
<string name="action_refresh">Oppdater data</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 -->
|
||||
<string name="permission_location_title">Posisjonsløyve krevst</string>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@
|
|||
<color name="text_primary">#ECEFF1</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="black">#000000</color>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<!-- Loading screen -->
|
||||
<string name="loading_shelters">Downloading shelter data…</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>
|
||||
|
||||
<!-- Shelter info -->
|
||||
|
|
@ -25,6 +26,11 @@
|
|||
<!-- Actions -->
|
||||
<string name="action_refresh">Refresh data</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 -->
|
||||
<string name="permission_location_title">Location permission required</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue