Initial commit: Norwegian emergency shelter finder app
Android app (Kotlin) that locates the nearest public shelter (tilfluktsrom) in Norway. Designed for offline-first emergency use. Features: - Downloads and caches all 556 Norwegian shelter locations from Geonorge - GPS-based nearest shelter finding with distance and bearing - OSMDroid map with offline tile caching for surroundings - Large directional compass arrow pointing to selected shelter - Compass sensor integration for real-time direction updates - Shows 3 nearest shelters with distance, capacity, and address - Toggle between map view and compass/arrow view - Auto-caches map tiles on first launch for offline use - Weekly background data refresh with manual force-update - i18n: Norwegian Bokmål, Nynorsk, and English Technical: - EPSG:25833 (UTM33N) → WGS84 coordinate conversion - Haversine distance and bearing calculations - Room database for shelter persistence - Fused Location Provider for precise GPS - Sensor fusion (rotation vector with accel+mag fallback) for compass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
27cad094e7
44 changed files with 2222 additions and 0 deletions
428
app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt
Normal file
428
app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
package no.naiv.tilfluktsrom
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.location.Location
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import no.naiv.tilfluktsrom.data.MapCacheManager
|
||||
import no.naiv.tilfluktsrom.data.Shelter
|
||||
import no.naiv.tilfluktsrom.data.ShelterRepository
|
||||
import no.naiv.tilfluktsrom.databinding.ActivityMainBinding
|
||||
import no.naiv.tilfluktsrom.location.LocationProvider
|
||||
import no.naiv.tilfluktsrom.location.ShelterFinder
|
||||
import no.naiv.tilfluktsrom.location.ShelterWithDistance
|
||||
import no.naiv.tilfluktsrom.ui.ShelterListAdapter
|
||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
|
||||
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
|
||||
|
||||
class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val DEFAULT_ZOOM = 14.0
|
||||
private const val NEAREST_COUNT = 3
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var repository: ShelterRepository
|
||||
private lateinit var locationProvider: LocationProvider
|
||||
private lateinit var mapCacheManager: MapCacheManager
|
||||
private lateinit var sensorManager: SensorManager
|
||||
private lateinit var shelterAdapter: ShelterListAdapter
|
||||
|
||||
private var myLocationOverlay: MyLocationNewOverlay? = null
|
||||
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 val locationPermissionRequest = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val fineGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
|
||||
val coarseGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||
|
||||
if (fineGranted || coarseGranted) {
|
||||
startLocationUpdates()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
repository = ShelterRepository(this)
|
||||
locationProvider = LocationProvider(this)
|
||||
mapCacheManager = MapCacheManager(this)
|
||||
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
|
||||
setupMap()
|
||||
setupShelterList()
|
||||
setupButtons()
|
||||
loadData()
|
||||
}
|
||||
|
||||
private fun setupMap() {
|
||||
binding.mapView.apply {
|
||||
setMultiTouchControls(true)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT)
|
||||
controller.setZoom(DEFAULT_ZOOM)
|
||||
// Default center: roughly central Norway
|
||||
controller.setCenter(GeoPoint(59.9, 10.7))
|
||||
|
||||
// Add user location overlay
|
||||
myLocationOverlay = MyLocationNewOverlay(
|
||||
GpsMyLocationProvider(this@MainActivity), this
|
||||
).apply {
|
||||
enableMyLocation()
|
||||
}
|
||||
overlays.add(myLocationOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupShelterList() {
|
||||
shelterAdapter = ShelterListAdapter { selected ->
|
||||
val idx = nearestShelters.indexOf(selected)
|
||||
if (idx >= 0) {
|
||||
selectedShelterIndex = idx
|
||||
updateSelectedShelter()
|
||||
}
|
||||
}
|
||||
|
||||
binding.shelterList.apply {
|
||||
layoutManager = LinearLayoutManager(this@MainActivity)
|
||||
adapter = shelterAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
binding.toggleViewFab.setOnClickListener {
|
||||
isCompassMode = !isCompassMode
|
||||
if (isCompassMode) {
|
||||
binding.mapView.visibility = View.GONE
|
||||
binding.compassContainer.visibility = View.VISIBLE
|
||||
binding.toggleViewFab.setImageResource(R.drawable.ic_map)
|
||||
} else {
|
||||
binding.mapView.visibility = View.VISIBLE
|
||||
binding.compassContainer.visibility = View.GONE
|
||||
binding.toggleViewFab.setImageResource(R.drawable.ic_compass)
|
||||
}
|
||||
}
|
||||
|
||||
binding.refreshButton.setOnClickListener {
|
||||
forceRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
lifecycleScope.launch {
|
||||
val hasData = repository.hasCachedData()
|
||||
|
||||
if (!hasData) {
|
||||
if (!isNetworkAvailable()) {
|
||||
binding.statusText.text = getString(R.string.error_no_data_offline)
|
||||
return@launch
|
||||
}
|
||||
showLoading(getString(R.string.loading_shelters))
|
||||
val success = repository.refreshData()
|
||||
hideLoading()
|
||||
|
||||
if (!success) {
|
||||
binding.statusText.text = getString(R.string.error_download_failed)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// Observe shelter data reactively
|
||||
launch {
|
||||
repository.getAllShelters().collectLatest { shelters ->
|
||||
allShelters = shelters
|
||||
binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size)
|
||||
updateShelterMarkers()
|
||||
currentLocation?.let { updateNearestShelters(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Request location and start updates
|
||||
requestLocationPermission()
|
||||
|
||||
// Check for stale data in background
|
||||
if (hasData && repository.isDataStale() && isNetworkAvailable()) {
|
||||
launch {
|
||||
val success = repository.refreshData()
|
||||
if (success) {
|
||||
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestLocationPermission() {
|
||||
if (locationProvider.hasLocationPermission()) {
|
||||
startLocationUpdates()
|
||||
} else {
|
||||
locationPermissionRequest.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
locationJob?.cancel()
|
||||
locationJob = lifecycleScope.launch {
|
||||
locationProvider.locationUpdates().collectLatest { location ->
|
||||
currentLocation = location
|
||||
updateNearestShelters(location)
|
||||
|
||||
// Center map on first location fix
|
||||
if (nearestShelters.isEmpty()) {
|
||||
binding.mapView.controller.animateTo(
|
||||
GeoPoint(location.latitude, location.longitude)
|
||||
)
|
||||
}
|
||||
|
||||
// Cache map tiles on first launch
|
||||
if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) {
|
||||
if (isNetworkAvailable()) {
|
||||
cacheMapTiles(location.latitude, location.longitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNearestShelters(location: Location) {
|
||||
if (allShelters.isEmpty()) return
|
||||
|
||||
nearestShelters = ShelterFinder.findNearest(
|
||||
allShelters, location.latitude, location.longitude, NEAREST_COUNT
|
||||
)
|
||||
|
||||
shelterAdapter.submitList(nearestShelters)
|
||||
selectedShelterIndex = 0
|
||||
shelterAdapter.selectPosition(0)
|
||||
updateSelectedShelter()
|
||||
}
|
||||
|
||||
private fun updateSelectedShelter() {
|
||||
if (nearestShelters.isEmpty()) return
|
||||
|
||||
val selected = nearestShelters[selectedShelterIndex]
|
||||
val distanceText = DistanceUtils.formatDistance(selected.distanceMeters)
|
||||
|
||||
// Update bottom sheet
|
||||
binding.selectedShelterAddress.text = selected.shelter.adresse
|
||||
binding.selectedShelterDetails.text = getString(
|
||||
R.string.shelter_room_nr, selected.shelter.romnr
|
||||
) + " - " + getString(
|
||||
R.string.shelter_capacity, selected.shelter.plasser
|
||||
) + " - " + distanceText
|
||||
|
||||
// Update mini arrow in bottom sheet
|
||||
val bearing = selected.bearingDegrees.toFloat()
|
||||
binding.miniArrow.setDirection(bearing - deviceHeading)
|
||||
|
||||
// Update compass view
|
||||
binding.compassDistanceText.text = distanceText
|
||||
binding.compassAddressText.text = selected.shelter.adresse
|
||||
binding.directionArrow.setDirection(bearing - deviceHeading)
|
||||
|
||||
// Center map on shelter if in map mode
|
||||
if (!isCompassMode) {
|
||||
highlightShelterOnMap(selected)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateShelterMarkers() {
|
||||
// Remove old markers
|
||||
shelterMarkers.forEach { binding.mapView.overlays.remove(it) }
|
||||
shelterMarkers.clear()
|
||||
|
||||
// Add markers for all shelters
|
||||
allShelters.forEach { shelter ->
|
||||
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)
|
||||
}
|
||||
shelterMarkers.add(marker)
|
||||
binding.mapView.overlays.add(marker)
|
||||
}
|
||||
|
||||
binding.mapView.invalidate()
|
||||
}
|
||||
|
||||
private fun highlightShelterOnMap(selected: ShelterWithDistance) {
|
||||
val shelterPoint = GeoPoint(selected.shelter.latitude, selected.shelter.longitude)
|
||||
|
||||
// If we have location, show both user and shelter in view
|
||||
currentLocation?.let { loc ->
|
||||
val userPoint = GeoPoint(loc.latitude, loc.longitude)
|
||||
val boundingBox = org.osmdroid.util.BoundingBox.fromGeoPoints(
|
||||
listOf(userPoint, shelterPoint)
|
||||
)
|
||||
// Add padding so markers aren't at the edge
|
||||
binding.mapView.zoomToBoundingBox(boundingBox.increaseByScale(1.5f), true)
|
||||
} ?: run {
|
||||
binding.mapView.controller.animateTo(shelterPoint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheMapTiles(latitude: Double, longitude: Double) {
|
||||
lifecycleScope.launch {
|
||||
binding.statusText.text = getString(R.string.status_caching_map)
|
||||
mapCacheManager.cacheMapArea(
|
||||
binding.mapView, latitude, longitude
|
||||
) { progress ->
|
||||
binding.statusText.text = getString(R.string.status_caching_map) +
|
||||
" (${(progress * 100).toInt()}%)"
|
||||
}
|
||||
binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceRefresh() {
|
||||
if (!isNetworkAvailable()) {
|
||||
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
binding.statusText.text = getString(R.string.status_updating)
|
||||
val success = repository.refreshData()
|
||||
if (success) {
|
||||
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoading(message: String) {
|
||||
binding.loadingOverlay.visibility = View.VISIBLE
|
||||
binding.loadingText.text = message
|
||||
}
|
||||
|
||||
private fun hideLoading() {
|
||||
binding.loadingOverlay.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun isNetworkAvailable(): Boolean {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
// --- Sensor handling for compass ---
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
binding.mapView.onResume()
|
||||
|
||||
// Register for rotation vector (best compass source)
|
||||
val rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
if (rotationSensor != null) {
|
||||
sensorManager.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_UI)
|
||||
} else {
|
||||
// Fallback to accelerometer + magnetometer
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI)
|
||||
}
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)?.let {
|
||||
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
binding.mapView.onPause()
|
||||
sensorManager.unregisterListener(this)
|
||||
}
|
||||
|
||||
private val gravity = FloatArray(3)
|
||||
private val geomagnetic = FloatArray(3)
|
||||
private val rotationMatrix = FloatArray(9)
|
||||
private val orientation = FloatArray(3)
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ROTATION_VECTOR -> {
|
||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||
SensorManager.getOrientation(rotationMatrix, orientation)
|
||||
deviceHeading = Math.toDegrees(orientation[0].toDouble()).toFloat()
|
||||
if (deviceHeading < 0) deviceHeading += 360f
|
||||
updateDirectionArrows()
|
||||
}
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
System.arraycopy(event.values, 0, gravity, 0, 3)
|
||||
updateFromAccelMag()
|
||||
}
|
||||
Sensor.TYPE_MAGNETIC_FIELD -> {
|
||||
System.arraycopy(event.values, 0, geomagnetic, 0, 3)
|
||||
updateFromAccelMag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFromAccelMag() {
|
||||
val r = FloatArray(9)
|
||||
if (SensorManager.getRotationMatrix(r, null, gravity, geomagnetic)) {
|
||||
SensorManager.getOrientation(r, orientation)
|
||||
deviceHeading = Math.toDegrees(orientation[0].toDouble()).toFloat()
|
||||
if (deviceHeading < 0) deviceHeading += 360f
|
||||
updateDirectionArrows()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDirectionArrows() {
|
||||
if (nearestShelters.isEmpty()) return
|
||||
val selected = nearestShelters[selectedShelterIndex]
|
||||
val bearing = selected.bearingDegrees.toFloat()
|
||||
val arrowAngle = bearing - deviceHeading
|
||||
|
||||
binding.directionArrow.setDirection(arrowAngle)
|
||||
binding.miniArrow.setDirection(arrowAngle)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/no/naiv/tilfluktsrom/TilfluktsromApp.kt
Normal file
19
app/src/main/java/no/naiv/tilfluktsrom/TilfluktsromApp.kt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package no.naiv.tilfluktsrom
|
||||
|
||||
import android.app.Application
|
||||
import org.osmdroid.config.Configuration
|
||||
|
||||
class TilfluktsromApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Configure OSMDroid: set user agent and tile cache path
|
||||
Configuration.getInstance().apply {
|
||||
userAgentValue = packageName
|
||||
// Use app-specific internal storage for tile cache
|
||||
osmdroidBasePath = filesDir
|
||||
osmdroidTileCache = java.io.File(filesDir, "tiles")
|
||||
}
|
||||
}
|
||||
}
|
||||
155
app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt
Normal file
155
app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package no.naiv.tilfluktsrom.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.
|
||||
*/
|
||||
class MapCacheManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MapCacheManager"
|
||||
private const val PREFS_NAME = "map_cache_prefs"
|
||||
private const val KEY_CACHED_LAT = "cached_center_lat"
|
||||
private const val KEY_CACHED_LON = "cached_center_lon"
|
||||
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
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Check if we have a map cache that covers the given location.
|
||||
*/
|
||||
fun hasCacheForLocation(latitude: Double, longitude: Double): Boolean {
|
||||
if (!prefs.getBoolean(KEY_CACHE_COMPLETE, false)) return false
|
||||
|
||||
val cachedLat = Double.fromBits(prefs.getLong(KEY_CACHED_LAT, 0))
|
||||
val cachedLon = Double.fromBits(prefs.getLong(KEY_CACHED_LON, 0))
|
||||
val radius = prefs.getFloat(KEY_CACHE_RADIUS, 0f).toDouble()
|
||||
|
||||
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.
|
||||
* Reports progress via callback (0.0 to 1.0).
|
||||
*/
|
||||
suspend fun cacheMapArea(
|
||||
mapView: MapView,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
onProgress: (Float) -> Unit = {}
|
||||
): Boolean = withContext(Dispatchers.Main) {
|
||||
try {
|
||||
Log.i(TAG, "Starting map 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 cacheManager = CacheManager(mapView)
|
||||
var complete = false
|
||||
var success = false
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cache map tiles", 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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt
Normal file
19
app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package no.naiv.tilfluktsrom.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* A public shelter (offentlig tilfluktsrom).
|
||||
* Coordinates are stored in WGS84 (EPSG:4326) after conversion from UTM33N.
|
||||
*/
|
||||
@Entity(tableName = "shelters")
|
||||
data class Shelter(
|
||||
@PrimaryKey
|
||||
val lokalId: String,
|
||||
val romnr: Int,
|
||||
val plasser: Int,
|
||||
val adresse: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double
|
||||
)
|
||||
26
app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDao.kt
Normal file
26
app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDao.kt
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package no.naiv.tilfluktsrom.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ShelterDao {
|
||||
|
||||
@Query("SELECT * FROM shelters")
|
||||
fun getAllShelters(): Flow<List<Shelter>>
|
||||
|
||||
@Query("SELECT * FROM shelters")
|
||||
suspend fun getAllSheltersList(): List<Shelter>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(shelters: List<Shelter>)
|
||||
|
||||
@Query("DELETE FROM shelters")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Query("SELECT COUNT(*) FROM shelters")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package no.naiv.tilfluktsrom.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(entities = [Shelter::class], version = 1, exportSchema = false)
|
||||
abstract class ShelterDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun shelterDao(): ShelterDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: ShelterDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): ShelterDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
ShelterDatabase::class.java,
|
||||
"shelters.db"
|
||||
).build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package no.naiv.tilfluktsrom.data
|
||||
|
||||
import no.naiv.tilfluktsrom.util.CoordinateConverter
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Parses shelter GeoJSON data from the Geonorge ZIP download.
|
||||
* Converts coordinates from UTM33N (EPSG:25833) to WGS84 (EPSG:4326).
|
||||
*/
|
||||
object ShelterGeoJsonParser {
|
||||
|
||||
/**
|
||||
* Extract and parse GeoJSON from a ZIP input stream.
|
||||
*/
|
||||
fun parseFromZip(zipStream: InputStream): List<Shelter> {
|
||||
val json = extractGeoJsonFromZip(zipStream)
|
||||
return parseGeoJson(json)
|
||||
}
|
||||
|
||||
private fun extractGeoJsonFromZip(zipStream: InputStream): String {
|
||||
ZipInputStream(zipStream).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name.endsWith(".geojson") || entry.name.endsWith(".json")) {
|
||||
val buffer = ByteArrayOutputStream()
|
||||
zis.copyTo(buffer)
|
||||
return buffer.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("No GeoJSON file found in ZIP archive")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw GeoJSON string into Shelter objects.
|
||||
*/
|
||||
fun parseGeoJson(json: String): List<Shelter> {
|
||||
val root = JSONObject(json)
|
||||
val features = root.getJSONArray("features")
|
||||
val shelters = mutableListOf<Shelter>()
|
||||
|
||||
for (i in 0 until features.length()) {
|
||||
val feature = features.getJSONObject(i)
|
||||
val geometry = feature.getJSONObject("geometry")
|
||||
val properties = feature.getJSONObject("properties")
|
||||
|
||||
val coordinates = geometry.getJSONArray("coordinates")
|
||||
val easting = coordinates.getDouble(0)
|
||||
val northing = coordinates.getDouble(1)
|
||||
|
||||
// Convert UTM33N to WGS84
|
||||
val latLon = CoordinateConverter.utm33nToWgs84(easting, northing)
|
||||
|
||||
shelters.add(
|
||||
Shelter(
|
||||
lokalId = properties.optString("lokalId", "unknown-$i"),
|
||||
romnr = properties.optInt("romnr", 0),
|
||||
plasser = properties.optInt("plasser", 0),
|
||||
adresse = properties.optString("adresse", ""),
|
||||
latitude = latLon.latitude,
|
||||
longitude = latLon.longitude
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return shelters
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package no.naiv.tilfluktsrom.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Repository managing shelter data: local Room cache + remote Geonorge download.
|
||||
* Offline-first: always returns cached data when available, updates in background.
|
||||
*/
|
||||
class ShelterRepository(context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ShelterRepository"
|
||||
private const val PREFS_NAME = "shelter_prefs"
|
||||
private const val KEY_LAST_UPDATE = "last_update_ms"
|
||||
private const val UPDATE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days
|
||||
|
||||
// Geonorge GeoJSON download (ZIP containing all Norwegian shelters)
|
||||
private const val SHELTER_DATA_URL =
|
||||
"https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/" +
|
||||
"TilfluktsromOffentlige/GeoJSON/" +
|
||||
"Samfunnssikkerhet_0000_Norge_25833_TilfluktsromOffentlige_GeoJSON.zip"
|
||||
}
|
||||
|
||||
private val db = ShelterDatabase.getInstance(context)
|
||||
private val dao = db.shelterDao()
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/** Reactive stream of all shelters from local cache. */
|
||||
fun getAllShelters(): Flow<List<Shelter>> = dao.getAllShelters()
|
||||
|
||||
/** Check if we have cached shelter data. */
|
||||
suspend fun hasCachedData(): Boolean = dao.count() > 0
|
||||
|
||||
/** Check if the cached data is stale and should be refreshed. */
|
||||
fun isDataStale(): Boolean {
|
||||
val lastUpdate = prefs.getLong(KEY_LAST_UPDATE, 0)
|
||||
return System.currentTimeMillis() - lastUpdate > UPDATE_INTERVAL_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Download shelter data from Geonorge and cache it locally.
|
||||
* Returns true on success, false on failure.
|
||||
*/
|
||||
suspend fun refreshData(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.i(TAG, "Downloading shelter data from Geonorge...")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(SHELTER_DATA_URL)
|
||||
.header("User-Agent", "Tilfluktsrom-Android/1.0")
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(TAG, "Download failed: HTTP ${response.code}")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val body = response.body ?: run {
|
||||
Log.e(TAG, "Empty response body")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val shelters = body.byteStream().use { stream ->
|
||||
ShelterGeoJsonParser.parseFromZip(stream)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Parsed ${shelters.size} shelters, saving to database...")
|
||||
|
||||
dao.deleteAll()
|
||||
dao.insertAll(shelters)
|
||||
|
||||
prefs.edit().putLong(KEY_LAST_UPDATE, System.currentTimeMillis()).apply()
|
||||
Log.i(TAG, "Shelter data updated successfully")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to refresh shelter data", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package no.naiv.tilfluktsrom.location
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.location.FusedLocationProviderClient
|
||||
import com.google.android.gms.location.LocationCallback
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationResult
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
/**
|
||||
* Provides GPS location updates using the Fused Location Provider.
|
||||
* Emits location updates as a Flow for reactive consumption.
|
||||
*/
|
||||
class LocationProvider(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LocationProvider"
|
||||
private const val UPDATE_INTERVAL_MS = 5000L
|
||||
private const val FASTEST_INTERVAL_MS = 2000L
|
||||
}
|
||||
|
||||
private val fusedClient: FusedLocationProviderClient =
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
|
||||
/**
|
||||
* Stream of location updates. Emits the last known location first (if available),
|
||||
* then continuous updates.
|
||||
*/
|
||||
fun locationUpdates(): Flow<Location> = callbackFlow {
|
||||
if (!hasLocationPermission()) {
|
||||
Log.w(TAG, "Location permission not granted")
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
// Try to get last known location for immediate display
|
||||
try {
|
||||
fusedClient.lastLocation.addOnSuccessListener { location ->
|
||||
if (location != null) {
|
||||
trySend(location)
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException getting last location", e)
|
||||
}
|
||||
|
||||
val locationRequest = LocationRequest.Builder(
|
||||
Priority.PRIORITY_HIGH_ACCURACY,
|
||||
UPDATE_INTERVAL_MS
|
||||
).apply {
|
||||
setMinUpdateIntervalMillis(FASTEST_INTERVAL_MS)
|
||||
setWaitForAccurateLocation(false)
|
||||
}.build()
|
||||
|
||||
val callback = object : LocationCallback() {
|
||||
override fun onLocationResult(result: LocationResult) {
|
||||
result.lastLocation?.let { trySend(it) }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fusedClient.requestLocationUpdates(
|
||||
locationRequest,
|
||||
callback,
|
||||
Looper.getMainLooper()
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException requesting location updates", e)
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
fusedClient.removeLocationUpdates(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasLocationPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package no.naiv.tilfluktsrom.location
|
||||
|
||||
import no.naiv.tilfluktsrom.data.Shelter
|
||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||
|
||||
/**
|
||||
* Result containing a shelter and its distance/bearing from the user.
|
||||
*/
|
||||
data class ShelterWithDistance(
|
||||
val shelter: Shelter,
|
||||
val distanceMeters: Double,
|
||||
val bearingDegrees: Double
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds the nearest shelters to a given location.
|
||||
*/
|
||||
object ShelterFinder {
|
||||
|
||||
/**
|
||||
* Find the N nearest shelters to the given location.
|
||||
* Returns results sorted by distance (nearest first).
|
||||
*/
|
||||
fun findNearest(
|
||||
shelters: List<Shelter>,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
count: Int = 3
|
||||
): List<ShelterWithDistance> {
|
||||
return shelters
|
||||
.map { shelter ->
|
||||
ShelterWithDistance(
|
||||
shelter = shelter,
|
||||
distanceMeters = DistanceUtils.distanceMeters(
|
||||
latitude, longitude,
|
||||
shelter.latitude, shelter.longitude
|
||||
),
|
||||
bearingDegrees = DistanceUtils.bearingDegrees(
|
||||
latitude, longitude,
|
||||
shelter.latitude, shelter.longitude
|
||||
)
|
||||
)
|
||||
}
|
||||
.sortedBy { it.distanceMeters }
|
||||
.take(count)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package no.naiv.tilfluktsrom.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import no.naiv.tilfluktsrom.R
|
||||
|
||||
/**
|
||||
* Custom view that displays a large directional arrow pointing toward
|
||||
* the target shelter. The arrow rotates based on the bearing to the
|
||||
* shelter relative to the device heading (compass).
|
||||
*
|
||||
* rotationAngle = shelterBearing - deviceHeading
|
||||
* This gives the direction the user needs to walk, adjusted for which
|
||||
* way they're currently facing.
|
||||
*/
|
||||
class DirectionArrowView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var rotationAngle = 0f
|
||||
|
||||
private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = context.getColor(R.color.shelter_primary)
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val outlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 4f
|
||||
}
|
||||
|
||||
private val arrowPath = Path()
|
||||
|
||||
/**
|
||||
* Set the rotation angle in degrees.
|
||||
* 0 = pointing up (north/forward), positive = clockwise.
|
||||
*/
|
||||
fun setDirection(degrees: Float) {
|
||||
rotationAngle = degrees
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val size = minOf(width, height) * 0.4f
|
||||
|
||||
canvas.save()
|
||||
canvas.rotate(rotationAngle, cx, cy)
|
||||
|
||||
// Draw arrow pointing up
|
||||
arrowPath.reset()
|
||||
arrowPath.moveTo(cx, cy - size) // tip
|
||||
arrowPath.lineTo(cx + size * 0.5f, cy + size * 0.3f) // right
|
||||
arrowPath.lineTo(cx + size * 0.15f, cy + size * 0.1f)
|
||||
arrowPath.lineTo(cx + size * 0.15f, cy + size * 0.7f) // right tail
|
||||
arrowPath.lineTo(cx - size * 0.15f, cy + size * 0.7f) // left tail
|
||||
arrowPath.lineTo(cx - size * 0.15f, cy + size * 0.1f)
|
||||
arrowPath.lineTo(cx - size * 0.5f, cy + size * 0.3f) // left
|
||||
arrowPath.close()
|
||||
|
||||
canvas.drawPath(arrowPath, arrowPaint)
|
||||
canvas.drawPath(arrowPath, outlinePaint)
|
||||
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package no.naiv.tilfluktsrom.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import no.naiv.tilfluktsrom.R
|
||||
import no.naiv.tilfluktsrom.databinding.ItemShelterBinding
|
||||
import no.naiv.tilfluktsrom.location.ShelterWithDistance
|
||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||
|
||||
/**
|
||||
* Adapter for the list of nearest shelters shown in the bottom sheet.
|
||||
*/
|
||||
class ShelterListAdapter(
|
||||
private val onShelterSelected: (ShelterWithDistance) -> Unit
|
||||
) : ListAdapter<ShelterWithDistance, ShelterListAdapter.ViewHolder>(DIFF_CALLBACK) {
|
||||
|
||||
private var selectedPosition = 0
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemShelterBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(getItem(position), position == selectedPosition)
|
||||
}
|
||||
|
||||
fun selectPosition(position: Int) {
|
||||
val old = selectedPosition
|
||||
selectedPosition = position
|
||||
notifyItemChanged(old)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
private val binding: ItemShelterBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: ShelterWithDistance, isSelected: Boolean) {
|
||||
val ctx = binding.root.context
|
||||
binding.shelterAddress.text = item.shelter.adresse
|
||||
binding.shelterDistance.text = DistanceUtils.formatDistance(item.distanceMeters)
|
||||
binding.shelterCapacity.text = ctx.getString(
|
||||
R.string.shelter_capacity, item.shelter.plasser
|
||||
)
|
||||
binding.shelterRoomNr.text = ctx.getString(
|
||||
R.string.shelter_room_nr, item.shelter.romnr
|
||||
)
|
||||
|
||||
binding.root.isSelected = isSelected
|
||||
binding.root.alpha = if (isSelected) 1.0f else 0.7f
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
val pos = adapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) {
|
||||
selectPosition(pos)
|
||||
onShelterSelected(getItem(pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ShelterWithDistance>() {
|
||||
override fun areItemsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) =
|
||||
a.shelter.lokalId == b.shelter.lokalId
|
||||
|
||||
override fun areContentsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) =
|
||||
a == b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package no.naiv.tilfluktsrom.util
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* Converts UTM zone 33N (EPSG:25833) coordinates to WGS84 (EPSG:4326).
|
||||
*
|
||||
* The shelter data from Geonorge uses EUREF89 UTM zone 33N. EUREF89 is
|
||||
* identical to WGS84 for all practical purposes (sub-meter difference).
|
||||
*
|
||||
* The conversion uses the Karney method (series expansion) for accuracy.
|
||||
*/
|
||||
object CoordinateConverter {
|
||||
|
||||
// WGS84 ellipsoid parameters
|
||||
private const val A = 6378137.0 // semi-major axis (meters)
|
||||
private const val F = 1.0 / 298.257223563 // flattening
|
||||
private const val E2 = 2 * F - F * F // eccentricity squared
|
||||
|
||||
// UTM parameters
|
||||
private const val K0 = 0.9996 // scale factor
|
||||
private const val FALSE_EASTING = 500000.0
|
||||
private const val FALSE_NORTHING = 0.0 // northern hemisphere
|
||||
private const val ZONE_33_CENTRAL_MERIDIAN = 15.0 // degrees
|
||||
|
||||
data class LatLon(val latitude: Double, val longitude: Double)
|
||||
|
||||
/**
|
||||
* Convert UTM33N easting/northing to WGS84 latitude/longitude.
|
||||
*/
|
||||
fun utm33nToWgs84(easting: Double, northing: Double): LatLon {
|
||||
val x = easting - FALSE_EASTING
|
||||
val y = northing - FALSE_NORTHING
|
||||
|
||||
val e1 = (1 - sqrt(1 - E2)) / (1 + sqrt(1 - E2))
|
||||
|
||||
val m = y / K0
|
||||
val mu = m / (A * (1 - E2 / 4 - 3 * E2 * E2 / 64 - 5 * E2 * E2 * E2 / 256))
|
||||
|
||||
// Footprint latitude using series expansion
|
||||
val phi1 = mu +
|
||||
(3 * e1 / 2 - 27 * e1.pow(3) / 32) * sin(2 * mu) +
|
||||
(21 * e1 * e1 / 16 - 55 * e1.pow(4) / 32) * sin(4 * mu) +
|
||||
(151 * e1.pow(3) / 96) * sin(6 * mu) +
|
||||
(1097 * e1.pow(4) / 512) * sin(8 * mu)
|
||||
|
||||
val sinPhi1 = sin(phi1)
|
||||
val cosPhi1 = cos(phi1)
|
||||
val tanPhi1 = tan(phi1)
|
||||
|
||||
val n1 = A / sqrt(1 - E2 * sinPhi1 * sinPhi1)
|
||||
val t1 = tanPhi1 * tanPhi1
|
||||
val c1 = (E2 / (1 - E2)) * cosPhi1 * cosPhi1
|
||||
val r1 = A * (1 - E2) / (1 - E2 * sinPhi1 * sinPhi1).pow(1.5)
|
||||
val d = x / (n1 * K0)
|
||||
|
||||
val lat = phi1 - (n1 * tanPhi1 / r1) * (
|
||||
d * d / 2 -
|
||||
(5 + 3 * t1 + 10 * c1 - 4 * c1 * c1 - 9 * E2 / (1 - E2)) * d.pow(4) / 24 +
|
||||
(61 + 90 * t1 + 298 * c1 + 45 * t1 * t1 - 252 * E2 / (1 - E2) - 3 * c1 * c1) * d.pow(6) / 720
|
||||
)
|
||||
|
||||
val lon = (d -
|
||||
(1 + 2 * t1 + c1) * d.pow(3) / 6 +
|
||||
(5 - 2 * c1 + 28 * t1 - 3 * c1 * c1 + 8 * E2 / (1 - E2) + 24 * t1 * t1) * d.pow(5) / 120
|
||||
) / cosPhi1
|
||||
|
||||
return LatLon(
|
||||
latitude = Math.toDegrees(lat),
|
||||
longitude = ZONE_33_CENTRAL_MERIDIAN + Math.toDegrees(lon)
|
||||
)
|
||||
}
|
||||
}
|
||||
47
app/src/main/java/no/naiv/tilfluktsrom/util/DistanceUtils.kt
Normal file
47
app/src/main/java/no/naiv/tilfluktsrom/util/DistanceUtils.kt
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package no.naiv.tilfluktsrom.util
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* Distance and bearing calculations using the Haversine formula.
|
||||
*/
|
||||
object DistanceUtils {
|
||||
|
||||
private const val EARTH_RADIUS_METERS = 6371000.0
|
||||
|
||||
/**
|
||||
* Calculate the distance in meters between two WGS84 points.
|
||||
*/
|
||||
fun distanceMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val dLat = Math.toRadians(lat2 - lat1)
|
||||
val dLon = Math.toRadians(lon2 - lon1)
|
||||
val a = sin(dLat / 2).pow(2) +
|
||||
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
|
||||
sin(dLon / 2).pow(2)
|
||||
return EARTH_RADIUS_METERS * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the initial bearing (in degrees, 0=north, clockwise)
|
||||
* from point 1 to point 2.
|
||||
*/
|
||||
fun bearingDegrees(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val phi1 = Math.toRadians(lat1)
|
||||
val phi2 = Math.toRadians(lat2)
|
||||
val dLambda = Math.toRadians(lon2 - lon1)
|
||||
val y = sin(dLambda) * cos(phi2)
|
||||
val x = cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(dLambda)
|
||||
return (Math.toDegrees(atan2(y, x)) + 360) % 360
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance for display: meters if <1km, otherwise km with one decimal.
|
||||
*/
|
||||
fun formatDistance(meters: Double): String {
|
||||
return if (meters < 1000) {
|
||||
"${meters.toInt()} m"
|
||||
} else {
|
||||
"${"%.1f".format(meters / 1000)} km"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue