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
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
/app/build
|
||||||
43
CLAUDE.md
Normal file
43
CLAUDE.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Tilfluktsrom - Norwegian Emergency Shelter Finder
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Android app (Kotlin) that helps find the nearest public shelter (tilfluktsrom) in Norway during emergencies. Offline-first design: must work without internet after initial data cache.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **Language**: Kotlin, targeting Android API 26+ (Android 8.0+)
|
||||||
|
- **Build**: Gradle 8.7, AGP 8.5.2, KSP for Room annotation processing
|
||||||
|
- **Maps**: OSMDroid (offline-capable OpenStreetMap)
|
||||||
|
- **Database**: Room (SQLite) for shelter data cache
|
||||||
|
- **HTTP**: OkHttp for data downloads
|
||||||
|
- **Location**: Google Play Services Fused Location Provider
|
||||||
|
- **UI**: Traditional Views with ViewBinding
|
||||||
|
|
||||||
|
## Key Data Flow
|
||||||
|
1. Shelter data downloaded as GeoJSON ZIP from Geonorge (EPSG:25833 UTM33N)
|
||||||
|
2. Coordinates converted to WGS84 (lat/lon) via `CoordinateConverter`
|
||||||
|
3. Stored in Room database for offline access
|
||||||
|
4. Nearest shelters found using Haversine distance calculation
|
||||||
|
5. Direction arrow uses device compass bearing minus shelter bearing
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
- **Shelter data**: `https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/TilfluktsromOffentlige/GeoJSON/...`
|
||||||
|
- **Map tiles**: OpenStreetMap via OSMDroid (auto-cached for offline use)
|
||||||
|
|
||||||
|
## Package Structure
|
||||||
|
```
|
||||||
|
no.naiv.tilfluktsrom/
|
||||||
|
├── data/ # Room entities, DAO, repository, GeoJSON parser, map cache
|
||||||
|
├── location/ # GPS location provider, nearest shelter finder
|
||||||
|
├── ui/ # Custom views (DirectionArrowView), adapters
|
||||||
|
└── util/ # Coordinate conversion (UTM→WGS84), distance calculations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
- Default (English): `res/values/strings.xml`
|
||||||
|
- Norwegian Bokmål: `res/values-nb/strings.xml`
|
||||||
|
- Norwegian Nynorsk: `res/values-nn/strings.xml`
|
||||||
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>
|
||||||
5
build.gradle.kts
Normal file
5
build.gradle.kts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.5.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||||
|
id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
|
||||||
|
}
|
||||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
android.suppressUnsupportedCompileSdk=35
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
176
gradlew
vendored
Executable file
176
gradlew
vendored
Executable file
|
|
@ -0,0 +1,176 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS=""
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin, switch paths to Windows format before running java
|
||||||
|
if $cygwin ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
(0) set -- ;;
|
||||||
|
(1) set -- "$args0" ;;
|
||||||
|
(2) set -- "$args0" "$args1" ;;
|
||||||
|
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=$(save "$@")
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
|
||||||
|
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||||
|
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
84
gradlew.bat
vendored
Normal file
84
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:init
|
||||||
|
@rem Get command-line arguments, handling Windows variants
|
||||||
|
|
||||||
|
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||||
|
|
||||||
|
:win9xME_args
|
||||||
|
@rem Slurp the command line arguments.
|
||||||
|
set CMD_LINE_ARGS=
|
||||||
|
set _SKIP=2
|
||||||
|
|
||||||
|
:win9xME_args_slurp
|
||||||
|
if "x%~1" == "x" goto execute
|
||||||
|
|
||||||
|
set CMD_LINE_ARGS=%*
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
19
settings.gradle.kts
Normal file
19
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Tilfluktsrom"
|
||||||
|
include(":app")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue