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
70
app/build.gradle.kts
Normal file
70
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "no.naiv.tilfluktsrom"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "no.naiv.tilfluktsrom"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.2.0")
|
||||
|
||||
// Room (local database for shelter cache)
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
// OkHttp (HTTP client for data downloads)
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// OSMDroid (offline-capable OpenStreetMap)
|
||||
implementation("org.osmdroid:osmdroid-android:6.1.20")
|
||||
|
||||
// Google Play Services Location (precise GPS)
|
||||
implementation("com.google.android.gms:play-services-location:21.3.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
}
|
||||
12
app/proguard-rules.pro
vendored
Normal file
12
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# OSMDroid
|
||||
-keep class org.osmdroid.** { *; }
|
||||
-dontwarn org.osmdroid.**
|
||||
|
||||
# Room
|
||||
-keep class * extends androidx.room.RoomDatabase
|
||||
-keep @androidx.room.Entity class *
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
32
app/src/main/AndroidManifest.xml
Normal file
32
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<!-- Write to external storage for OSMDroid tile cache on older devices -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
|
||||
<application
|
||||
android:name=".TilfluktsromApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Tilfluktsrom"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustPan">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/src/main/res/drawable/ic_compass.xml
Normal file
19
app/src/main/res/drawable/ic_compass.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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,2 C6.48,2 2,6.48 2,12 C2,17.52 6.48,22 12,22 C17.52,22 22,17.52 22,12 C22,6.48 17.52,2 12,2 Z M12,20 C7.59,20 4,16.41 4,12 C4,7.59 7.59,4 12,4 C16.41,4 20,7.59 20,12 C20,16.41 16.41,20 12,20 Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14.5,12 L12,2 L9.5,12 L12,22 Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#CCFFFFFF"
|
||||
android:pathData="M12,2 L14.5,12 L12,22 Z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
11
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:fillColor="#1A1A2E"
|
||||
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
16
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Shelter shield icon centered in adaptive icon safe zone -->
|
||||
<path
|
||||
android:fillColor="#FF6B35"
|
||||
android:pathData="M54,24 L30,36 L30,54 C30,70 39,83 54,87 C69,83 78,70 78,54 L78,36 Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M51,42 L57,42 L57,51 L66,51 L66,57 L57,57 L57,66 L51,66 L51,57 L42,57 L42,51 L51,51 Z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_map.xml
Normal file
11
app/src/main/res/drawable/ic_map.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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="M20.5,3 L20.34,3.03 L15,5.1 L9,3 L3.36,4.9 C3.15,4.97 3,5.15 3,5.38 L3,20.5 C3,20.78 3.22,21 3.5,21 L3.66,20.97 L9,18.9 L15,21 L20.64,19.1 C20.85,19.03 21,18.85 21,18.62 L21,3.5 C21,3.22 20.78,3 20.5,3 Z M15,19 L9,16.89 L9,5 L15,7.11 Z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_refresh.xml
Normal file
11
app/src/main/res/drawable/ic_refresh.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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="M17.65,6.35 C16.2,4.9 14.21,4 12,4 C7.58,4 4.01,7.58 4.01,12 C4.01,16.42 7.58,20 12,20 C15.73,20 18.84,17.45 19.73,14 L17.65,14 C16.83,16.33 14.61,18 12,18 C8.69,18 6,15.31 6,12 C6,8.69 8.69,6 12,6 C13.66,6 15.14,6.69 16.22,7.78 L13,11 L20,11 L20,4 Z" />
|
||||
</vector>
|
||||
18
app/src/main/res/drawable/ic_shelter.xml
Normal file
18
app/src/main/res/drawable/ic_shelter.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Shelter marker icon: a shield/bunker symbol -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Shield body -->
|
||||
<path
|
||||
android:fillColor="#FF6B35"
|
||||
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 for shelter -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
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>
|
||||
192
app/src/main/res/layout/activity_main.xml
Normal file
192
app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<!-- Status bar at top -->
|
||||
<LinearLayout
|
||||
android:id="@+id/statusBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/status_bar_bg"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="@color/status_text"
|
||||
android:textSize="12sp"
|
||||
tools:text="@string/status_ready" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/refreshButton"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_refresh"
|
||||
android:src="@drawable/ic_refresh"
|
||||
app:tint="@color/status_text" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Map view (main content) -->
|
||||
<org.osmdroid.views.MapView
|
||||
android:id="@+id/mapView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomSheet" />
|
||||
|
||||
<!-- Direction arrow overlay (shown when toggled) -->
|
||||
<FrameLayout
|
||||
android:id="@+id/compassContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:background="@color/compass_bg"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomSheet">
|
||||
|
||||
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
||||
android:id="@+id/directionArrow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/compassDistanceText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="48sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="1.2 km" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/compassAddressText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|center_horizontal"
|
||||
android:layout_marginTop="24dp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
tools:text="Storgata 1" />
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Toggle button: map <-> compass -->
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/toggleViewFab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/action_toggle_view"
|
||||
android:src="@drawable/ic_compass"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomSheet"
|
||||
app:backgroundTint="@color/shelter_primary"
|
||||
app:tint="@color/white" />
|
||||
|
||||
<!-- Bottom sheet with shelter info -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bottomSheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/bottom_sheet_bg"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp"
|
||||
android:elevation="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<!-- Selected shelter summary -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
||||
android:id="@+id/miniArrow"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/selectedShelterAddress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Storgata 1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/selectedShelterDetails"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
tools:text="1.2 km - 400 plasser - Rom 776" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Nearest shelters list -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/shelterList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:clipToPadding="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Loading overlay for initial data download -->
|
||||
<FrameLayout
|
||||
android:id="@+id/loadingOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/loading_bg"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/loadingProgress"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:indeterminate="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/loadingText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
tools:text="@string/loading_shelters" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
54
app/src/main/res/layout/item_shelter.xml
Normal file
54
app/src/main/res/layout/item_shelter.xml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/shelterAddress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Storgata 1 - Bunker (off)" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/shelterDistance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/shelter_primary"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="1.2 km" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/shelterCapacity"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
tools:text="400 plasser" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/shelterRoomNr"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
tools:text="Rom 776" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
5
app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-xhdpi/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-xhdpi/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
39
app/src/main/res/values-nb/strings.xml
Normal file
39
app/src/main/res/values-nb/strings.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Tilfluktsrom</string>
|
||||
|
||||
<!-- Statusmeldinger -->
|
||||
<string name="status_ready">Klar</string>
|
||||
<string name="status_loading">Laster tilfluktsromdata…</string>
|
||||
<string name="status_updating">Oppdaterer…</string>
|
||||
<string name="status_offline">Frakoblet modus</string>
|
||||
<string name="status_shelters_loaded">%d tilfluktsrom lastet</string>
|
||||
<string name="status_no_location">Venter på GPS…</string>
|
||||
<string name="status_caching_map">Lagrer kart for frakoblet bruk…</string>
|
||||
|
||||
<!-- Lasteskjerm -->
|
||||
<string name="loading_shelters">Laster ned tilfluktsromdata…</string>
|
||||
<string name="loading_map">Lagrer kartfliser…</string>
|
||||
<string name="loading_first_time">Gjør klar for første gangs bruk…</string>
|
||||
|
||||
<!-- Tilfluktsrominfo -->
|
||||
<string name="shelter_capacity">%d plasser</string>
|
||||
<string name="shelter_room_nr">Rom %d</string>
|
||||
<string name="nearest_shelter">Nærmeste tilfluktsrom</string>
|
||||
<string name="no_shelters">Ingen tilfluktsromdata tilgjengelig</string>
|
||||
|
||||
<!-- Handlinger -->
|
||||
<string name="action_refresh">Oppdater data</string>
|
||||
<string name="action_toggle_view">Bytt mellom kart og kompassvisning</string>
|
||||
|
||||
<!-- Tillatelser -->
|
||||
<string name="permission_location_title">Posisjonstillatelse kreves</string>
|
||||
<string name="permission_location_message">Denne appen trenger din posisjon for å finne nærmeste tilfluktsrom. Vennligst gi tilgang til posisjon.</string>
|
||||
<string name="permission_denied">Posisjonstillatelse avslått. Appen kan ikke finne tilfluktsrom i nærheten uten den.</string>
|
||||
|
||||
<!-- Feil -->
|
||||
<string name="error_download_failed">Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.</string>
|
||||
<string name="error_no_data_offline">Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.</string>
|
||||
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
||||
<string name="update_failed">Oppdatering mislyktes — bruker lagrede data</string>
|
||||
</resources>
|
||||
39
app/src/main/res/values-nn/strings.xml
Normal file
39
app/src/main/res/values-nn/strings.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Tilfluktsrom</string>
|
||||
|
||||
<!-- Statusmeldingar -->
|
||||
<string name="status_ready">Klar</string>
|
||||
<string name="status_loading">Lastar tilfluktsromdata…</string>
|
||||
<string name="status_updating">Oppdaterer…</string>
|
||||
<string name="status_offline">Fråkopla modus</string>
|
||||
<string name="status_shelters_loaded">%d tilfluktsrom lasta</string>
|
||||
<string name="status_no_location">Ventar på GPS…</string>
|
||||
<string name="status_caching_map">Lagrar kart for fråkopla bruk…</string>
|
||||
|
||||
<!-- Lasteskjerm -->
|
||||
<string name="loading_shelters">Lastar ned tilfluktsromdata…</string>
|
||||
<string name="loading_map">Lagrar kartfliser…</string>
|
||||
<string name="loading_first_time">Gjer klar for fyrste gongs bruk…</string>
|
||||
|
||||
<!-- Tilfluktsrominfo -->
|
||||
<string name="shelter_capacity">%d plassar</string>
|
||||
<string name="shelter_room_nr">Rom %d</string>
|
||||
<string name="nearest_shelter">Næraste tilfluktsrom</string>
|
||||
<string name="no_shelters">Ingen tilfluktsromdata tilgjengeleg</string>
|
||||
|
||||
<!-- Handlingar -->
|
||||
<string name="action_refresh">Oppdater data</string>
|
||||
<string name="action_toggle_view">Byt mellom kart og kompassvising</string>
|
||||
|
||||
<!-- Løyve -->
|
||||
<string name="permission_location_title">Posisjonsløyve krevst</string>
|
||||
<string name="permission_location_message">Denne appen treng posisjonen din for å finne næraste tilfluktsrom. Ver venleg og gje tilgang til posisjon.</string>
|
||||
<string name="permission_denied">Posisjonsløyve avslått. Appen kan ikkje finne tilfluktsrom i nærleiken utan det.</string>
|
||||
|
||||
<!-- Feil -->
|
||||
<string name="error_download_failed">Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.</string>
|
||||
<string name="error_no_data_offline">Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.</string>
|
||||
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
||||
<string name="update_failed">Oppdatering mislukkast — brukar lagra data</string>
|
||||
</resources>
|
||||
20
app/src/main/res/values/colors.xml
Normal file
20
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Emergency-themed color palette: high contrast, easily visible -->
|
||||
<color name="shelter_primary">#FF6B35</color>
|
||||
<color name="shelter_primary_dark">#E55A2B</color>
|
||||
<color name="shelter_accent">#FFC107</color>
|
||||
|
||||
<color name="background">#1A1A2E</color>
|
||||
<color name="status_bar_bg">#16213E</color>
|
||||
<color name="status_text">#B0BEC5</color>
|
||||
<color name="bottom_sheet_bg">#1A1A2E</color>
|
||||
<color name="compass_bg">#0F0F23</color>
|
||||
<color name="loading_bg">#CC000000</color>
|
||||
|
||||
<color name="text_primary">#ECEFF1</color>
|
||||
<color name="text_secondary">#90A4AE</color>
|
||||
|
||||
<color name="white">#FFFFFF</color>
|
||||
<color name="black">#000000</color>
|
||||
</resources>
|
||||
39
app/src/main/res/values/strings.xml
Normal file
39
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Tilfluktsrom</string>
|
||||
|
||||
<!-- Status messages -->
|
||||
<string name="status_ready">Ready</string>
|
||||
<string name="status_loading">Loading shelter data…</string>
|
||||
<string name="status_updating">Updating…</string>
|
||||
<string name="status_offline">Offline mode</string>
|
||||
<string name="status_shelters_loaded">%d shelters loaded</string>
|
||||
<string name="status_no_location">Waiting for GPS…</string>
|
||||
<string name="status_caching_map">Caching map for offline use…</string>
|
||||
|
||||
<!-- Loading screen -->
|
||||
<string name="loading_shelters">Downloading shelter data…</string>
|
||||
<string name="loading_map">Caching map tiles…</string>
|
||||
<string name="loading_first_time">Setting up for first use…</string>
|
||||
|
||||
<!-- Shelter info -->
|
||||
<string name="shelter_capacity">%d places</string>
|
||||
<string name="shelter_room_nr">Room %d</string>
|
||||
<string name="nearest_shelter">Nearest shelter</string>
|
||||
<string name="no_shelters">No shelter data available</string>
|
||||
|
||||
<!-- Actions -->
|
||||
<string name="action_refresh">Refresh data</string>
|
||||
<string name="action_toggle_view">Toggle map/compass view</string>
|
||||
|
||||
<!-- Permissions -->
|
||||
<string name="permission_location_title">Location permission required</string>
|
||||
<string name="permission_location_message">This app needs your location to find the nearest shelter. Please grant location access.</string>
|
||||
<string name="permission_denied">Location permission denied. The app cannot find nearby shelters without it.</string>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_download_failed">Could not download shelter data. Check your internet connection.</string>
|
||||
<string name="error_no_data_offline">No cached data available. Connect to the internet to download shelter data.</string>
|
||||
<string name="update_success">Shelter data updated</string>
|
||||
<string name="update_failed">Update failed — using cached data</string>
|
||||
</resources>
|
||||
11
app/src/main/res/values/themes.xml
Normal file
11
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Tilfluktsrom" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/shelter_primary</item>
|
||||
<item name="colorPrimaryDark">@color/shelter_primary_dark</item>
|
||||
<item name="colorAccent">@color/shelter_accent</item>
|
||||
<item name="android:windowBackground">@color/background</item>
|
||||
<item name="android:statusBarColor">@color/status_bar_bg</item>
|
||||
<item name="android:navigationBarColor">@color/background</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
app/src/main/res/xml/network_security_config.xml
Normal file
7
app/src/main/res/xml/network_security_config.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Allow cleartext for OSMDroid tile servers that may use HTTP -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">tile.openstreetmap.org</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
Loading…
Add table
Add a link
Reference in a new issue