diff --git a/.gitignore b/.gitignore index e3bdbed..f827122 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .cxx local.properties /app/build +keystore.properties diff --git a/.maestro/screenshots-nn.yaml b/.maestro/screenshots-nn.yaml new file mode 100644 index 0000000..dae75a5 --- /dev/null +++ b/.maestro/screenshots-nn.yaml @@ -0,0 +1,72 @@ +# Maestro flow: Capture F-Droid screenshots for nn-NO (Nynorsk) +# +# Nynorsk is not supported as a system locale on stock Android. +# This flow uses per-app locale (API 33+) which requires NOT clearing app state, +# since clearState wipes per-app locale settings. +# +# Run the main screenshots.yaml first (any locale) so that map tiles are cached. +# Then set the per-app locale and run this flow: +# +# adb shell "cmd locale set-app-locales no.naiv.tilfluktsrom --locales nn" +# maestro test .maestro/screenshots-nn.yaml + +appId: no.naiv.tilfluktsrom + +env: + OUTPUT_DIR: "fastlane/metadata/android" + +--- + +# Mock GPS: Bergen centrum (Torgallmenningen) +- setLocation: + latitude: 60.3913 + longitude: 5.3221 + +# Launch WITHOUT clearing state (preserves per-app locale and cached tiles) +- launchApp: + appId: no.naiv.tilfluktsrom + clearState: false + permissions: + android.permission.ACCESS_FINE_LOCATION: allow + android.permission.ACCESS_COARSE_LOCATION: allow + +# Wait for shelter data to load +- extendedWaitUntil: + visible: + id: "statusText" + timeout: 15000 + +# Wait for shelter list to populate +- extendedWaitUntil: + visible: + id: "shelterList" + timeout: 15000 + +- waitForAnimationToEnd + +# Screenshot 1: Map view +- takeScreenshot: + path: "${OUTPUT_DIR}/nn-NO/images/phoneScreenshots/1_map_view" + +# Screenshot 2: Tap second shelter +- tapOn: "Håkonsgaten 5" +- waitForAnimationToEnd +- takeScreenshot: + path: "${OUTPUT_DIR}/nn-NO/images/phoneScreenshots/2_shelter_selected" + +# Screenshot 3: Compass view +- tapOn: + id: "toggleViewFab" +- waitForAnimationToEnd +- takeScreenshot: + path: "${OUTPUT_DIR}/nn-NO/images/phoneScreenshots/3_compass_view" + +# Screenshot 4: Civil defense dialog +- tapOn: + id: "toggleViewFab" +- waitForAnimationToEnd +- tapOn: + id: "infoButton" +- waitForAnimationToEnd +- takeScreenshot: + path: "${OUTPUT_DIR}/nn-NO/images/phoneScreenshots/4_civil_defense_info" diff --git a/.maestro/screenshots.yaml b/.maestro/screenshots.yaml new file mode 100644 index 0000000..de15230 --- /dev/null +++ b/.maestro/screenshots.yaml @@ -0,0 +1,95 @@ +# Maestro flow: Capture F-Droid screenshots for Tilfluktsrom +# +# Usage (from project root): +# maestro test .maestro/screenshots.yaml +# +# With a specific locale: +# LOCALE=nb-NO maestro test .maestro/screenshots.yaml +# +# Prerequisites: +# - Android emulator (API 31+) or device running +# - App installed: ./gradlew installDebug +# - Internet connection (needed for map tile caching) +# - Install Maestro: curl -Ls https://get.maestro.mobile.dev | bash +# +# GPS is mocked to Bergen centrum (Torgallmenningen area), +# which has several shelters within walking distance. + +appId: no.naiv.tilfluktsrom + +env: + LOCALE: "en-US" + OUTPUT_DIR: "fastlane/metadata/android" + +--- + +# Mock GPS: Bergen centrum (Torgallmenningen) +- setLocation: + latitude: 60.3913 + longitude: 5.3221 + +# Grant location permission and launch with fresh state +- launchApp: + appId: no.naiv.tilfluktsrom + clearState: true + permissions: + android.permission.ACCESS_FINE_LOCATION: allow + android.permission.ACCESS_COARSE_LOCATION: allow + +# Wait for shelter data to load from bundled asset. +- extendedWaitUntil: + visible: + id: "statusText" + timeout: 15000 + +# The app shows a map caching prompt on first GPS fix when no cache exists. +# Tap "Cache map" and wait for caching to complete (~15s). +# This prevents the overlay from re-appearing on subsequent GPS updates. +- extendedWaitUntil: + visible: + id: "loadingOkButton" + timeout: 15000 + +- tapOn: + id: "loadingOkButton" + +# Wait for caching to finish (4 zoom levels x 9 grid points x 300ms ≈ 11s + network) +- extendedWaitUntil: + notVisible: + id: "loadingOverlay" + timeout: 30000 + +# Let the map settle and shelter list populate after caching +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: "shelterList" + timeout: 10000 + +# Screenshot 1: Map view with shelter markers and bottom sheet +- takeScreenshot: + path: "${OUTPUT_DIR}/${LOCALE}/images/phoneScreenshots/1_map_view" + +# Screenshot 2: Tap the second shelter (Håkonsgaten 5) to show a different selection. +# Shelter addresses are locale-independent, so this works in all languages. +- tapOn: "Håkonsgaten 5" +- waitForAnimationToEnd +- takeScreenshot: + path: "${OUTPUT_DIR}/${LOCALE}/images/phoneScreenshots/2_shelter_selected" + +# Screenshot 3: Switch to compass view +- tapOn: + id: "toggleViewFab" +- waitForAnimationToEnd +- takeScreenshot: + path: "${OUTPUT_DIR}/${LOCALE}/images/phoneScreenshots/3_compass_view" + +# Screenshot 4: Switch back to map, open civil defense info dialog +- tapOn: + id: "toggleViewFab" +- waitForAnimationToEnd +- tapOn: + id: "infoButton" +- waitForAnimationToEnd +- takeScreenshot: + path: "${OUTPUT_DIR}/${LOCALE}/images/phoneScreenshots/4_civil_defense_info" diff --git a/.maestro/take-screenshots.sh b/.maestro/take-screenshots.sh new file mode 100755 index 0000000..d44bb4e --- /dev/null +++ b/.maestro/take-screenshots.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Generate F-Droid screenshots for all locales using Maestro. +# +# Prerequisites: +# 1. Install Maestro: curl -Ls https://get.maestro.mobile.dev | bash +# 2. Start an Android emulator (API 33+): emulator -avd +# 3. Build and install the app: ./gradlew installDebug +# 4. Run this script: .maestro/take-screenshots.sh +# +# Screenshots are saved directly into fastlane/metadata/android//images/ +# +# Locale handling: +# - en-US and nb-NO use system locale (settings put system system_locales) +# - nn-NO (Nynorsk) requires per-app locale since Android doesn't support +# Nynorsk as a system locale — it falls back to Bokmål + +set -euo pipefail +cd "$(dirname "$0")/.." + +FLOW=".maestro/screenshots.yaml" +FLOW_NN=".maestro/screenshots-nn.yaml" + +restart_framework() { + adb shell stop 2>/dev/null + sleep 2 + adb shell start 2>/dev/null + sleep 8 +} + +ensure_root() { + adb root 2>/dev/null || true + sleep 1 +} + +echo "=== Ensuring root access ===" +ensure_root + +# --- en-US --- +echo "=== Capturing screenshots for en-US ===" +mkdir -p "fastlane/metadata/android/en-US/images/phoneScreenshots" +rm -f "fastlane/metadata/android/en-US/images/.gitkeep" + +adb shell "settings put system system_locales en-US" +restart_framework + +sed -i 's/LOCALE: ".*"/LOCALE: "en-US"/' "$FLOW" +maestro test "$FLOW" +echo "=== Done: en-US ===" +echo "" + +# --- nb-NO --- +echo "=== Capturing screenshots for nb-NO ===" +mkdir -p "fastlane/metadata/android/nb-NO/images/phoneScreenshots" +rm -f "fastlane/metadata/android/nb-NO/images/.gitkeep" + +adb shell "settings put system system_locales nb-NO" +restart_framework + +sed -i 's/LOCALE: ".*"/LOCALE: "nb-NO"/' "$FLOW" +maestro test "$FLOW" +sed -i 's/LOCALE: "nb-NO"/LOCALE: "en-US"/' "$FLOW" +echo "=== Done: nb-NO ===" +echo "" + +# --- nn-NO (Nynorsk) --- +# Android doesn't support nn as a system locale, so we use per-app locale. +# The main flow must have run first to cache map tiles (nn flow uses clearState: false). +echo "=== Capturing screenshots for nn-NO ===" +mkdir -p "fastlane/metadata/android/nn-NO/images/phoneScreenshots" +rm -f "fastlane/metadata/android/nn-NO/images/.gitkeep" + +adb shell "am force-stop no.naiv.tilfluktsrom" +adb shell "cmd locale set-app-locales no.naiv.tilfluktsrom --locales nn" +sleep 2 + +maestro test "$FLOW_NN" +echo "=== Done: nn-NO ===" +echo "" + +# Restore en-US +adb shell "settings put system system_locales en-US" +adb shell "cmd locale set-app-locales no.naiv.tilfluktsrom --locales en" +restart_framework + +echo "All screenshots captured." +echo "Check: fastlane/metadata/android/*/images/phoneScreenshots/" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c544235 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,455 @@ +# Architecture + +This document describes the architecture of Tilfluktsrom, a Norwegian emergency shelter finder available as an Android app and a Progressive Web App (PWA). Both platforms share the same design philosophy — offline-first, no hard external dependencies — but are implemented independently. + +## Table of Contents + +- [Design Principles](#design-principles) +- [Data Pipeline](#data-pipeline) +- [Android App](#android-app) + - [Package Structure](#package-structure) + - [Data Layer](#data-layer) + - [Location & Navigation](#location--navigation) + - [Compass System](#compass-system) + - [Map & Tile Caching](#map--tile-caching) + - [Build Variants](#build-variants) + - [Home Screen Widget](#home-screen-widget) + - [Deep Linking](#deep-linking) +- [Progressive Web App](#progressive-web-app) + - [Module Structure](#module-structure) + - [Build System](#build-system) + - [Data Layer (PWA)](#data-layer-pwa) + - [Location & Compass (PWA)](#location--compass-pwa) + - [Map & Offline Tiles (PWA)](#map--offline-tiles-pwa) + - [Service Worker](#service-worker) + - [UI Components](#ui-components) +- [Shared Algorithms](#shared-algorithms) + - [UTM33N → WGS84 Conversion](#utm33n--wgs84-conversion) + - [Haversine Distance & Bearing](#haversine-distance--bearing) +- [Offline Capability Summary](#offline-capability-summary) +- [Security & Privacy](#security--privacy) +- [Internationalization](#internationalization) + +--- + +## Design Principles + +### Offline-First +This is an emergency app. Core functionality — finding the nearest shelter, compass navigation, distance display — must work without internet after initial setup. Network is used only for initial data download, periodic refresh, and map tile caching. + +### De-Google Compatibility (Android) +The Android app runs on devices without Google Play Services (LineageOS, GrapheneOS, /e/OS). Every Google-specific API has an AOSP fallback. Play Services improve accuracy and battery life when available, but are never required. + +### Minimal Dependencies +Both platforms use few, well-chosen libraries. No heavy frameworks, no external CDNs at runtime. The PWA bundles everything locally; the Android app uses only OSMDroid, Room, OkHttp, and WorkManager. + +### Data Sovereignty +Shelter data comes directly from Geonorge (the Norwegian mapping authority). No intermediate servers. The app fetches, converts, and caches the data locally. + +--- + +## Data Pipeline + +Both platforms consume the same upstream data source: + +``` +Geonorge ZIP (EPSG:25833 UTM33N) + ↓ Download (~320KB) + ↓ Extract GeoJSON from ZIP + ↓ Parse features + ↓ Convert UTM33N → WGS84 (Karney method) + ↓ Validate (Norway bounding box, required fields) + ↓ Store locally +``` + +**Source URL:** `https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/TilfluktsromOffentlige/GeoJSON/Samfunnssikkerhet_0000_Norge_25833_TilfluktsromOffentlige_GeoJSON.zip` + +**Fields per shelter:** + +| Field | Description | +|------------|--------------------------------------| +| `lokalId` | Unique identifier (UUID) | +| `romnr` | Shelter room number | +| `plasser` | Capacity (number of people) | +| `adresse` | Street address | +| `latitude` | WGS84 latitude (converted from UTM) | +| `longitude`| WGS84 longitude (converted from UTM) | + +**When conversion happens:** +- **Android:** At runtime during data download (`ShelterGeoJsonParser`) +- **PWA:** At build time (`scripts/fetch-shelters.ts`), output is pre-converted `shelters.json` + +--- + +## Android App + +**Language:** Kotlin · **Min SDK:** 26 (Android 8.0) · **Target SDK:** 35 +**Build:** Gradle 8.7, AGP 8.5.2, KSP for Room annotation processing + +### Package Structure + +``` +no.naiv.tilfluktsrom/ +├── TilfluktsromApp.kt # Application class (OSMDroid config) +├── MainActivity.kt # Central UI controller (~870 lines) +├── data/ +│ ├── Shelter.kt # Room entity +│ ├── ShelterDatabase.kt # Room database singleton +│ ├── ShelterDao.kt # Data access object (Flow queries) +│ ├── ShelterRepository.kt # Repository: bundled seed + network refresh +│ ├── ShelterGeoJsonParser.kt # GeoJSON ZIP → Shelter list +│ └── MapCacheManager.kt # Offline map tile pre-caching +├── location/ +│ ├── LocationProvider.kt # GPS provider (flavor-specific) +│ └── ShelterFinder.kt # Nearest N shelters by Haversine +├── ui/ +│ ├── DirectionArrowView.kt # Custom compass arrow View +│ ├── ShelterListAdapter.kt # RecyclerView adapter for shelter list +│ ├── CivilDefenseInfoDialog.kt # Emergency instructions +│ └── AboutDialog.kt # Privacy and copyright +├── util/ +│ ├── CoordinateConverter.kt # UTM33N → WGS84 (Karney method) +│ └── DistanceUtils.kt # Haversine distance and bearing +└── widget/ + ├── ShelterWidgetProvider.kt # Home screen widget (flavor-specific) + └── WidgetUpdateWorker.kt # WorkManager periodic update +``` + +Files under `location/` and `widget/` have separate implementations per build variant: +- `app/src/standard/java/` — Google Play Services variant +- `app/src/fdroid/java/` — AOSP-only variant + +### Data Layer + +**Storage:** Room (SQLite) with a single `shelters` table. + +**Loading strategy (three-layer fallback):** + +1. **Bundled asset** (`assets/shelters.json`): Pre-converted WGS84 data, loaded on first launch via `ShelterRepository.seedFromAsset()`. Marked as stale (timestamp=0) so a network refresh is attempted when possible. + +2. **Room database**: ~556 shelters cached locally. Reactive updates via Kotlin Flow. Atomic refresh: `deleteAll()` + `insertAll()` in a single transaction. + +3. **Network refresh**: Downloads the Geonorge ZIP via OkHttp, parses with `ShelterGeoJsonParser`. Staleness threshold: 7 days. Runs in the background; failure does not block the UI. + +**GeoJSON parsing pipeline:** +``` +OkHttp response stream + → ZipInputStream (find .geojson entry, 10MB size limit) + → JSON parsing (features array) + → Per feature: + → Extract UTM33N coordinates + → CoordinateConverter.utm33nToWgs84() + → Validate: lokalId not blank, plasser ≥ 0, within Norway bounds + → Create Shelter entity (skip malformed features with warning) +``` + +### Location & Navigation + +**LocationProvider** abstracts GPS access with two flavor implementations: + +| Flavor | Primary | Fallback | +|-----------|----------------------------------|-------------------| +| standard | FusedLocationProviderClient | LocationManager | +| fdroid | LocationManager | — | + +Both emit location updates via Kotlin Flow. Update interval: 5 seconds, fastest 2 seconds. + +**ShelterFinder** takes the user's position and all shelters, computes Haversine distance and initial bearing for each, sorts by distance, and returns the N nearest (default: 3) as `ShelterWithDistance` objects. + +### Compass System + +**Sensor priority:** +1. Rotation Vector sensor (`TYPE_ROTATION_VECTOR`) — most accurate, single sensor +2. Accelerometer + Magnetometer — low-pass filtered (α=0.25) for smoothing +3. No compass available — error message shown + +**Direction calculation:** +``` +arrowAngle = shelterBearing − deviceHeading +``` + +**DirectionArrowView** is a custom View that draws: +- A large arrow rotated by `arrowAngle`, pointing toward the shelter +- An optional north indicator on the perimeter for compass calibration + +### Map & Tile Caching + +**Map library:** OSMDroid (OpenStreetMap, no Google dependency). + +**Tile caching:** OSMDroid's built-in `SqlTileWriter` passively caches every tile loaded. `MapCacheManager` supplements this with active pre-caching: + +- Pans the MapView across a 3×3 grid at zoom levels 10, 12, 14, 16 +- 300ms delay between pans (respects OSM tile usage policy) +- Covers ~15km radius around the user +- Progress reported via callback for UI display + +Tile cache stored in app-specific internal storage (`osmdroidBasePath`). + +### Build Variants + +``` +productFlavors { + standard { } // Google Play Services + AOSP fallback + fdroid { } // AOSP only +} +``` + +The `standard` flavor adds `com.google.android.gms:play-services-location`. Runtime detection via `GoogleApiAvailability` determines which code path runs. + +Both flavors produce identical user experiences — `standard` achieves faster GPS fixes and better battery efficiency when Play Services are present. + +### Home Screen Widget + +**ShelterWidgetProvider** displays the nearest shelter's address, capacity, and distance. Updated by: + +1. **MainActivity** — sends latest location on each GPS update +2. **WorkManager** — `WidgetUpdateWorker` runs every 15 minutes, requests a fresh location fix +3. **Manual** — user taps refresh button on the widget + +**Location resolution (priority order):** +1. Location from intent (WorkManager or MainActivity) +2. FusedLocationProviderClient cache (standard) +3. Active GPS request (10s timeout) +4. LocationManager cache +5. SharedPreferences saved location (max 24h old) + +### Deep Linking + +**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{lokalId}` + +The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts` (exposed as `BuildConfig.DEEP_LINK_DOMAIN` and manifest placeholder `${deepLinkHost}`). + +- `autoVerify="true"` on the HTTPS intent filter triggers Android's App Links verification at install time +- Verification requires `/.well-known/assetlinks.json` to be served by the PWA (in `pwa/public/.well-known/`) +- If the app is installed and verified, `/shelter/*` links open the app directly (no disambiguation dialog) +- If not installed, the link opens in the browser, where the PWA handles it + +Share messages include the HTTPS URL, which SMS apps auto-link as a tappable URL. + +--- + +## Progressive Web App + +**Stack:** TypeScript, Vite 5, Leaflet, idb (IndexedDB wrapper), vite-plugin-pwa +**Package manager:** bun + +### Module Structure + +``` +pwa/ +├── index.html # SPA shell, CSP headers, semantic layout +├── vite.config.ts # Build config, PWA plugin, tile caching rules +├── manifest.webmanifest # PWA metadata and icons +├── scripts/ +│ └── fetch-shelters.ts # Build-time: download + convert shelter data +├── public/ +│ └── data/shelters.json # Pre-processed shelter data (build artifact) +└── src/ + ├── main.ts # Entry point, SW registration, locale init + ├── app.ts # Main controller (~400 lines) + ├── types.ts # Shelter, ShelterWithDistance, LatLon interfaces + ├── data/ + │ ├── shelter-repository.ts # Fetch + IndexedDB storage + │ └── shelter-db.ts # IndexedDB wrapper (idb library) + ├── location/ + │ ├── location-provider.ts # navigator.geolocation wrapper + │ ├── compass-provider.ts # DeviceOrientationEvent (iOS/Android) + │ └── shelter-finder.ts # Haversine nearest-N calculation + ├── ui/ + │ ├── map-view.ts # Leaflet map, custom SVG markers + │ ├── compass-view.ts # Canvas-based direction arrow + │ ├── shelter-list.ts # Bottom sheet shelter list + │ ├── loading-overlay.ts # Modal spinner / cache prompt + │ ├── about-dialog.ts # Privacy, data info, cache clear + │ ├── civil-defense-dialog.ts # DSB 5-step emergency guide + │ └── status-bar.ts # Status text and refresh button + ├── cache/ + │ └── map-cache-manager.ts # Tile pre-caching via programmatic panning + ├── i18n/ + │ ├── i18n.ts # Locale detection, string substitution + │ ├── en.ts # English strings + │ ├── nb.ts # Bokmål strings + │ └── nn.ts # Nynorsk strings + ├── util/ + │ └── distance-utils.ts # Haversine distance, bearing, formatting + └── main.css # Dark theme, Leaflet overrides, dialogs +``` + +### Build System + +**Vite** bundles the app with content-hashed filenames for cache busting. + +**Key configuration:** +- Base path: `./` (relative, deployable anywhere) +- `__BUILD_REVISION__` define: ISO timestamp injected at build time, used to invalidate service worker caches +- **vite-plugin-pwa**: Generates the service worker with Workbox. Precaches all static assets. Runtime-caches OSM tile requests with CacheFirst strategy (30-day expiry, max 5000 entries). + +**Build-time data preprocessing:** +`scripts/fetch-shelters.ts` downloads the Geonorge ZIP, extracts the GeoJSON, converts UTM33N→WGS84, validates, and writes `public/data/shelters.json`. This means coordinate conversion is a build step, not a runtime cost. + +```bash +bun run fetch-shelters # Download and convert shelter data +bun run build # TypeScript check + Vite build + SW generation +``` + +### Data Layer (PWA) + +**Storage:** IndexedDB via the `idb` library. + +**Schema:** +- Object store `shelters` (keyPath: `lokalId`) — full shelter records +- Object store `metadata` — `lastUpdate` timestamp + +**Loading strategy:** +1. Check IndexedDB for cached shelters +2. If empty or stale (>7 days): fetch `shelters.json` (precached by service worker) +3. Replace all records in a single transaction +4. Reactive: UI reads from IndexedDB after load + +Unlike the Android app, the PWA does not perform coordinate conversion at runtime — `shelters.json` is pre-converted at build time and served as a static asset. + +### Location & Compass (PWA) + +**Location:** `navigator.geolocation.watchPosition()` with high accuracy enabled, 10s maximum age, 30s timeout. + +**Compass:** DeviceOrientationEvent with platform-specific handling: + +| Platform | API | Heading Source | +|----------------|------------------------------|--------------------------| +| iOS Safari | `deviceorientation` | `webkitCompassHeading` | +| Android Chrome | `deviceorientationabsolute` | `(360 − alpha) % 360` | + +iOS 13+ requires an explicit permission request (`DeviceOrientationEvent.requestPermission()`), triggered by a user gesture. + +Low-pass filter (smoothing factor 0.3) with 0/360° wraparound handling ensures fluid rotation. + +### Map & Offline Tiles (PWA) + +**Map library:** Leaflet with OpenStreetMap tiles. No CDN — Leaflet is bundled from `node_modules`. + +**Custom markers:** SVG-based `divIcon` elements — orange house with "T" for shelters, blue circle for user location. Selected shelter uses a larger yellow marker. + +**Auto-fit logic:** When a shelter is selected, the map fits bounds to show both user and shelter (30% padding), unless the user has manually panned or zoomed. A "reset view" button appears after manual interaction. + +**Tile pre-caching:** `MapCacheManager` uses the same approach as the Android app — programmatically pans the map across a 3×3 grid at 4 zoom levels. The service worker's runtime cache intercepts and stores the tile requests. Cache location stored in localStorage (rounded to ~11km precision for privacy). + +### Service Worker + +Generated by vite-plugin-pwa (Workbox): + +- **Precaching:** All static assets (JS, CSS, HTML, JSON, images) with content-hash versioning +- **Runtime caching:** OSM tiles from `{a,b,c}.tile.openstreetmap.org` with CacheFirst strategy, 30-day TTL, max 5000 entries +- **Navigation fallback:** Serves `index.html` for all navigation requests (SPA behavior) +- **Auto-update:** New service worker activates automatically; `controllerchange` event notifies the user + +`main.ts` injects the build revision into the service worker context, ensuring each build invalidates stale caches. It also requests persistent storage (`navigator.storage.persist()`) to prevent the browser from evicting cached data. + +### UI Components + +The PWA uses no framework — all UI is vanilla TypeScript manipulating the DOM. + +| Component | Description | +|--------------------------|----------------------------------------------------------| +| `app.ts` | Main controller: wires components, manages state | +| `map-view.ts` | Leaflet map with custom markers, auto-fit, interaction tracking | +| `compass-view.ts` | Canvas-rendered arrow with north indicator, requestAnimationFrame | +| `shelter-list.ts` | Bottom sheet with 3 nearest shelters, click selection | +| `loading-overlay.ts` | Modal: spinner during load, OK/Skip for cache prompt | +| `about-dialog.ts` | Privacy statement, data sources, "clear cache" button | +| `civil-defense-dialog.ts`| DSB emergency instructions (5 steps) | +| `status-bar.ts` | Data freshness indicator, refresh button | + +Dialogs are created dynamically and implement focus management (save/restore previous focus, trap focus inside modal). + +--- + +## Shared Algorithms + +Both platforms implement these algorithms independently (no shared code), ensuring each platform has zero runtime dependencies on the other. + +### UTM33N → WGS84 Conversion + +**Algorithm:** Karney series expansion method. + +Converts EUREF89 / UTM zone 33N (EPSG:25833) to WGS84 (EPSG:4326). + +**Constants:** +- Semi-major axis: 6,378,137 m +- Flattening: 1/298.257223563 +- UTM zone 33 central meridian: 15° E +- Scale factor: 0.9996 +- False easting: 500,000 m + +**Steps:** +1. Remove false easting/northing +2. Compute footprint latitude using series expansion +3. Apply iterative corrections for latitude and longitude +4. Add central meridian offset + +**Validation:** Reject results outside Norway bounding box (57–72°N, 3–33°E). + +**Implementations:** +- Android: `util/CoordinateConverter.kt` (runtime) +- PWA: `scripts/fetch-shelters.ts` (build-time) + +### Haversine Distance & Bearing + +**Distance** between two WGS84 points: +``` +a = sin²(Δφ/2) + cos(φ₁) · cos(φ₂) · sin²(Δλ/2) +d = 2R · atan2(√a, √(1−a)) +``` +Where R = 6,371 km (Earth mean radius). + +**Initial bearing** from point 1 to point 2: +``` +θ = atan2(sin(Δλ) · cos(φ₂), cos(φ₁) · sin(φ₂) − sin(φ₁) · cos(φ₂) · cos(Δλ)) +``` +Result in degrees: 0° = north, 90° = east, 180° = south, 270° = west. + +**Implementations:** +- Android: `util/DistanceUtils.kt` +- PWA: `util/distance-utils.ts` + +--- + +## Offline Capability Summary + +| Capability | Android | PWA | +|-------------------------|----------------------------------|------------------------------------| +| Shelter data | Room DB + bundled asset seed | IndexedDB + precached JSON | +| Map tiles | OSMDroid SqlTileWriter + active pre-cache | Service worker CacheFirst + active pre-cache | +| GPS | Device hardware (no network) | Device hardware (no network) | +| Compass | Device sensors | DeviceOrientationEvent | +| Distance/bearing math | Local computation | Local computation | +| Network required for | Initial refresh, new tiles | Initial page load, new tiles | +| Staleness threshold | 7 days | 7 days | + +--- + +## Security & Privacy + +- **No tracking:** No analytics, no telemetry, no external requests beyond Geonorge data and OSM tiles +- **No user accounts:** No registration, no login, no server-side storage +- **Location stays local:** GPS coordinates are used only for distance calculation and never transmitted +- **HTTPS enforced:** Android network security config restricts to TLS; PWA CSP headers restrict connections +- **ZIP bomb protection:** 10MB limit on uncompressed GeoJSON (Android parser) +- **CSP headers (PWA):** Restrictive Content-Security-Policy blocks external scripts; `img-src` allows only OSM tile servers +- **Input validation:** Shelter data validated at parse time (bounds check, required fields, non-negative capacity) +- **Minimal permissions:** Location only; no contacts, camera, storage (except legacy tile cache on Android ≤9) + +--- + +## Internationalization + +Both platforms support three languages: + +| Language | Android | PWA | +|---------------------|----------------------|----------------| +| English (default) | `values/strings.xml` | `i18n/en.ts` | +| Norwegian Bokmål | `values-nb/strings.xml` | `i18n/nb.ts` | +| Norwegian Nynorsk | `values-nn/strings.xml` | `i18n/nn.ts` | + +**Android:** Uses Android's built-in locale resolution. Per-app language selection supported on Android 13+ via `locales_config.xml`. + +**PWA:** Detects browser language (`navigator.language`). String substitution supports `%d` (number) and `%s` (string) placeholders. Falls back to English for unsupported locales. diff --git a/CLAUDE.md b/CLAUDE.md index 1775270..df4f859 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,13 +3,28 @@ ## 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. +## Design Principles + +### De-Google Compatibility +The app must work on devices without Google Play Services (e.g. LineageOS, GrapheneOS, /e/OS). Every feature that uses a Google-specific API must have a fallback that works without it. Use Google Play Services when available for better accuracy/performance, but never as a hard dependency. + +**Pattern**: Check for Play Services at runtime, fall back to AOSP/standard APIs. +- **Location**: Prefer FusedLocationProviderClient (Play Services) → fall back to LocationManager (AOSP) +- **Maps**: OSMDroid (no Google dependency) +- **Database**: Room/SQLite (no Google dependency) +- **Background work**: WorkManager (works without Play Services via built-in scheduler) + +### Offline-First +This is an emergency app. Assume internet and infrastructure may be degraded or unavailable. All core functionality (finding nearest shelter, compass navigation, sharing location) must work offline after initial data cache. Avoid solutions that depend on external servers being reachable. + ## 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 +- **Location**: FusedLocationProviderClient (Play Services) with LocationManager fallback +- **Background**: WorkManager for periodic widget updates - **UI**: Traditional Views with ViewBinding ## Key Data Flow @@ -29,6 +44,7 @@ no.naiv.tilfluktsrom/ ├── data/ # Room entities, DAO, repository, GeoJSON parser, map cache ├── location/ # GPS location provider, nearest shelter finder ├── ui/ # Custom views (DirectionArrowView), adapters +├── widget/ # Home screen widget, WorkManager periodic updater └── util/ # Coordinate conversion (UTM→WGS84), distance calculations ``` @@ -37,6 +53,62 @@ no.naiv.tilfluktsrom/ ./gradlew assembleDebug ``` +## Build Variants +- **standard**: Includes Google Play Services for better GPS accuracy +- **fdroid**: AOSP-only, no Google dependencies + +## Distribution +- **Forgejo** (primary): `kode.naiv.no/olemd/tilfluktsrom` — releases with both APK variants + PWA tarball +- **GitHub** (mirror): `github.com/olemd/tilfluktsrom` — automatically mirrored from Forgejo, do not push manually +- **F-Droid**: Metadata maintained in a separate fdroiddata repo (GitLab fork). F-Droid builds from source using the `fdroid` variant and signs with the F-Droid key. + +## Release Process +When creating a new release: +1. Bump `versionCode` and `versionName` in `app/build.gradle.kts` +2. Update User-Agent string in `ShelterRepository.kt` to match new version +3. Build Android APKs: `./gradlew assembleStandardRelease assembleFdroidRelease` +4. Build PWA: `cd pwa && bun scripts/fetch-shelters.ts && bun run build && tar -czf /tmp/tilfluktsrom-vX.Y.Z-pwa.tar.gz -C dist .` +5. Commit, push, then create Forgejo release with all three artifacts: + ```bash + fj release create --create-tag vX.Y.Z --branch main \ + --attach "/tmp/tilfluktsrom-vX.Y.Z-standard.apk:tilfluktsrom-vX.Y.Z-standard.apk" \ + --attach "/tmp/tilfluktsrom-vX.Y.Z-fdroid.apk:tilfluktsrom-vX.Y.Z-fdroid.apk" \ + --body "release notes" "vX.Y.Z" + fj release asset create vX.Y.Z /tmp/tilfluktsrom-vX.Y.Z-pwa.tar.gz tilfluktsrom-vX.Y.Z-pwa.tar.gz + ``` +6. Install on phone: `adb install -r app/build/outputs/apk/standard/release/app-standard-release.apk` + +## Screenshots + +Use the Android emulator and Maestro to take screenshots for the README and fastlane metadata. + +### Emulator setup +- AVD: `tilfluktsrom` (Pixel 6, API 35, google_apis/x86_64) +- Start headless: `~/android-sdk/emulator/emulator -avd tilfluktsrom -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect &` +- When both a physical device and emulator are connected, use `-s emulator-5554` with adb +- Set fake GPS: `adb -s emulator-5554 emu geo fix ` (note: longitude first) +- Grant permissions before launch: `adb -s emulator-5554 shell pm grant no.naiv.tilfluktsrom android.permission.ACCESS_FINE_LOCATION` +- Always cache map tiles ("Lagre kart") — never skip caching for screenshots + +### Maestro (v2.3.0) +- Installed at `~/.maestro/bin/maestro` +- Use Maestro flows for repeatable screenshot sequences instead of manual `adb shell input tap` coordinates +- Maestro can target a specific device: `maestro --device emulator-5554 test flow.yaml` +- Place flows in a `maestro/` directory if creating reusable screenshot workflows + +### Screenshot destinations +Screenshots go in all three fastlane locale directories: +- `fastlane/metadata/android/en-US/images/phoneScreenshots/` +- `fastlane/metadata/android/nb-NO/images/phoneScreenshots/` +- `fastlane/metadata/android/nn-NO/images/phoneScreenshots/` + +Current screenshots: +1. `1_map_view.png` — Map with shelter markers +2. `2_shelter_selected.png` — Selected shelter with direction arrow +3. `3_compass_view.png` — Compass navigation with north indicator +4. `4_civil_defense_info.png` — Civil defense instructions +5. `5_about.png` — About page with privacy statement + ## i18n - Default (English): `res/values/strings.xml` - Norwegian Bokmål: `res/values-nb/strings.xml` diff --git a/F-DROID.md b/F-DROID.md new file mode 100644 index 0000000..0eeb632 --- /dev/null +++ b/F-DROID.md @@ -0,0 +1,44 @@ +# F-Droid submission notes + +## Anti-features: Play Services dependency + +The app includes `com.google.android.gms:play-services-location:21.3.0` for better location accuracy via `FusedLocationProviderClient`. However, this is **not a hard dependency**: + +- The app checks for Play Services at runtime via `GoogleApiAvailability` +- If unavailable, it falls back to `LocationManager` (standard AOSP API) +- All core functionality (finding shelters, compass navigation, offline maps) works without Play Services + +This was specifically designed to support degoogled devices (LineageOS, GrapheneOS, /e/OS). + +### F-Droid build options + +**Option A: Accept as-is with `NonFreeDep` anti-feature** +The app works fully without Play Services. Mark with `NonFreeDep` anti-feature. + +**Option B: Build flavor without Play Services (recommended)** +Create a `fdroid` product flavor that excludes the Play Services dependency entirely. The fallback code paths already handle the absence — only the dependency and the Fused provider code need to be conditionally included. + +## Metadata structure + +``` +fastlane/metadata/android/ +├── en-US/ # English (default) +├── nb-NO/ # Norwegian Bokmål +└── nn-NO/ # Norwegian Nynorsk +``` + +Each locale contains `title.txt`, `short_description.txt`, `full_description.txt`, and `changelogs/` with per-versionCode files. + +## Screenshots + +Screenshots still need to be added to `images/` directories: +- `phoneScreenshots/` — at least 3 phone screenshots +- `featureGraphic.png` — 1024x500 feature graphic + +## Build instructions + +Standard Gradle build, no custom steps needed: + +``` +./gradlew assembleRelease +``` diff --git a/README.md b/README.md index 0fbbd3f..2783a9d 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,35 @@ Finn nærmeste offentlige tilfluktsrom i Norge. Appen er bygd for nødsituasjoner og fungerer uten internett etter første gangs bruk. +

+ Kartvisning med tilfluktsrom i Bergen sentrum + Valgt tilfluktsrom med avstand og retningspil + Kompassnavigasjon med nordindikator + Sivilforsvarsinfo: hva du skal gjøre om alarmen går + Om-side med personvernerklæring +

+ +## Slik fungerer appen + +**Kartvisning** — Appen viser alle 556 offentlige tilfluktsrom i Norge på et OpenStreetMap-kart. De tre nærmeste tilfluktsrommene vises i bunnen med avstand, kapasitet og romnummer. Trykk på en kartmarkering eller et listeelement for å velge et tilfluktsrom. + +**Kompassnavigasjon** — Trykk på kompassknappen for å bytte til retningspil-visning. En stor pil peker mot det valgte tilfluktsrommet, med avstand i meter eller kilometer. En diskret nordindikator vises på kanten slik at du kan verifisere kompasskalibreringen. Fungerer uten internett — bare GPS og kompassensor. + +**Sivilforsvarsinfo** — Trykk på info-knappen for å se trinn-for-trinn-veiledning fra DSB om hva du skal gjøre når alarmen går: viktig melding-signal, flyalarm, finn dekning, lytt til NRK på DAB-radio, og faren over. + +**Om og personvern** — Appen samler ikke inn persondata. Alt skjer lokalt på enheten. Se om-siden i appen for fullstendig personvernerklæring, datakilder og opphavsrett. + ## Funksjoner -- **Finn nærmeste tilfluktsrom** — viser de tre nærmeste tilfluktsrommene med avstand og kapasitet -- **Kompassnavigasjon** — retningspil som peker mot valgt tilfluktsrom +- **Finn nærmeste tilfluktsrom** — viser de tre nærmeste med avstand og kapasitet +- **Kompassnavigasjon** — retningspil som peker mot valgt tilfluktsrom, med nordindikator - **Frakoblet kart** — kartfliser lagres automatisk for bruk uten nett -- **Velg fritt** — trykk på en hvilken som helst markør i kartet for å navigere dit +- **Velg fritt** — trykk på en markering i kartet for å navigere dit +- **Del tilfluktsrom** — send adresse, kapasitet og koordinater til andre +- **Sivilforsvarsinfo** — veiledning fra DSB om hva du skal gjøre når alarmen går +- **Hjemmeskjerm-widget** — viser nærmeste tilfluktsrom uten å åpne appen - **Flerspråklig** — engelsk, bokmål og nynorsk +- **Tilgjengelighet** — TalkBack-støtte, fokusindikatorer og tilstrekkelig kontrast ## Plattformer @@ -34,7 +56,7 @@ Progressiv nettapp med Vite, TypeScript og Leaflet. Kan installeres på alle enh ## Datakilde -Tilfluktsromdata lastes ned fra [Geonorge](https://www.geonorge.no/) som GeoJSON i UTM33N-projeksjon (EPSG:25833). Koordinatene konverteres til WGS84 (bredde-/lengdegrad) for visning i kartet. +Tilfluktsromdata er offentlig informasjon fra [DSB](https://www.dsb.no/) (Direktoratet for samfunnssikkerhet og beredskap), distribuert via [Geonorge](https://www.geonorge.no/) som GeoJSON i UTM33N-projeksjon (EPSG:25833). Koordinatene konverteres til WGS84 (bredde-/lengdegrad) for visning i kartet. Datasettet inneholder ca. 556 offentlige tilfluktsrom med adresse, romnummer og kapasitet (antall plasser). @@ -47,7 +69,8 @@ tilfluktsrom/ │ ├── java/.../ │ │ ├── data/ # Room-database, nedlasting, GeoJSON-parser │ │ ├── location/ # GPS, nærmeste tilfluktsrom -│ │ ├── ui/ # Retningspil, liste-adapter +│ │ ├── ui/ # Retningspil, liste-adapter, om-dialog +│ │ ├── widget/ # Hjemmeskjerm-widget │ │ └── util/ # UTM→WGS84-konvertering, avstandsberegning │ └── res/ # Layout, strenger (en/nb/nn), ikoner ├── pwa/ # Nettapp (TypeScript) @@ -72,14 +95,27 @@ Appen er designet etter «offline-first»-prinsippet: ## Sikkerhet -- All nettverkstrafikk går over HTTPS +- All nettverkstrafikk går over HTTPS (klartekst er deaktivert) +- Content Security Policy (CSP) i PWA-versjonen - Tilfluktsromdata valideres ved parsing (koordinater innenfor Norge, gyldige felt) - Databaseoppdateringer er atomiske (transaksjon) for å unngå datatap -- Ingen persondata lagres — kun tilfluktsromdata og kartfliser +- Lagret GPS-posisjon utløper automatisk etter 24 timer +- Egendefinert User-Agent forhindrer enhetsfingeravtrykk + +## Personvern + +Appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester. GPS-posisjonen brukes bare lokalt på enheten for å finne nærmeste tilfluktsrom, og sendes aldri til noen server. Se om-siden i appen for fullstendig personvernerklæring. + +## Opphavsrett + +Copyright (c) Ole-Morten Duesund ## Lisens Kildekoden er lisensiert under [Mozilla Public License 2.0](LICENSE). -Tilfluktsromdata er åpne data fra Geonorge / Direktoratet for samfunnssikkerhet og beredskap (DSB). -Kartfliser fra OpenStreetMap er lisensiert under [ODbL](https://opendatacommons.org/licenses/odbl/). +Appen bruker åpne data og tjenester fra flere kilder. Se [SOURCES.md](SOURCES.md) for en fullstendig oversikt over datakilder, URL-er og lisenser. + +## Se også + +- [STANDING_ON_SHOULDERS.md](STANDING_ON_SHOULDERS.md) — estimat over de ~119 000 menneskene som har gjort denne appen mulig diff --git a/SOURCES.md b/SOURCES.md new file mode 100644 index 0000000..203e709 --- /dev/null +++ b/SOURCES.md @@ -0,0 +1,17 @@ +# Data Sources + +Centralized list of all external data sources used by the app. This is the authoritative reference for attribution, licensing, and usage context. + +## Sources + +| Source | Provider | URL | Usage | License | Used in | +|--------|----------|-----|-------|---------|---------| +| Shelter data (GeoJSON) | Geonorge / DSB | [nedlasting.geonorge.no/...](https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/TilfluktsromOffentlige/GeoJSON/Samfunnssikkerhet_0000_Norge_25833_TilfluktsromOffentlige_GeoJSON.zip) | Shelter locations, capacity, addresses (~556 shelters) | [NLOD 2.0](https://data.norge.no/nlod/no/2.0) | `app/.../data/ShelterRepository.kt`, `pwa/scripts/fetch-shelters.ts` | +| Map tiles | OpenStreetMap | [tile.openstreetmap.org](https://tile.openstreetmap.org) | Offline-capable map display | [ODbL](https://opendatacommons.org/licenses/odbl/) | `app/` via OSMDroid, `pwa/src/ui/map-view.ts` via Leaflet | +| Civil defense guidelines | DSB (Direktoratet for samfunnssikkerhet og beredskap) | [dsb.no/sikkerhverdag/egenberedskap](https://www.dsb.no/sikkerhverdag/egenberedskap/) | Emergency instructions shown in the civil defense info dialog | Norwegian public sector information | `app/.../res/values/strings.xml` (civil_defense_* strings) | + +## Notes + +- **Shelter data** is downloaded as a ZIP containing GeoJSON in EPSG:25833 (UTM33N) projection. The app converts coordinates to WGS84 at parse time. +- **Map tiles** are cached locally by OSMDroid (Android) and the service worker (PWA) for offline use. OpenStreetMap's [tile usage policy](https://operations.osmfoundation.org/policies/tiles/) applies. +- **Civil defense guidelines** are adapted from official DSB recommendations, not quoted verbatim. Content is available in English, Bokmal, and Nynorsk. diff --git a/STANDING_ON_SHOULDERS.md b/STANDING_ON_SHOULDERS.md new file mode 100644 index 0000000..f61b1e2 --- /dev/null +++ b/STANDING_ON_SHOULDERS.md @@ -0,0 +1,192 @@ +# Standing on Shoulders + +## How many people made this app possible? + +Tilfluktsrom is a small Android app — about 900 source files — that helps +Norwegians find their nearest public shelter in an emergency. One person built +it in under a day. But that was only possible because of the accumulated work of +roughly **119,000 people**, spanning decades, countries, and disciplines. + +This document traces the human effort behind every layer of the stack, with +sources for each estimate. + +```mermaid +pie title People behind each layer + "Map data (OSM)" : 50000 + "Linux kernel" : 20000 + "Physical infrastructure" : 10500 + "Build tools & dev infra" : 6700 + "AI-assisted development" : 6000 + "Shelter data & builders" : 6000 + "Internet & standards" : 5250 + "OS & runtimes (excl. Linux)" : 3800 + "Libraries (Android)" : 2100 + "Libraries (PWA)" : 1750 + "Hosting & distribution" : 5700 + "Programming languages" : 1600 +``` + +--- + +## Layer 0: Physical Infrastructure — GPS & Sensors (~10,500 people) + +| Component | Role | Est. people | Source | +|---|---|---|---| +| GPS constellation | 31 satellites, maintained by US Space Force | ~5,000 | Industry estimate; [GPS.gov](https://www.gps.gov/) | +| Magnetometer/compass sensors | Enable the direction arrow to point at shelters | ~500 | Industry estimate | +| ARM architecture | The CPU instruction set running every Android device | ~5,000 | [Arm had 8,330 employees in 2025](https://www.macrotrends.net/stocks/charts/ARM/arm-holdings/number-of-employees); ~5,000 estimated over the architecture's 40-year history | + +Before a single line of code runs, hardware designed by tens of thousands of +engineers must be in orbit, in your pocket, and on the circuit board. + +## Layer 1: Internet & Standards (~5,250 people) + +| Component | Role | Est. people | Source | +|---|---|---|---| +| TCP/IP, DNS, HTTP, TLS | The protocols that carry shelter data from server to phone | ~5,000 | Cumulative IETF/W3C contributors over decades | +| GeoJSON specification | The format the shelter data is published in (IETF RFC 7946) | ~50 | [RFC 7946 authors + WG](https://datatracker.ietf.org/doc/html/rfc7946) | +| EPSG / coordinate reference systems | The math behind UTM33N → WGS84 coordinate conversion | ~200 | [IOGP Geomatics Committee](https://epsg.org/) | + +## Layer 2: Operating Systems & Runtimes (~23,800 people) + +| Component | Role | Est. people | Source | +|---|---|---|---| +| Linux kernel | Foundation of Android | ~20,000 | [Linux Foundation: ~20,000+ unique contributors since 2005](https://www.linuxfoundation.org/blog/blog/2017-linux-kernel-report-highlights-developers-roles-accelerating-pace-change) | +| Android (AOSP) | Mobile OS, incl. ART runtime | ~2,000 | [ResearchGate study: ~1,563 contributors](https://www.researchgate.net/figure/Top-Companies-Contributing-to-Android-Projects_tbl3_236631958); likely higher now | +| OpenJDK | The Java runtime Kotlin compiles to | ~1,800 | [GitHub: ~1,779 contributors](https://github.com/openjdk/jdk) | + +## Layer 3: Programming Languages (~1,600 people) + +| Language | Origin | Contributors | Source | +|---|---|---|---| +| Kotlin | JetBrains + community | ~765 | [GitHub: JetBrains/kotlin](https://github.com/JetBrains/kotlin) | +| TypeScript | Microsoft + community (for the PWA) | ~823 | [GitHub: microsoft/TypeScript](https://github.com/microsoft/TypeScript) | + +## Layer 4: Build Tools & Dev Infrastructure (~6,700 people) + +| Tool | Role | Contributors | Source | +|---|---|---|---| +| Gradle | Build automation | ~869 | [GitHub: gradle/gradle](https://github.com/gradle/gradle) | +| Android Gradle Plugin | Android-specific build pipeline | ~200 | Google internal; estimate | +| KSP (Kotlin Symbol Processing) | Code generation for Room database | ~100 | Estimate based on [GitHub: google/ksp](https://github.com/google/ksp) | +| R8 / ProGuard | Release minification and optimization | ~100 | Estimate | +| Vite | PWA bundler | ~1,100 | [GitHub: vitejs/vite](https://github.com/vitejs/vite) | +| Bun | Package manager and JS runtime | ~733 | [GitHub: oven-sh/bun](https://github.com/oven-sh/bun) | +| Git | Version control | ~1,820 | [GitHub: git/git](https://github.com/git/git) | +| Android Studio / IntelliJ | IDE | ~1,500 | Estimate; JetBrains + Google | +| Maven Central, Google Maven, npm | Package registry infrastructure | ~300 | Estimate | + +## Layer 5: Libraries — Android App (~2,100 people) + +| Library | What it does | Contributors | Source | +|---|---|---|---| +| AndroidX (Core, AppCompat, Room, WorkManager, etc.) | UI, architecture, database, scheduling | ~1,000 | [GitHub: androidx/androidx](https://github.com/androidx/androidx) monorepo | +| Material Design Components | Visual design language and components | ~199 | [GitHub: material-components-android](https://github.com/material-components/material-components-android) | +| Kotlinx Coroutines | Async data loading without blocking the UI | ~308 | [GitHub: Kotlin/kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) | +| OkHttp | Downloads the GeoJSON ZIP from Geonorge | ~287 | [GitHub: square/okhttp](https://github.com/square/okhttp) | +| OSMDroid | Offline OpenStreetMap rendering | ~105 | [GitHub: osmdroid/osmdroid](https://github.com/osmdroid/osmdroid) | +| Play Services Location | FusedLocationProvider for precise GPS | ~200 | Google internal; estimate | +| SQLite | The embedded database engine | **~4** | [sqlite.org/crew.html](https://sqlite.org/crew.html) — the most deployed database in the world, maintained by 3–4 people | + +## Layer 6: Libraries — PWA (~1,750 people) + +| Library | Role | Contributors | Source | +|---|---|---|---| +| Leaflet | Interactive web maps (created in Ukraine) | ~865 | [GitHub: Leaflet/Leaflet](https://github.com/Leaflet/Leaflet) | +| leaflet.offline | Offline tile caching | ~20 | Estimate based on GitHub | +| idb | IndexedDB wrapper for offline storage | ~30 | Estimate based on GitHub | +| vite-plugin-pwa | Service worker and Workbox integration | ~100 | Estimate based on GitHub | +| Vitest | Test framework | ~718 | [GitHub: vitest-dev/vitest](https://github.com/vitest-dev/vitest) | + +## Layer 7: Data — The Content That Makes It Useful (~56,000 people) + +| Source | Role | Est. people | Source link | +|---|---|---|---| +| OpenStreetMap | Global map data | ~50,000 | [~2.25M have ever edited; ~50,000 active monthly](https://wiki.openstreetmap.org/wiki/Stats) | +| Kartverket / Geonorge | Norwegian Mapping Authority; national geodata infrastructure | ~800 | [kartverket.no](https://www.kartverket.no/) | +| DSB | Created and maintains the public shelter registry | ~200 | [dsb.no](https://www.dsb.no/) | +| The shelter builders | Construction, engineering, civil defense planning since the Cold War | ~5,000 | Estimate based on ~556 shelters built 1950s–80s | + +The app's data exists because of Cold War civil defense planning. The shelters +were built in the 1950s–80s, digitized by DSB, published via Geonorge's open +data mandate — a chain of decisions spanning 70 years that now fits in a 320 KB +GeoJSON file. + +## Layer 8: AI-Assisted Development (~6,000 people) + +| Component | Role | Est. people | Source | +|---|---|---|---| +| Anthropic / Claude | Researchers, engineers, safety team | ~1,000 | [anthropic.com](https://www.anthropic.com/) | +| ML research lineage | Transformers, attention, RLHF, scaling laws — across academia & industry | ~5,000 | Estimate across all contributing institutions | +| Training data | The collective written output of humanity | incalculable | | + +## Layer 9: Hosting & Distribution (~5,700 people) + +| Component | Role | Contributors | Source | +|---|---|---|---| +| Forgejo / Gitea | Hosts this project at kode.naiv.no; Forgejo forked from Gitea in 2022 | ~800 | [Forgejo: ~230 contributors](https://codeberg.org/forgejo/forgejo); [Gitea: go-gitea/gitea](https://github.com/go-gitea/gitea) | +| GitHub | Mirror repo + hosts nearly all upstream dependencies | ~3,000 | [~5,000 employees, ~50% engineers](https://kinsta.com/blog/github-statistics/) | +| F-Droid | Open-source app store infrastructure and review | ~150 | [GitLab: fdroid](https://gitlab.com/fdroid); estimate | +| Fastlane | Metadata and screenshot tooling | ~1,524 | [GitHub: fastlane/fastlane](https://github.com/fastlane/fastlane) | + +--- + +## Summary + +| Layer | People | +|---|---| +| Physical infrastructure (GPS, ARM, sensors) | ~10,500 | +| Internet & standards | ~5,250 | +| Operating systems & runtimes | ~23,800 | +| Programming languages | ~1,600 | +| Build tools & dev infrastructure | ~6,700 | +| Direct libraries (Android) | ~2,100 | +| Direct libraries (PWA) | ~1,750 | +| Data (maps, shelters, geodesy) | ~56,000 | +| AI-assisted development | ~6,000 | +| Hosting & distribution | ~5,700 | +| **Conservative total** | **~119,000** | + +This is conservative. It excludes: + +- The millions of OSM mappers globally whose edits feed the tile rendering pipeline +- Hardware manufacturing (semiconductor fabs, device assembly — millions of workers) +- The educators who taught all these people their craft +- The civil defense planners who decided Norway needed public shelters +- The mathematicians behind Haversine, UTM projections, and geodesy going back centuries + +Including OpenStreetMap's full contributor base and hardware, the number crosses +**2 million** easily. + +--- + +## Notable details + +- **SQLite** — the most widely deployed database engine in the world (in every + phone, browser, and operating system) — is maintained by + [3–4 people](https://sqlite.org/crew.html). It powers every shelter lookup + in this app. + +- **Leaflet** — the JavaScript mapping library used by the PWA — was created by + [Volodymyr Agafonkin](https://agafonkin.com/) in Kyiv, Ukraine. An emergency + shelter app built with a mapping library from a country at war. + +- **OpenStreetMap** has ~10 million registered accounts, but only ~50,000 are + active in any given month. The map tiles this app displays are the work of a + dedicated minority. + +--- + +## Perspective + +For every line of application code, roughly 119,000 people made the tools, data, +and infrastructure that line depends on. No single company, country, or +organization could have built this stack alone. Linux (Finland → global), Kotlin +(Czech Republic/Russia → JetBrains), OSM (UK → global), GPS (US military → +civilian), Leaflet (Ukraine), SQLite (US, public domain) — this emergency app is +a product of genuine global cooperation. + +The fact that one person can build a working, offline-capable emergency app in +under a day is arguably one of the most remarkable expressions of accumulated +human cooperation — and almost none of it was coordinated by any central +authority. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a646779..5992c73 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,11 +6,6 @@ plugins { id("com.google.devtools.ksp") } -// Read version from shared version.properties -val versionProps = Properties().apply { - rootProject.file("version.properties").inputStream().use { load(it) } -} - android { namespace = "no.naiv.tilfluktsrom" compileSdk = 35 @@ -19,27 +14,45 @@ android { applicationId = "no.naiv.tilfluktsrom" minSdk = 26 targetSdk = 35 - versionCode = versionProps.getProperty("versionCode").toInt() - versionName = "${versionProps.getProperty("versionMajor")}." + - "${versionProps.getProperty("versionMinor")}." + - versionProps.getProperty("versionPatch") + versionCode = 12 + versionName = "1.8.0" - // Make version available in BuildConfig - buildConfigField("String", "VERSION_DISPLAY", "\"$versionName\"") + // Deep link domain — single source of truth for manifest + Kotlin code + val deepLinkDomain = "tilfluktsrom.naiv.no" + buildConfigField("String", "DEEP_LINK_DOMAIN", "\"$deepLinkDomain\"") + manifestPlaceholders["deepLinkHost"] = deepLinkDomain + } + + dependenciesInfo { + includeInApk = false + includeInBundle = false } signingConfigs { create("release") { - val keystorePath = System.getProperty("user.home") + "/.android/tilfluktsrom-release.jks" - if (file(keystorePath).exists()) { - storeFile = file(keystorePath) - storePassword = "tilfluktsrom" - keyAlias = "tilfluktsrom" - keyPassword = "tilfluktsrom" + val keystorePropsFile = rootProject.file("keystore.properties") + if (keystorePropsFile.exists()) { + val keystoreProps = Properties().apply { + keystorePropsFile.inputStream().use { load(it) } + } + storeFile = file(keystoreProps.getProperty("storeFile")) + storePassword = keystoreProps.getProperty("storePassword") + keyAlias = keystoreProps.getProperty("keyAlias") + keyPassword = keystoreProps.getProperty("keyPassword") } } } + flavorDimensions += "distribution" + productFlavors { + create("standard") { + dimension = "distribution" + } + create("fdroid") { + dimension = "distribution" + } + } + buildTypes { release { isMinifyEnabled = true @@ -88,8 +101,11 @@ dependencies { // 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") + // Google Play Services Location (precise GPS) — standard flavor only + "standardImplementation"("com.google.android.gms:play-services-location:21.3.0") + + // WorkManager (periodic widget updates) + implementation("androidx.work:work-runtime-ktx:2.9.1") // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt new file mode 100644 index 0000000..87e19af --- /dev/null +++ b/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt @@ -0,0 +1,119 @@ +package no.naiv.tilfluktsrom.location + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import android.os.Looper +import android.util.Log +import androidx.core.content.ContextCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Provides GPS location updates using AOSP LocationManager. + * + * F-Droid flavor: no Google Play Services dependency. Uses GPS + Network providers + * directly via LocationManager (available on all Android 8.0+ devices). + */ +class LocationProvider(private val context: Context) { + + companion object { + private const val TAG = "LocationProvider" + private const val UPDATE_INTERVAL_MS = 5000L + } + + init { + Log.d(TAG, "Location backend: LocationManager (F-Droid build)") + } + + /** + * Stream of location updates. Emits the last known location first (if available), + * then continuous updates. Throws SecurityException if permission is not granted. + */ + fun locationUpdates(): Flow = callbackFlow { + if (!hasLocationPermission()) { + close(SecurityException("Location permission not granted")) + return@callbackFlow + } + + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) + as? LocationManager + + if (locationManager == null) { + close(IllegalStateException("LocationManager not available")) + return@callbackFlow + } + + // Emit best last known location immediately (pick most recent of GPS/Network) + try { + val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + val best = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time } + if (best != null) { + val result = trySend(best) + if (result.isFailure) { + Log.w(TAG, "Failed to emit last known location") + } + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException getting last known location", e) + } + + // LocationListener compatible with API 26-28 (onStatusChanged required before API 29) + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + val sendResult = trySend(location) + if (sendResult.isFailure) { + Log.w(TAG, "Failed to emit location update") + } + } + + // Required for API 26-28 compatibility (deprecated from API 29+) + @Deprecated("Deprecated in API 29") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} + override fun onProviderEnabled(provider: String) {} + override fun onProviderDisabled(provider: String) {} + } + + try { + // Request from both providers: GPS is accurate, Network gives faster first fix + if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + UPDATE_INTERVAL_MS, + 0f, + listener, + Looper.getMainLooper() + ) + } + if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + UPDATE_INTERVAL_MS, + 0f, + listener, + Looper.getMainLooper() + ) + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException requesting location updates", e) + close(e) + return@callbackFlow + } + + awaitClose { + locationManager.removeUpdates(listener) + } + } + + fun hasLocationPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt new file mode 100644 index 0000000..1f42882 --- /dev/null +++ b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt @@ -0,0 +1,268 @@ +package no.naiv.tilfluktsrom.widget + +import android.Manifest +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.os.Build +import android.os.CancellationSignal +import android.text.format.DateFormat +import android.util.Log +import android.widget.RemoteViews +import androidx.core.content.ContextCompat +import no.naiv.tilfluktsrom.MainActivity +import no.naiv.tilfluktsrom.R +import no.naiv.tilfluktsrom.data.ShelterDatabase +import no.naiv.tilfluktsrom.location.ShelterFinder +import no.naiv.tilfluktsrom.util.DistanceUtils +import java.util.concurrent.TimeUnit + +/** + * Home screen widget showing the nearest shelter with distance. + * + * F-Droid flavor: uses LocationManager only (no Google Play Services). + */ +class ShelterWidgetProvider : AppWidgetProvider() { + + companion object { + private const val TAG = "ShelterWidget" + const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH" + private const val EXTRA_LATITUDE = "lat" + private const val EXTRA_LONGITUDE = "lon" + + fun requestUpdate(context: Context) { + val intent = Intent(context, ShelterWidgetProvider::class.java).apply { + action = ACTION_REFRESH + } + context.sendBroadcast(intent) + } + + fun requestUpdateWithLocation(context: Context, latitude: Double, longitude: Double) { + val intent = Intent(context, ShelterWidgetProvider::class.java).apply { + action = ACTION_REFRESH + putExtra(EXTRA_LATITUDE, latitude) + putExtra(EXTRA_LONGITUDE, longitude) + } + context.sendBroadcast(intent) + } + } + + override fun onEnabled(context: Context) { + super.onEnabled(context) + WidgetUpdateWorker.schedule(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + WidgetUpdateWorker.cancel(context) + } + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + WidgetUpdateWorker.schedule(context) + updateAllWidgetsAsync(context, null) + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + + if (intent.action == ACTION_REFRESH) { + val providedLocation = if (intent.hasExtra(EXTRA_LATITUDE)) { + Location("widget").apply { + latitude = intent.getDoubleExtra(EXTRA_LATITUDE, 0.0) + longitude = intent.getDoubleExtra(EXTRA_LONGITUDE, 0.0) + } + } else null + + updateAllWidgetsAsync(context, providedLocation) + } + } + + private fun updateAllWidgetsAsync(context: Context, providedLocation: Location?) { + val pendingResult = goAsync() + Thread { + try { + val appWidgetManager = AppWidgetManager.getInstance(context) + val widgetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, ShelterWidgetProvider::class.java) + ) + val location = providedLocation ?: getBestLocation(context) + for (appWidgetId in widgetIds) { + updateWidget(context, appWidgetManager, appWidgetId, location) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to update widgets", e) + } finally { + pendingResult.finish() + } + }.start() + } + + private fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + location: Location? + ) { + val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter) + + val openAppIntent = Intent(context, MainActivity::class.java) + val openAppPending = PendingIntent.getActivity( + context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widgetRoot, openAppPending) + + val refreshIntent = Intent(context, ShelterWidgetProvider::class.java).apply { + action = ACTION_REFRESH + } + val refreshPending = PendingIntent.getBroadcast( + context, 0, refreshIntent, PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending) + + if (ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + showFallback(context, views, context.getString(R.string.widget_open_app)) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + if (location == null) { + showFallback(context, views, context.getString(R.string.widget_no_location)) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + val shelters = try { + val dao = ShelterDatabase.getInstance(context).shelterDao() + kotlinx.coroutines.runBlocking { dao.getAllSheltersList() } + } catch (e: Exception) { + Log.e(TAG, "Failed to query shelters", e) + emptyList() + } + + if (shelters.isEmpty()) { + showFallback(context, views, context.getString(R.string.widget_no_data)) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + val nearest = ShelterFinder.findNearest( + shelters, location.latitude, location.longitude, 1 + ).firstOrNull() + + if (nearest == null) { + showFallback(context, views, context.getString(R.string.widget_no_data)) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + views.setTextViewText(R.id.widgetAddress, nearest.shelter.adresse) + views.setTextViewText( + R.id.widgetDetails, + context.getString(R.string.shelter_capacity, nearest.shelter.plasser) + ) + views.setTextViewText( + R.id.widgetDistance, + DistanceUtils.formatDistance(nearest.distanceMeters) + ) + views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context)) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + private fun showFallback(context: Context, views: RemoteViews, message: String) { + views.setTextViewText(R.id.widgetAddress, message) + views.setTextViewText(R.id.widgetDetails, "") + views.setTextViewText(R.id.widgetDistance, "") + views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context)) + } + + private fun formatTimestamp(context: Context): String { + val format = DateFormat.getTimeFormat(context) + val timeStr = format.format(System.currentTimeMillis()) + return context.getString(R.string.widget_updated_at, timeStr) + } + + /** + * Get the best available location via LocationManager or SharedPreferences. + * Safe to call from a background thread. + */ + private fun getBestLocation(context: Context): Location? { + if (ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) return null + + val lmLocation = getLocationManagerLocation(context) + if (lmLocation != null) return lmLocation + + return getSavedLocation(context) + } + + /** Returns null if older than 24 hours to avoid retaining stale location data. */ + private fun getSavedLocation(context: Context): Location? { + val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) + if (!prefs.contains("last_lat")) return null + val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) + if (age > 24 * 60 * 60 * 1000L) return null + return Location("saved").apply { + latitude = prefs.getFloat("last_lat", 0f).toDouble() + longitude = prefs.getFloat("last_lon", 0f).toDouble() + } + } + + private fun getLocationManagerLocation(context: Context): Location? { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) + as? LocationManager ?: return null + + try { + val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + val cached = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time } + if (cached != null) return cached + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException getting last known location", e) + return null + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val provider = when { + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> + LocationManager.NETWORK_PROVIDER + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) -> + LocationManager.GPS_PROVIDER + else -> return null + } + try { + val latch = java.util.concurrent.CountDownLatch(1) + var result: Location? = null + val signal = CancellationSignal() + locationManager.getCurrentLocation( + provider, signal, context.mainExecutor + ) { location -> + result = location + latch.countDown() + } + latch.await(10, TimeUnit.SECONDS) + signal.cancel() + return result + } catch (e: Exception) { + Log.e(TAG, "Active location request failed", e) + } + } + + return null + } +} diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt new file mode 100644 index 0000000..8d482f4 --- /dev/null +++ b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt @@ -0,0 +1,136 @@ +package no.naiv.tilfluktsrom.widget + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.os.Build +import android.os.CancellationSignal +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume + +/** + * Periodic background worker that refreshes the home screen widget. + * + * F-Droid flavor: uses LocationManager only (no Google Play Services). + */ +class WidgetUpdateWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "WidgetUpdateWorker" + private const val WORK_NAME = "widget_update" + private const val LOCATION_TIMEOUT_MS = 10_000L + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder( + 15, TimeUnit.MINUTES + ).build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + fun runOnce(context: Context) { + val request = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueue(request) + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + } + + override suspend fun doWork(): Result { + val location = requestFreshLocation() ?: getSavedLocation() + if (location != null) { + ShelterWidgetProvider.requestUpdateWithLocation( + applicationContext, location.latitude, location.longitude + ) + } else { + ShelterWidgetProvider.requestUpdate(applicationContext) + } + return Result.success() + } + + /** Returns null if older than 24 hours. */ + private fun getSavedLocation(): Location? { + val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) + if (!prefs.contains("last_lat")) return null + val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) + if (age > 24 * 60 * 60 * 1000L) return null + return Location("saved").apply { + latitude = prefs.getFloat("last_lat", 0f).toDouble() + longitude = prefs.getFloat("last_lon", 0f).toDouble() + } + } + + private suspend fun requestFreshLocation(): Location? { + val context = applicationContext + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED + ) return null + + return requestViaLocationManager() + } + + private suspend fun requestViaLocationManager(): Location? { + val locationManager = applicationContext.getSystemService(Context.LOCATION_SERVICE) + as? LocationManager ?: return null + + val provider = when { + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) -> + LocationManager.GPS_PROVIDER + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> + LocationManager.NETWORK_PROVIDER + else -> return null + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return requestCurrentLocation(locationManager, provider) + } + // API 26-29: fall back to passive cache + return try { + locationManager.getLastKnownLocation(provider) + } catch (e: SecurityException) { + null + } + } + + private suspend fun requestCurrentLocation(locationManager: LocationManager, provider: String): Location? { + return try { + withTimeoutOrNull(LOCATION_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + locationManager.getCurrentLocation( + provider, + signal, + applicationContext.mainExecutor + ) { location -> + if (cont.isActive) cont.resume(location) + } + cont.invokeOnCancellation { signal.cancel() } + } + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException requesting location via LocationManager", e) + null + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f411fb5..e522483 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,11 +23,21 @@ + + + + + + + pendingDeepLinkShelterId = null + val shelter = shelters.find { it.lokalId == id } + if (shelter != null) { + selectShelterByData(shelter) + } else { + Toast.makeText(this@MainActivity, R.string.error_shelter_not_found, Toast.LENGTH_SHORT).show() + } + } + if (currentLocation != null) { + updateNearestShelters(currentLocation!!) + } else { + showWaitingForLocation() + } } } catch (e: CancellationException) { throw e @@ -317,6 +381,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { try { locationProvider.locationUpdates().collectLatest { location -> currentLocation = location + saveLastLocation(location) updateNearestShelters(location) // Center map on first location fix @@ -369,6 +434,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { } updateSelectedShelterUI() + ShelterWidgetProvider.requestUpdate(this) } /** @@ -436,7 +502,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener { /** Update all UI elements for the currently selected shelter. */ private fun updateSelectedShelterUI() { val selected = selectedShelter ?: return - val distanceText = DistanceUtils.formatDistance(selected.distanceMeters) + val distanceText = if (selected.distanceMeters.isNaN()) { + getString(R.string.status_no_location) + } else { + DistanceUtils.formatDistance(selected.distanceMeters) + } // Update bottom sheet binding.selectedShelterAddress.text = selected.shelter.adresse @@ -454,10 +524,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener { R.string.direction_arrow_description, distanceText ) - // Update compass view + // Update compass view (large arrow gets a north indicator) binding.compassDistanceText.text = distanceText binding.compassAddressText.text = selected.shelter.adresse binding.directionArrow.setDirection(arrowAngle) + binding.directionArrow.setNorthAngle(-deviceHeading) binding.directionArrow.contentDescription = getString( R.string.direction_arrow_description, distanceText ) @@ -598,6 +669,37 @@ class MainActivity : AppCompatActivity(), SensorEventListener { } } + /** + * Share the currently selected shelter via ACTION_SEND. + * Includes address, capacity, geo: URI, and an HTTPS deep link + * that opens the app (if installed) or the PWA (in browser). + */ + private fun shareShelter() { + val selected = selectedShelter + if (selected == null) { + Toast.makeText(this, R.string.share_no_shelter, Toast.LENGTH_SHORT).show() + return + } + + val shelter = selected.shelter + val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.lokalId}" + val body = getString( + R.string.share_body, + shelter.adresse, + shelter.plasser, + shelter.latitude, + shelter.longitude, + deepLink + ) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_subject)) + putExtra(Intent.EXTRA_TEXT, body) + } + startActivity(Intent.createChooser(shareIntent, getString(R.string.action_share))) + } + /** Update the freshness indicator below the status bar with color-coded age. */ private fun updateFreshnessIndicator() { val lastUpdate = repository.getLastUpdateMs() @@ -631,6 +733,24 @@ class MainActivity : AppCompatActivity(), SensorEventListener { binding.loadingOverlay.visibility = View.GONE } + /** + * Show a waiting state in the bottom sheet when shelters are loaded + * but no GPS fix is available yet. + */ + private fun showWaitingForLocation() { + binding.selectedShelterAddress.text = getString(R.string.status_no_location) + binding.selectedShelterDetails.text = getString(R.string.status_shelters_loaded, allShelters.size) + } + + /** Persist last GPS fix so the widget can use it even when the app isn't running. */ + private fun saveLastLocation(location: Location) { + getSharedPreferences("widget_prefs", Context.MODE_PRIVATE).edit() + .putFloat("last_lat", location.latitude.toFloat()) + .putFloat("last_lon", location.longitude.toFloat()) + .putLong("last_time", System.currentTimeMillis()) + .apply() + } + private fun isNetworkAvailable(): Boolean { val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false @@ -725,6 +845,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { val arrowAngle = bearing - deviceHeading binding.directionArrow.setDirection(arrowAngle) + binding.directionArrow.setNorthAngle(-deviceHeading) binding.miniArrow.setDirection(arrowAngle) } @@ -738,9 +859,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { Log.w(TAG, "Compass accuracy degraded: $accuracy") binding.compassAddressText.let { tv -> val current = selectedShelter?.shelter?.adresse ?: "" - if (!current.contains("⚠")) { - tv.text = "⚠ $current" - } + tv.text = getString(R.string.compass_accuracy_warning, current) } } SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM, diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt index 4298c39..048a93d 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONArray @@ -43,6 +44,11 @@ class ShelterRepository(private val context: Context) { private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) + .addInterceptor(Interceptor { chain -> + chain.proceed(chain.request().newBuilder() + .header("User-Agent", "Tilfluktsrom/1.8.0") + .build()) + }) .build() /** Reactive stream of all shelters from local cache. */ diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt new file mode 100644 index 0000000..712562e --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt @@ -0,0 +1,43 @@ +package no.naiv.tilfluktsrom.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import no.naiv.tilfluktsrom.R + +/** + * Full-screen dialog showing app info, privacy statement, and copyright. + * Static content — all text comes from string resources for offline use and i18n. + */ +class AboutDialog : DialogFragment() { + + companion object { + const val TAG = "AboutDialog" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.Theme_Tilfluktsrom_Dialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.dialog_about, container, false) + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt new file mode 100644 index 0000000..053497b --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt @@ -0,0 +1,50 @@ +package no.naiv.tilfluktsrom.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import no.naiv.tilfluktsrom.R + +/** + * Full-screen dialog showing civil defense instructions. + * Static content — all text comes from string resources for offline use and i18n. + */ +class CivilDefenseInfoDialog : DialogFragment() { + + companion object { + const val TAG = "CivilDefenseInfoDialog" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.Theme_Tilfluktsrom_Dialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.dialog_civil_defense, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById(R.id.aboutLink)?.setOnClickListener { + AboutDialog().show(parentFragmentManager, AboutDialog.TAG) + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt index edd3154..aa50e8f 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt @@ -17,6 +17,9 @@ import no.naiv.tilfluktsrom.R * rotationAngle = shelterBearing - deviceHeading * This gives the direction the user needs to walk, adjusted for which * way they're currently facing. + * + * Optionally draws a discrete north indicator on the perimeter so users + * can validate compass calibration against a known direction. */ class DirectionArrowView @JvmOverloads constructor( context: Context, @@ -25,6 +28,7 @@ class DirectionArrowView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr) { private var rotationAngle = 0f + private var northAngle = Float.NaN private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = context.getColor(R.color.shelter_primary) @@ -37,7 +41,18 @@ class DirectionArrowView @JvmOverloads constructor( strokeWidth = 4f } + private val northPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0x99CFD8DC.toInt() // text_secondary at ~60% opacity + style = Paint.Style.FILL + } + + private val northTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0x99CFD8DC.toInt() + textAlign = Paint.Align.CENTER + } + private val arrowPath = Path() + private val northPath = Path() /** * Set the rotation angle in degrees. @@ -48,6 +63,16 @@ class DirectionArrowView @JvmOverloads constructor( invalidate() } + /** + * Set the angle to north in the view's coordinate space. + * This is typically -deviceHeading (where north is on screen). + * Set to Float.NaN to hide the north indicator. + */ + fun setNorthAngle(degrees: Float) { + northAngle = degrees + invalidate() + } + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) @@ -55,6 +80,11 @@ class DirectionArrowView @JvmOverloads constructor( val cy = height / 2f val size = minOf(width, height) * 0.4f + // Draw north indicator first (behind the main arrow) + if (!northAngle.isNaN()) { + drawNorthIndicator(canvas, cx, cy, size) + } + canvas.save() canvas.rotate(rotationAngle, cx, cy) @@ -74,4 +104,32 @@ class DirectionArrowView @JvmOverloads constructor( canvas.restore() } + + /** + * Draw a small north indicator: a tiny triangle and "N" label + * placed on the perimeter of the view, pointing inward toward center. + */ + private fun drawNorthIndicator(canvas: Canvas, cx: Float, cy: Float, arrowSize: Float) { + val radius = arrowSize * 1.35f + val tickSize = arrowSize * 0.1f + + // Scale "N" text relative to the view + northTextPaint.textSize = arrowSize * 0.18f + + canvas.save() + canvas.rotate(northAngle, cx, cy) + + // Small triangle at the top of the perimeter circle + northPath.reset() + northPath.moveTo(cx, cy - radius) + northPath.lineTo(cx - tickSize, cy - radius - tickSize * 1.8f) + northPath.lineTo(cx + tickSize, cy - radius - tickSize * 1.8f) + northPath.close() + canvas.drawPath(northPath, northPaint) + + // "N" label just outside the triangle + canvas.drawText("N", cx, cy - radius - tickSize * 2.2f, northTextPaint) + + canvas.restore() + } } diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt index 55655f2..f4a7307 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt @@ -1,5 +1,6 @@ package no.naiv.tilfluktsrom.ui +import android.view.HapticFeedbackConstants import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil @@ -52,10 +53,18 @@ class ShelterListAdapter( R.string.shelter_room_nr, item.shelter.romnr ) + binding.root.contentDescription = ctx.getString( + R.string.content_desc_shelter_item, + item.shelter.adresse, + DistanceUtils.formatDistance(item.distanceMeters), + item.shelter.plasser + ) + binding.root.isSelected = isSelected binding.root.alpha = if (isSelected) 1.0f else 0.7f binding.root.setOnClickListener { + it.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) val pos = adapterPosition if (pos != RecyclerView.NO_POSITION) { selectPosition(pos) diff --git a/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt deleted file mode 100644 index ef16094..0000000 --- a/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt +++ /dev/null @@ -1,167 +0,0 @@ -package no.naiv.tilfluktsrom.widget - -import android.Manifest -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationManager -import android.util.Log -import android.widget.RemoteViews -import androidx.core.content.ContextCompat -import kotlinx.coroutines.runBlocking -import no.naiv.tilfluktsrom.MainActivity -import no.naiv.tilfluktsrom.R -import no.naiv.tilfluktsrom.data.ShelterDatabase -import no.naiv.tilfluktsrom.location.ShelterFinder -import no.naiv.tilfluktsrom.util.DistanceUtils - -/** - * Home screen widget showing the nearest shelter with distance. - * - * Update strategy: no automatic periodic updates (updatePeriodMillis=0). - * Updates only when the user taps the refresh button, which sends ACTION_REFRESH. - * Tapping the widget body opens MainActivity. - * - * Uses LocationManager directly (not the hybrid LocationProvider) because - * BroadcastReceiver context makes FusedLocationProviderClient setup awkward. - * For a one-shot getLastKnownLocation, LocationManager is equally effective. - */ -class ShelterWidgetProvider : AppWidgetProvider() { - - companion object { - private const val TAG = "ShelterWidget" - const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH" - } - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - for (appWidgetId in appWidgetIds) { - updateWidget(context, appWidgetManager, appWidgetId) - } - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - - if (intent.action == ACTION_REFRESH) { - val appWidgetManager = AppWidgetManager.getInstance(context) - val widgetIds = appWidgetManager.getAppWidgetIds( - ComponentName(context, ShelterWidgetProvider::class.java) - ) - for (appWidgetId in widgetIds) { - updateWidget(context, appWidgetManager, appWidgetId) - } - } - } - - private fun updateWidget( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetId: Int - ) { - val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter) - - // Tapping widget body opens the app - val openAppIntent = Intent(context, MainActivity::class.java) - val openAppPending = PendingIntent.getActivity( - context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widgetRoot, openAppPending) - - // Refresh button sends our custom broadcast - val refreshIntent = Intent(context, ShelterWidgetProvider::class.java).apply { - action = ACTION_REFRESH - } - val refreshPending = PendingIntent.getBroadcast( - context, 0, refreshIntent, PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending) - - // Check location permission - if (ContextCompat.checkSelfPermission( - context, Manifest.permission.ACCESS_FINE_LOCATION - ) != PackageManager.PERMISSION_GRANTED - ) { - showFallback(views, context.getString(R.string.widget_open_app)) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - // Get last known location from LocationManager - val location = getLastKnownLocation(context) - if (location == null) { - showFallback(views, context.getString(R.string.widget_no_location)) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - // Query shelters from Room (fast: ~556 rows, <10ms) - val shelters = try { - val dao = ShelterDatabase.getInstance(context).shelterDao() - runBlocking { dao.getAllSheltersList() } - } catch (e: Exception) { - Log.e(TAG, "Failed to query shelters", e) - emptyList() - } - - if (shelters.isEmpty()) { - showFallback(views, context.getString(R.string.widget_no_data)) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - // Find nearest shelter - val nearest = ShelterFinder.findNearest( - shelters, location.latitude, location.longitude, 1 - ).firstOrNull() - - if (nearest == null) { - showFallback(views, context.getString(R.string.widget_no_data)) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - // Show shelter info - views.setTextViewText(R.id.widgetAddress, nearest.shelter.adresse) - views.setTextViewText( - R.id.widgetDetails, - context.getString(R.string.shelter_capacity, nearest.shelter.plasser) - ) - views.setTextViewText( - R.id.widgetDistance, - DistanceUtils.formatDistance(nearest.distanceMeters) - ) - - appWidgetManager.updateAppWidget(appWidgetId, views) - } - - /** Show a fallback message when location or data is unavailable. */ - private fun showFallback(views: RemoteViews, message: String) { - views.setTextViewText(R.id.widgetAddress, message) - views.setTextViewText(R.id.widgetDetails, "") - views.setTextViewText(R.id.widgetDistance, "") - } - - /** Get the best last known location from GPS and Network providers. */ - private fun getLastKnownLocation(context: Context): Location? { - val locationManager = context.getSystemService(Context.LOCATION_SERVICE) - as? LocationManager ?: return null - - return try { - val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) - listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time } - } catch (e: SecurityException) { - Log.e(TAG, "SecurityException getting last known location", e) - null - } - } -} diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..59969c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..32f43dc --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index fefe456..4c8701e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -31,14 +31,24 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" + android:accessibilityLiveRegion="polite" android:textColor="@color/status_text" android:textSize="12sp" tools:text="@string/status_ready" /> + + @@ -71,6 +82,7 @@ android:layout_width="match_parent" android:layout_height="0dp" android:background="@color/compass_bg" + android:contentDescription="@string/a11y_compass" android:visibility="gone" app:layout_constraintTop_toBottomOf="@id/statusBar" app:layout_constraintBottom_toTopOf="@id/bottomSheet"> @@ -211,6 +223,16 @@ android:textSize="13sp" tools:text="1.2 km - 400 plasser - Rom 776" /> + + @@ -230,6 +252,8 @@ android:background="@color/loading_bg" android:clickable="true" android:focusable="true" + android:importantForAccessibility="yes" + android:accessibilityLiveRegion="assertive" android:visibility="gone"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_civil_defense.xml b/app/src/main/res/layout/dialog_civil_defense.xml new file mode 100644 index 0000000..b4d0691 --- /dev/null +++ b/app/src/main/res/layout/dialog_civil_defense.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_nearest_shelter.xml b/app/src/main/res/layout/widget_nearest_shelter.xml index 5ac2ba1..9992358 100644 --- a/app/src/main/res/layout/widget_nearest_shelter.xml +++ b/app/src/main/res/layout/widget_nearest_shelter.xml @@ -43,6 +43,14 @@ android:textColor="@color/text_secondary" android:textSize="12sp" tools:text="400 places" /> + + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 59b9fcf..8f0f2d3 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -30,6 +30,7 @@ Lagre kart Lagre nå Tilbakestill navigasjonsvisning + Del tilfluktsrom Ingen frakoblet kart lagret. Kartet krever internett. @@ -41,6 +42,7 @@ Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen. Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata. Kompass er ikke tilgjengelig på denne enheten + Fant ikke tilfluktsrommet Tilfluktsromdata oppdatert Oppdatering mislyktes — bruker lagrede data @@ -49,12 +51,52 @@ \u00c5pne appen for posisjon Ingen tilfluktsromdata Trykk for \u00e5 oppdatere + Oppdatert %s Data er oppdatert Data er %d dager gammel Data er utdatert + + Tilfluktsrom + Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s + Ingen tilfluktsrom valgt + Retning til tilfluktsrom, %s unna + %1$s, %2$s, %3$d plasser + Upresist kompass - %s + Tilfluktsromkart + Kompassnavigasjon + + + Sivilforsvarsinformasjon + Hva du skal gjøre hvis alarmen går + 1. Viktig melding-signalet + Tre serier med korte støt med ett minutts stillhet mellom hver serie. Dette betyr: søk informasjon umiddelbart. Slå på DAB-radio, TV, eller sjekk offisielle kilder på nett. + 2. Flyalarm + Korte støt som varer omtrent ett minutt. Dette betyr umiddelbar fare for angrep — søk dekning nå. Gå til nærmeste tilfluktsrom, kjeller eller innerrom umiddelbart. + 3. Gå innendørs og finn dekning + Kom deg innendørs. Lukk alle vinduer, dører og ventilasjonsåpninger. Bruk denne appen for å finne nærmeste offentlige tilfluktsrom. Kompasset og kartet fungerer uten internett. Hvis det ikke er noe tilfluktsrom i nærheten, gå til en kjeller eller et innerrom bort fra vinduer. + 4. Lytt til NRK på DAB-radio + Lytt til NRK P1 på DAB-radio for offisielle oppdateringer og instruksjoner fra myndighetene. DAB-radio fungerer selv når mobilnettet og internett er nede. Ha en batteridrevet eller sveivbar DAB-radio som del av beredskapsutstyret ditt. + 5. Faren over + Én sammenhengende tone på omtrent 30 sekunder. Faren eller angrepet er over. Fortsett å følge instruksjoner fra myndighetene. + Kilde: DSB (Direktoratet for samfunnssikkerhet og beredskap) + + + Om Tilfluktsrom + Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen er laget for å fungere uten internett etter første oppsett — du trenger ikke nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din. + Personvern + Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester.\n\nGPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server. + Datakilder + Tilfluktsromdata er offentlig informasjon fra DSB (Direktoratet for samfunnssikkerhet og beredskap), distribuert via Geonorge. Kartfliser lastes fra OpenStreetMap. Begge lagres lokalt for frakoblet bruk. + Lagret på enheten din + • Tilfluktsromdatabase (offentlige data fra DSB)\n• Kartfliser for frakoblet bruk\n• Din siste GPS-posisjon (for hjemmeskjerm-widgeten)\n\nIngen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser. + Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom + Om denne appen + + + Opphavsrett © Ole-Morten Duesund diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index d1dc1a0..088398a 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -30,6 +30,7 @@ Lagre kart Lagre no Tilbakestill navigasjonsvising + Del tilfluktsrom Ingen fråkopla kart lagra. Kartet krev internett. @@ -41,6 +42,7 @@ Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga. Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata. Kompass er ikkje tilgjengeleg på denne eininga + Fann ikkje tilfluktsrommet Tilfluktsromdata oppdatert Oppdatering mislukkast — brukar lagra data @@ -49,12 +51,52 @@ Opne appen for posisjon Ingen tilfluktsromdata Trykk for \u00e5 oppdatere + Oppdatert %s Data er oppdatert Data er %d dagar gammal Data er utdatert + + Tilfluktsrom + Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s + Ingen tilfluktsrom valt + Retning til tilfluktsrom, %s unna + %1$s, %2$s, %3$d plassar + Upresis kompass - %s + Tilfluktsromkart + Kompassnavigasjon + + + Sivilforsvarsinformasjon + Kva du skal gjere om alarmen går + 1. Viktig melding-signalet + Tre seriar med korte støyt med eitt minutt stille mellom kvar serie. Dette tyder: søk informasjon med ein gong. Slå på DAB-radio, TV, eller sjekk offisielle kjelder på nett. + 2. Flyalarm + Korte støyt som varar omtrent eitt minutt. Dette tyder umiddelbar fare for åtak — søk dekning no. Gå til næraste tilfluktsrom, kjellar eller innerrom med ein gong. + 3. Gå innandørs og finn dekning + Kom deg innandørs. Lukk alle vindauge, dører og ventilasjonsopningar. Bruk denne appen for å finne næraste offentlege tilfluktsrom. Kompasset og kartet fungerer utan internett. Om det ikkje er noko tilfluktsrom i nærleiken, gå til ein kjellar eller eit innerrom bort frå vindauge. + 4. Lytt til NRK på DAB-radio + Lytt til NRK P1 på DAB-radio for offisielle oppdateringar og instruksjonar frå styresmaktene. DAB-radio fungerer sjølv når mobilnettet og internett er nede. Ha ein batteridrive eller sveivbar DAB-radio som del av beredskapsutstyret ditt. + 5. Faren over + Éin samanhengande tone på omtrent 30 sekund. Faren eller åtaket er over. Hald fram med å følgje instruksjonar frå styresmaktene. + Kjelde: DSB (Direktoratet for samfunnstryggleik og beredskap) + + + Om Tilfluktsrom + Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen er laga for å fungere utan internett etter fyrste oppsett — du treng ikkje nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din. + Personvern + Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester.\n\nGPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar. + Datakjelder + Tilfluktsromdata er offentleg informasjon frå DSB (Direktoratet for samfunnstryggleik og beredskap), distribuert via Geonorge. Kartfliser vert lasta frå OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk. + Lagra på eininga di + • Tilfluktsromdatabase (offentlege data frå DSB)\n• Kartfliser for fråkopla bruk\n• Din siste GPS-posisjon (for heimeskjerm-widgeten)\n\nIngen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser. + Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom + Om denne appen + + + Opphavsrett © Ole-Morten Duesund diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 0a9d1a1..59aae1f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,7 +7,7 @@ #1A1A2E #16213E - #B0BEC5 + #CFD8DC #1A1A2E #0F0F23 #CC000000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80d4f9d..34afc98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ Cache map Cache now Reset navigation view + Share shelter No offline map cached. Map requires internet. @@ -41,6 +42,7 @@ Could not download shelter data. Check your internet connection. No cached data available. Connect to the internet to download shelter data. Compass not available on this device + Shelter not found Shelter data updated Update failed — using cached data @@ -49,12 +51,52 @@ Open app for location No shelter data Tap to refresh + Updated %s Data is up to date Data is %d days old Data is outdated + + Emergency shelter + Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s + No shelter selected + Direction to shelter, %s away + %1$s, %2$s, %3$d places + Low accuracy - %s + Shelter map + Compass navigation + + + Civil defense information + What to do if the alarm sounds + 1. Important message signal + Three series of short blasts with one minute of silence between each series. This means: seek information immediately. Turn on DAB radio, TV, or check official sources online. + 2. Air raid alarm + Short blasts lasting approximately one minute. This means immediate danger of attack — seek shelter now. Go to the nearest shelter, basement, or inner room immediately. + 3. Go indoors and find shelter + Get indoors. Close all windows, doors, and ventilation openings. Use this app to find the nearest public shelter (tilfluktsrom). The compass and map work offline. If no shelter is nearby, go to a basement or an inner room away from windows. + 4. Listen to NRK on DAB radio + Tune in to NRK P1 on DAB radio for official updates and instructions from authorities. DAB radio works even when mobile networks and the internet are down. Keep a battery-powered or hand-crank DAB radio as part of your emergency kit. + 5. All clear + One continuous tone lasting approximately 30 seconds. The danger or attack is over. Continue to follow instructions from authorities. + Source: DSB (Norwegian Directorate for Civil Protection) + + + About Tilfluktsrom + Tilfluktsrom helps you find the nearest public shelter in Norway. The app is designed to work offline after initial setup — no internet required to find shelters, navigate by compass, or share your location. + Privacy + This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services.\n\nYour GPS location is used only on your device to find nearby shelters and is never sent to any server. + Data sources + Shelter data is public information from DSB (Norwegian Directorate for Civil Protection), distributed via Geonorge. Map tiles are loaded from OpenStreetMap. Both are cached locally for offline use. + Stored on your device + • Shelter database (public data from DSB)\n• Map tiles for offline use\n• Your last GPS position (for the home screen widget)\n\nNo data leaves your device except requests to download shelter data and map tiles. + Open source — kode.naiv.no/olemd/tilfluktsrom + About this app + + + Copyright © Ole-Morten Duesund diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index aa679b7..098a7f3 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -8,4 +8,9 @@ @color/status_bar_bg @color/background + + diff --git a/app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt b/app/src/standard/java/no/naiv/tilfluktsrom/location/LocationProvider.kt similarity index 100% rename from app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt rename to app/src/standard/java/no/naiv/tilfluktsrom/location/LocationProvider.kt diff --git a/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt new file mode 100644 index 0000000..42a9fdc --- /dev/null +++ b/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt @@ -0,0 +1,349 @@ +package no.naiv.tilfluktsrom.widget + +import android.Manifest +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.os.Build +import android.os.CancellationSignal +import android.text.format.DateFormat +import android.util.Log +import android.widget.RemoteViews +import androidx.core.content.ContextCompat +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.Tasks +import no.naiv.tilfluktsrom.MainActivity +import no.naiv.tilfluktsrom.R +import no.naiv.tilfluktsrom.data.ShelterDatabase +import no.naiv.tilfluktsrom.location.ShelterFinder +import no.naiv.tilfluktsrom.util.DistanceUtils +import java.util.concurrent.TimeUnit + +/** + * Home screen widget showing the nearest shelter with distance. + * + * Update strategy: + * - Background: WorkManager runs every 15 min while widget exists + * - Live: MainActivity sends ACTION_REFRESH on each GPS location update + * - Manual: user taps the refresh button on the widget + * + * Location resolution (in priority order): + * 1. Location provided via intent extras (from WorkManager or MainActivity) + * 2. FusedLocationProviderClient cache/active request (Play Services) + * 3. LocationManager cache/active request (AOSP fallback) + * 4. Last GPS fix saved to SharedPreferences by MainActivity + * + * Note: Background processes cannot reliably trigger GPS hardware on + * Android 8+. The SharedPreferences fallback ensures the widget works + * after app updates and reboots without opening the app first. + */ +class ShelterWidgetProvider : AppWidgetProvider() { + + companion object { + private const val TAG = "ShelterWidget" + const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH" + private const val EXTRA_LATITUDE = "lat" + private const val EXTRA_LONGITUDE = "lon" + + /** Trigger a widget refresh from anywhere (e.g. MainActivity on location update). */ + fun requestUpdate(context: Context) { + val intent = Intent(context, ShelterWidgetProvider::class.java).apply { + action = ACTION_REFRESH + } + context.sendBroadcast(intent) + } + + /** Trigger a widget refresh with a known location (from WidgetUpdateWorker). */ + fun requestUpdateWithLocation(context: Context, latitude: Double, longitude: Double) { + val intent = Intent(context, ShelterWidgetProvider::class.java).apply { + action = ACTION_REFRESH + putExtra(EXTRA_LATITUDE, latitude) + putExtra(EXTRA_LONGITUDE, longitude) + } + context.sendBroadcast(intent) + } + } + + override fun onEnabled(context: Context) { + super.onEnabled(context) + WidgetUpdateWorker.schedule(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + WidgetUpdateWorker.cancel(context) + } + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + WidgetUpdateWorker.schedule(context) + updateAllWidgetsAsync(context, null) + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + + if (intent.action == ACTION_REFRESH) { + val providedLocation = if (intent.hasExtra(EXTRA_LATITUDE)) { + Location("widget").apply { + latitude = intent.getDoubleExtra(EXTRA_LATITUDE, 0.0) + longitude = intent.getDoubleExtra(EXTRA_LONGITUDE, 0.0) + } + } else null + + updateAllWidgetsAsync(context, providedLocation) + } + } + + /** + * Run widget update on a background thread so we can call + * FusedLocationProviderClient.getLastLocation() synchronously. + * Uses goAsync() to keep the BroadcastReceiver alive until done. + */ + private fun updateAllWidgetsAsync(context: Context, providedLocation: Location?) { + val pendingResult = goAsync() + Thread { + try { + val appWidgetManager = AppWidgetManager.getInstance(context) + val widgetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, ShelterWidgetProvider::class.java) + ) + val location = providedLocation ?: getBestLocation(context) + for (appWidgetId in widgetIds) { + updateWidget(context, appWidgetManager, appWidgetId, location) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to update widgets", e) + } finally { + pendingResult.finish() + } + }.start() + } + + private fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + location: Location? + ) { + val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter) + + // Tapping widget body opens the app + val openAppIntent = Intent(context, MainActivity::class.java) + val openAppPending = PendingIntent.getActivity( + context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widgetRoot, openAppPending) + + // Refresh button sends our custom broadcast + val refreshIntent = Intent(context, ShelterWidgetProvider::class.java).apply { + action = ACTION_REFRESH + } + val refreshPending = PendingIntent.getBroadcast( + context, 0, refreshIntent, PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending) + + // Check permission + if (ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + showFallback(context, views, context.getString(R.string.widget_open_app)) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + if (location == null) { + showFallback(context, views, context.getString(R.string.widget_no_location)) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + // Query shelters from Room (fast: ~556 rows, <10ms) + val shelters = try { + val dao = ShelterDatabase.getInstance(context).shelterDao() + kotlinx.coroutines.runBlocking { dao.getAllSheltersList() } + } catch (e: Exception) { + Log.e(TAG, "Failed to query shelters", e) + emptyList() + } + + if (shelters.isEmpty()) { + showFallback(context, views, context.getString(R.string.widget_no_data)) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + // Find nearest shelter + val nearest = ShelterFinder.findNearest( + shelters, location.latitude, location.longitude, 1 + ).firstOrNull() + + if (nearest == null) { + showFallback(context, views, context.getString(R.string.widget_no_data)) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + // Show shelter info + views.setTextViewText(R.id.widgetAddress, nearest.shelter.adresse) + views.setTextViewText( + R.id.widgetDetails, + context.getString(R.string.shelter_capacity, nearest.shelter.plasser) + ) + views.setTextViewText( + R.id.widgetDistance, + DistanceUtils.formatDistance(nearest.distanceMeters) + ) + views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context)) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + /** Show a fallback message when location or data is unavailable. */ + private fun showFallback(context: Context, views: RemoteViews, message: String) { + views.setTextViewText(R.id.widgetAddress, message) + views.setTextViewText(R.id.widgetDetails, "") + views.setTextViewText(R.id.widgetDistance, "") + views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context)) + } + + /** Format current time as "Updated HH:mm", respecting the user's 12/24h preference. */ + private fun formatTimestamp(context: Context): String { + val format = DateFormat.getTimeFormat(context) + val timeStr = format.format(System.currentTimeMillis()) + return context.getString(R.string.widget_updated_at, timeStr) + } + + /** + * Get the best available location. + * Tries FusedLocationProviderClient first (Play Services, better cache), + * then LocationManager (AOSP), then last saved GPS fix from SharedPreferences. + * Safe to call from a background thread. + */ + private fun getBestLocation(context: Context): Location? { + if (ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) return null + + // Try Play Services first — maintains a better location cache + val fusedLocation = getFusedLastLocation(context) + if (fusedLocation != null) return fusedLocation + + // Fall back to LocationManager + val lmLocation = getLocationManagerLocation(context) + if (lmLocation != null) return lmLocation + + // Fall back to last location saved by MainActivity + return getSavedLocation(context) + } + + /** Read the last GPS fix persisted by MainActivity to SharedPreferences. + * Returns null if older than 24 hours to avoid retaining stale location data. */ + private fun getSavedLocation(context: Context): Location? { + val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) + if (!prefs.contains("last_lat")) return null + val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) + if (age > 24 * 60 * 60 * 1000L) return null + return Location("saved").apply { + latitude = prefs.getFloat("last_lat", 0f).toDouble() + longitude = prefs.getFloat("last_lon", 0f).toDouble() + } + } + + /** + * Get location via Play Services — blocks, call from background thread. + * Tries cached location first, then actively requests a fix if cache is empty. + */ + private fun getFusedLastLocation(context: Context): Location? { + if (!isPlayServicesAvailable(context)) return null + return try { + val client = LocationServices.getFusedLocationProviderClient(context) + // Try cache first (instant) + val cached = Tasks.await(client.lastLocation, 3, TimeUnit.SECONDS) + if (cached != null) return cached + // Cache empty — actively request a fix (turns on GPS/network) + val task = client.getCurrentLocation( + Priority.PRIORITY_BALANCED_POWER_ACCURACY, null + ) + Tasks.await(task, 10, TimeUnit.SECONDS) + } catch (e: Exception) { + Log.w(TAG, "FusedLocationProvider failed", e) + null + } + } + + /** + * Get location via LocationManager (AOSP). + * Tries cache first, then actively requests a fix on API 30+. + * Blocks — call from background thread. + */ + private fun getLocationManagerLocation(context: Context): Location? { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) + as? LocationManager ?: return null + + // Try cache first + try { + val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + val cached = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time } + if (cached != null) return cached + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException getting last known location", e) + return null + } + + // Cache empty — actively request on API 30+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val provider = when { + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> + LocationManager.NETWORK_PROVIDER + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) -> + LocationManager.GPS_PROVIDER + else -> return null + } + try { + val latch = java.util.concurrent.CountDownLatch(1) + var result: Location? = null + val signal = CancellationSignal() + locationManager.getCurrentLocation( + provider, signal, context.mainExecutor + ) { location -> + result = location + latch.countDown() + } + latch.await(10, TimeUnit.SECONDS) + signal.cancel() + return result + } catch (e: Exception) { + Log.e(TAG, "Active location request failed", e) + } + } + + return null + } + + private fun isPlayServicesAvailable(context: Context): Boolean { + return try { + val result = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(context) + result == ConnectionResult.SUCCESS + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt new file mode 100644 index 0000000..91e5d3b --- /dev/null +++ b/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt @@ -0,0 +1,187 @@ +package no.naiv.tilfluktsrom.widget + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.os.Build +import android.os.CancellationSignal +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.Tasks +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume + +/** + * Periodic background worker that refreshes the home screen widget. + * + * Scheduled every 15 minutes (WorkManager's minimum interval). + * Actively requests a fresh location fix to populate the system cache, + * then triggers the widget's existing update logic via broadcast. + * + * Location strategy (mirrors LocationProvider): + * - Play Services: FusedLocationProviderClient.getCurrentLocation() + * - AOSP API 30+: LocationManager.getCurrentLocation() + * - AOSP API 26-29: LocationManager.getLastKnownLocation() + */ +class WidgetUpdateWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "WidgetUpdateWorker" + private const val WORK_NAME = "widget_update" + private const val LOCATION_TIMEOUT_MS = 10_000L + + /** Schedule periodic widget updates. Safe to call multiple times. */ + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder( + 15, TimeUnit.MINUTES + ).build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + /** Run once immediately (e.g. when widget is first placed or location was unavailable). */ + fun runOnce(context: Context) { + val request = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueue(request) + } + + /** Cancel periodic updates (e.g. when all widgets are removed). */ + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + } + + override suspend fun doWork(): Result { + val location = requestFreshLocation() ?: getSavedLocation() + if (location != null) { + ShelterWidgetProvider.requestUpdateWithLocation( + applicationContext, location.latitude, location.longitude + ) + } else { + ShelterWidgetProvider.requestUpdate(applicationContext) + } + return Result.success() + } + + /** Read the last GPS fix persisted by MainActivity. + * Returns null if older than 24 hours. */ + private fun getSavedLocation(): Location? { + val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) + if (!prefs.contains("last_lat")) return null + val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) + if (age > 24 * 60 * 60 * 1000L) return null + return Location("saved").apply { + latitude = prefs.getFloat("last_lat", 0f).toDouble() + longitude = prefs.getFloat("last_lon", 0f).toDouble() + } + } + + /** + * Actively request a location fix and return it. + * Returns null if permission is missing or location is unavailable. + */ + private suspend fun requestFreshLocation(): Location? { + val context = applicationContext + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED + ) return null + + return if (isPlayServicesAvailable()) { + requestViaPlayServices() + } else { + requestViaLocationManager() + } + } + + /** Use FusedLocationProviderClient.getCurrentLocation() — best accuracy, best cache. */ + private suspend fun requestViaPlayServices(): Location? { + return try { + val client = LocationServices.getFusedLocationProviderClient(applicationContext) + val task = client.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) + Tasks.await(task, LOCATION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException requesting location via Play Services", e) + null + } catch (e: Exception) { + Log.w(TAG, "Play Services location request failed, falling back", e) + requestViaLocationManager() + } + } + + /** Use LocationManager.getCurrentLocation() (API 30+) or getLastKnownLocation() fallback. */ + private suspend fun requestViaLocationManager(): Location? { + val locationManager = applicationContext.getSystemService(Context.LOCATION_SERVICE) + as? LocationManager ?: return null + + val provider = when { + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) -> + LocationManager.GPS_PROVIDER + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> + LocationManager.NETWORK_PROVIDER + else -> return null + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return requestCurrentLocation(locationManager, provider) + } + // API 26-29: fall back to passive cache + return try { + locationManager.getLastKnownLocation(provider) + } catch (e: SecurityException) { + null + } + } + + /** API 30+: actively request a single location fix. */ + private suspend fun requestCurrentLocation(locationManager: LocationManager, provider: String): Location? { + return try { + withTimeoutOrNull(LOCATION_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + locationManager.getCurrentLocation( + provider, + signal, + applicationContext.mainExecutor + ) { location -> + if (cont.isActive) cont.resume(location) + } + cont.invokeOnCancellation { signal.cancel() } + } + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException requesting location via LocationManager", e) + null + } + } + + private fun isPlayServicesAvailable(): Boolean { + return try { + val result = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(applicationContext) + result == ConnectionResult.SUCCESS + } catch (e: Exception) { + false + } + } +} diff --git a/fastlane/metadata/android/en-US/changelogs/3.txt b/fastlane/metadata/android/en-US/changelogs/3.txt new file mode 100644 index 0000000..8e9bf1e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3.txt @@ -0,0 +1,4 @@ +• Hybrid location: uses Play Services when available, falls back to standard Android GPS +• Data freshness indicator shows when shelter data was last updated +• Home screen widget showing nearest shelter with distance +• Bundled shelter data for instant offline use on first launch diff --git a/fastlane/metadata/android/en-US/changelogs/4.txt b/fastlane/metadata/android/en-US/changelogs/4.txt new file mode 100644 index 0000000..2f018ac --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4.txt @@ -0,0 +1,3 @@ +• Share shelter location with others via any app +• Deep link support — open shared shelters directly in the app +• Tap any marker on the map to select and navigate to it diff --git a/fastlane/metadata/android/en-US/changelogs/5.txt b/fastlane/metadata/android/en-US/changelogs/5.txt new file mode 100644 index 0000000..0dafa07 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5.txt @@ -0,0 +1,2 @@ +• Widget now updates automatically every 15 minutes via WorkManager +• Fixed widget not showing data without opening the app first diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt new file mode 100644 index 0000000..aad04e7 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/6.txt @@ -0,0 +1,4 @@ +• Civil defense information: what to do if the alarm sounds (based on DSB guidelines) +• Improved accessibility: screen reader labels, better contrast, haptic feedback +• Widget shows "Updated 14:32" instead of bare timestamp +• Copyright notice added diff --git a/fastlane/metadata/android/en-US/changelogs/7.txt b/fastlane/metadata/android/en-US/changelogs/7.txt new file mode 100644 index 0000000..72dc0ac --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/7.txt @@ -0,0 +1,6 @@ +• Accessibility: screen reader labels on shelter list, compass, and direction arrows +• Improved contrast on status bar text (WCAG AA) +• Haptic feedback on all buttons and list items +• Widget timestamp now shows "Updated 14:32" +• Softer civil defense dialog title +• Copyright notice added diff --git a/fastlane/metadata/android/en-US/changelogs/8.txt b/fastlane/metadata/android/en-US/changelogs/8.txt new file mode 100644 index 0000000..c6d65db --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/8.txt @@ -0,0 +1,2 @@ +- Show waiting status when GPS is unavailable instead of empty shelter list +- Add F-Droid screenshots for all locales diff --git a/fastlane/metadata/android/en-US/changelogs/9.txt b/fastlane/metadata/android/en-US/changelogs/9.txt new file mode 100644 index 0000000..5c85529 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/9.txt @@ -0,0 +1,2 @@ +- Add F-Droid build flavor without Google Play Services +- Move signing config out of source code diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..839991c --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,15 @@ +Find the nearest public emergency shelter (tilfluktsrom) in Norway. Built for emergencies — works fully offline after first use. + +Features: +• Shows the 3 nearest shelters with distance and capacity +• Compass navigation — direction arrow points to the selected shelter +• Offline map — map tiles are cached automatically for use without internet +• Select any shelter — tap any marker on the map to navigate there +• Home screen widget — shows nearest shelter at a glance +• Share shelters — send shelter location to others via any app +• Civil defense info — what to do if the alarm sounds +• Multilingual — English, Bokmål, and Nynorsk + +The app uses open data from Geonorge (Norwegian Mapping Authority) covering approximately 556 public shelters across Norway. + +Works on degoogled devices: the app uses Google Play Services for better location accuracy when available, but falls back to standard Android location APIs on devices without Play Services (LineageOS, GrapheneOS, /e/OS, etc.). diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..f156be8 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1_map_view.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1_map_view.png new file mode 100644 index 0000000..39b32bd Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1_map_view.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2_shelter_selected.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2_shelter_selected.png new file mode 100644 index 0000000..c44f0cb Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2_shelter_selected.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3_compass_view.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3_compass_view.png new file mode 100644 index 0000000..ea6c599 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3_compass_view.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4_civil_defense_info.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4_civil_defense_info.png new file mode 100644 index 0000000..a5d2682 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4_civil_defense_info.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5_about.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5_about.png new file mode 100644 index 0000000..584592e Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5_about.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..ae7bd40 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Find the nearest public emergency shelter in Norway — works offline diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..04599a8 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Tilfluktsrom diff --git a/fastlane/metadata/android/nb-NO/changelogs/3.txt b/fastlane/metadata/android/nb-NO/changelogs/3.txt new file mode 100644 index 0000000..e59b524 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/3.txt @@ -0,0 +1,4 @@ +• Hybrid posisjon: bruker Play Services når tilgjengelig, faller tilbake til standard Android-GPS +• Dataferskhetsindikator viser når tilfluktsromdata sist ble oppdatert +• Hjemskjerm-widget som viser nærmeste tilfluktsrom med avstand +• Medfølgende tilfluktsromdata for umiddelbar frakoblet bruk ved første oppstart diff --git a/fastlane/metadata/android/nb-NO/changelogs/4.txt b/fastlane/metadata/android/nb-NO/changelogs/4.txt new file mode 100644 index 0000000..421115d --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/4.txt @@ -0,0 +1,3 @@ +• Del tilfluktsrom med andre via en hvilken som helst app +• Støtte for dyplenker — åpne delte tilfluktsrom direkte i appen +• Trykk på en markør i kartet for å velge og navigere dit diff --git a/fastlane/metadata/android/nb-NO/changelogs/5.txt b/fastlane/metadata/android/nb-NO/changelogs/5.txt new file mode 100644 index 0000000..f5262c0 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/5.txt @@ -0,0 +1,2 @@ +• Widget oppdateres nå automatisk hvert 15. minutt via WorkManager +• Fikset at widget ikke viste data uten å åpne appen først diff --git a/fastlane/metadata/android/nb-NO/changelogs/6.txt b/fastlane/metadata/android/nb-NO/changelogs/6.txt new file mode 100644 index 0000000..a6dfe8c --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/6.txt @@ -0,0 +1,4 @@ +• Sivilforsvarsinformasjon: hva du skal gjøre hvis alarmen går (basert på DSB-retningslinjer) +• Forbedret tilgjengelighet: skjermleseretiketter, bedre kontrast, haptisk tilbakemelding +• Widget viser «Oppdatert 14:32» i stedet for bare tidsstempel +• Opphavsrettsmelding lagt til diff --git a/fastlane/metadata/android/nb-NO/changelogs/7.txt b/fastlane/metadata/android/nb-NO/changelogs/7.txt new file mode 100644 index 0000000..2e78f0f --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/7.txt @@ -0,0 +1,6 @@ +• Tilgjengelighet: skjermleseretiketter på tilfluktsromliste, kompass og retningspil +• Forbedret kontrast på statuslinjetekst (WCAG AA) +• Haptisk tilbakemelding på alle knapper og listeelement +• Widget-tidsstempel viser nå «Oppdatert 14:32» +• Mildere tittel på sivilforsvarsdialog +• Opphavsrettsmelding lagt til diff --git a/fastlane/metadata/android/nb-NO/changelogs/8.txt b/fastlane/metadata/android/nb-NO/changelogs/8.txt new file mode 100644 index 0000000..49bed3b --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/8.txt @@ -0,0 +1,2 @@ +- Vis ventestatus når GPS ikke er tilgjengelig i stedet for tom tilfluktsromliste +- Legg til F-Droid-skjermbilder for alle språk diff --git a/fastlane/metadata/android/nb-NO/changelogs/9.txt b/fastlane/metadata/android/nb-NO/changelogs/9.txt new file mode 100644 index 0000000..49d8038 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/9.txt @@ -0,0 +1,2 @@ +- Legg til F-Droid-byggvariant uten Google Play Services +- Flytt signeringskonfigurasjon ut av kildekoden diff --git a/fastlane/metadata/android/nb-NO/full_description.txt b/fastlane/metadata/android/nb-NO/full_description.txt new file mode 100644 index 0000000..bd4b8b9 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/full_description.txt @@ -0,0 +1,15 @@ +Finn nærmeste offentlige tilfluktsrom i Norge. Bygd for nødsituasjoner — fungerer helt uten internett etter første gangs bruk. + +Funksjoner: +• Viser de 3 nærmeste tilfluktsrommene med avstand og kapasitet +• Kompassnavigasjon — retningspil som peker mot valgt tilfluktsrom +• Frakoblet kart — kartfliser lagres automatisk for bruk uten nett +• Velg fritt — trykk på en markør i kartet for å navigere dit +• Hjemskjerm-widget — viser nærmeste tilfluktsrom med ett blikk +• Del tilfluktsrom — send posisjon til andre via en hvilken som helst app +• Sivilforsvarsinformasjon — hva du skal gjøre hvis alarmen går +• Flerspråklig — engelsk, bokmål og nynorsk + +Appen bruker åpne data fra Geonorge (Kartverket) med ca. 556 offentlige tilfluktsrom i hele Norge. + +Fungerer på de-Google-enheter: appen bruker Google Play Services for bedre posisjonsdata når det er tilgjengelig, men faller tilbake til standard Android-posisjons-API-er på enheter uten Play Services (LineageOS, GrapheneOS, /e/OS osv.). diff --git a/fastlane/metadata/android/nb-NO/images/icon.png b/fastlane/metadata/android/nb-NO/images/icon.png new file mode 100644 index 0000000..f156be8 Binary files /dev/null and b/fastlane/metadata/android/nb-NO/images/icon.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png new file mode 100644 index 0000000..39b32bd Binary files /dev/null and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png new file mode 100644 index 0000000..c44f0cb Binary files /dev/null and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png new file mode 100644 index 0000000..ea6c599 Binary files /dev/null and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png new file mode 100644 index 0000000..a5d2682 Binary files /dev/null and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/5_about.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/5_about.png new file mode 100644 index 0000000..584592e Binary files /dev/null and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/5_about.png differ diff --git a/fastlane/metadata/android/nb-NO/short_description.txt b/fastlane/metadata/android/nb-NO/short_description.txt new file mode 100644 index 0000000..09ec3b6 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/short_description.txt @@ -0,0 +1 @@ +Finn nærmeste offentlige tilfluktsrom i Norge — fungerer uten nett diff --git a/fastlane/metadata/android/nb-NO/title.txt b/fastlane/metadata/android/nb-NO/title.txt new file mode 100644 index 0000000..04599a8 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/title.txt @@ -0,0 +1 @@ +Tilfluktsrom diff --git a/fastlane/metadata/android/nn-NO/changelogs/3.txt b/fastlane/metadata/android/nn-NO/changelogs/3.txt new file mode 100644 index 0000000..a5bd8c3 --- /dev/null +++ b/fastlane/metadata/android/nn-NO/changelogs/3.txt @@ -0,0 +1,4 @@ +• Hybrid posisjon: brukar Play Services når tilgjengeleg, fell tilbake til standard Android-GPS +• Dataferskheitsindikator viser når tilfluktsromdata sist vart oppdatert +• Heimeskjerm-widget som viser næraste tilfluktsrom med avstand +• Medfølgjande tilfluktsromdata for umiddelbar fråkopla bruk ved fyrste oppstart diff --git a/fastlane/metadata/android/nn-NO/changelogs/4.txt b/fastlane/metadata/android/nn-NO/changelogs/4.txt new file mode 100644 index 0000000..8195b7f --- /dev/null +++ b/fastlane/metadata/android/nn-NO/changelogs/4.txt @@ -0,0 +1,3 @@ +• Del tilfluktsrom med andre via ei kva som helst app +• Støtte for djuplenker — opne delte tilfluktsrom direkte i appen +• Trykk på ein markør i kartet for å velje og navigere dit diff --git a/fastlane/metadata/android/nn-NO/changelogs/5.txt b/fastlane/metadata/android/nn-NO/changelogs/5.txt new file mode 100644 index 0000000..1cfa5a2 --- /dev/null +++ b/fastlane/metadata/android/nn-NO/changelogs/5.txt @@ -0,0 +1,2 @@ +• Widget oppdaterast no automatisk kvart 15. minutt via WorkManager +• Fiksa at widget ikkje viste data utan å opne appen fyrst diff --git a/fastlane/metadata/android/nn-NO/changelogs/6.txt b/fastlane/metadata/android/nn-NO/changelogs/6.txt new file mode 100644 index 0000000..671a17d --- /dev/null +++ b/fastlane/metadata/android/nn-NO/changelogs/6.txt @@ -0,0 +1,4 @@ +• Sivilforsvarsinformasjon: kva du skal gjere om alarmen går (basert på DSB-retningslinjer) +• Forbetra tilgjenge: skjermlesar-etiketter, betre kontrast, haptisk tilbakemelding +• Widget viser «Oppdatert 14:32» i staden for berre tidsstempel +• Opphavsrettsmelding lagt til diff --git a/fastlane/metadata/android/nn-NO/changelogs/7.txt b/fastlane/metadata/android/nn-NO/changelogs/7.txt new file mode 100644 index 0000000..211d811 --- /dev/null +++ b/fastlane/metadata/android/nn-NO/changelogs/7.txt @@ -0,0 +1,6 @@ +• Tilgjenge: skjermlesar-etiketter på tilfluktsromliste, kompass og retningspil +• Forbetra kontrast på statuslinjetekst (WCAG AA) +• Haptisk tilbakemelding på alle knappar og listeelement +• Widget-tidsstempel viser no «Oppdatert 14:32» +• Mildare tittel på sivilforsvarsdialog +• Opphavsrettsmelding lagt til diff --git a/fastlane/metadata/android/nn-NO/changelogs/8.txt b/fastlane/metadata/android/nn-NO/changelogs/8.txt new file mode 100644 index 0000000..d96550b --- /dev/null +++ b/fastlane/metadata/android/nn-NO/changelogs/8.txt @@ -0,0 +1,2 @@ +- Vis ventestatus når GPS ikkje er tilgjengeleg i staden for tom tilfluktsromliste +- Legg til F-Droid-skjermbilete for alle språk diff --git a/fastlane/metadata/android/nn-NO/changelogs/9.txt b/fastlane/metadata/android/nn-NO/changelogs/9.txt new file mode 100644 index 0000000..b71da28 --- /dev/null +++ b/fastlane/metadata/android/nn-NO/changelogs/9.txt @@ -0,0 +1,2 @@ +- Legg til F-Droid-byggvariant utan Google Play Services +- Flytt signeringskonfigurasjon ut av kjeldekoden diff --git a/fastlane/metadata/android/nn-NO/full_description.txt b/fastlane/metadata/android/nn-NO/full_description.txt new file mode 100644 index 0000000..fdc1279 --- /dev/null +++ b/fastlane/metadata/android/nn-NO/full_description.txt @@ -0,0 +1,15 @@ +Finn næraste offentlege tilfluktsrom i Noreg. Bygd for nødsituasjonar — fungerer heilt utan internett etter fyrste gongs bruk. + +Funksjonar: +• Viser dei 3 næraste tilfluktsromma med avstand og kapasitet +• Kompassnavigasjon — retningspil som peikar mot valt tilfluktsrom +• Fråkopla kart — kartfliser lagrast automatisk for bruk utan nett +• Vel fritt — trykk på ein markør i kartet for å navigere dit +• Heimeskjerm-widget — viser næraste tilfluktsrom med eitt blikk +• Del tilfluktsrom — send posisjon til andre via ei kva som helst app +• Sivilforsvarsinformasjon — kva du skal gjere om alarmen går +• Fleirspråkleg — engelsk, bokmål og nynorsk + +Appen brukar opne data frå Geonorge (Kartverket) med ca. 556 offentlege tilfluktsrom i heile Noreg. + +Fungerer på de-Google-einingar: appen brukar Google Play Services for betre posisjonsdata når det er tilgjengeleg, men fell tilbake til standard Android-posisjons-API-ar på einingar utan Play Services (LineageOS, GrapheneOS, /e/OS osb.). diff --git a/fastlane/metadata/android/nn-NO/images/icon.png b/fastlane/metadata/android/nn-NO/images/icon.png new file mode 100644 index 0000000..f156be8 Binary files /dev/null and b/fastlane/metadata/android/nn-NO/images/icon.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/1_map_view.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/1_map_view.png new file mode 100644 index 0000000..39b32bd Binary files /dev/null and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/1_map_view.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/2_shelter_selected.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/2_shelter_selected.png new file mode 100644 index 0000000..c44f0cb Binary files /dev/null and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/2_shelter_selected.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/3_compass_view.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/3_compass_view.png new file mode 100644 index 0000000..ea6c599 Binary files /dev/null and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/3_compass_view.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/4_civil_defense_info.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/4_civil_defense_info.png new file mode 100644 index 0000000..a5d2682 Binary files /dev/null and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/4_civil_defense_info.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/5_about.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/5_about.png new file mode 100644 index 0000000..584592e Binary files /dev/null and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/5_about.png differ diff --git a/fastlane/metadata/android/nn-NO/short_description.txt b/fastlane/metadata/android/nn-NO/short_description.txt new file mode 100644 index 0000000..e567414 --- /dev/null +++ b/fastlane/metadata/android/nn-NO/short_description.txt @@ -0,0 +1 @@ +Finn næraste offentlege tilfluktsrom i Noreg — fungerer utan nett diff --git a/fastlane/metadata/android/nn-NO/title.txt b/fastlane/metadata/android/nn-NO/title.txt new file mode 100644 index 0000000..04599a8 --- /dev/null +++ b/fastlane/metadata/android/nn-NO/title.txt @@ -0,0 +1 @@ +Tilfluktsrom diff --git a/pwa/.gitignore b/pwa/.gitignore index b947077..35ca4c8 100644 --- a/pwa/.gitignore +++ b/pwa/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +public/data/shelters.json diff --git a/pwa/index.html b/pwa/index.html index e5e890b..7440b65 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -1,68 +1,68 @@ - + + Tilfluktsrom - - - - + + +
-
- +
+ -
-
-
+
+
+ -
+ -
+ -
-
- +
+
+
-
-
-
+ - + diff --git a/pwa/public/.well-known/assetlinks.json b/pwa/public/.well-known/assetlinks.json new file mode 100644 index 0000000..b95a5f1 --- /dev/null +++ b/pwa/public/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "no.naiv.tilfluktsrom", + "sha256_cert_fingerprints": [ + "43:05:79:6F:EA:3E:F4:50:45:D3:8A:EF:EA:58:B6:65:49:D2:D2:C3:4B:4C:61:11:EE:74:48:B0:C7:70:E4:5B" + ] + } + } +] diff --git a/pwa/public/data/shelters.json b/pwa/public/data/shelters.json deleted file mode 100644 index 239a144..0000000 --- a/pwa/public/data/shelters.json +++ /dev/null @@ -1,4450 +0,0 @@ -[ - { - "lokalId": "87853d43-4dda-4964-96e5-96dffc1cf7f3", - "romnr": 776, - "plasser": 400, - "adresse": "Trimv. 09 - Borre Idrettspark (off)", - "latitude": 59.404915, - "longitude": 10.462107 - }, - { - "lokalId": "630ff427-3bfe-4766-b67c-89258e4d7055", - "romnr": 777, - "plasser": 390, - "adresse": "Torget 6", - "latitude": 59.417183, - "longitude": 10.483879 - }, - { - "lokalId": "c46fa6c9-5963-42f3-a034-830e1e0b5678", - "romnr": 785, - "plasser": 370, - "adresse": "Vestre Braarudgt. 6b - Åsheim", - "latitude": 59.4177, - "longitude": 10.479063 - }, - { - "lokalId": "fa659eb8-49cf-4c1e-8f12-e3cf3e75d28d", - "romnr": 786, - "plasser": 215, - "adresse": "Vognmannsgt. 1 - Trygdegården", - "latitude": 59.415724, - "longitude": 10.483809 - }, - { - "lokalId": "13486699-ae0a-443f-8fda-e48aa24a5d87", - "romnr": 787, - "plasser": 800, - "adresse": "Langgt. 14", - "latitude": 59.489427, - "longitude": 10.315461 - }, - { - "lokalId": "9245e9ca-3425-462d-b913-57ddaac9cd6a", - "romnr": 788, - "plasser": 159, - "adresse": "Badeparken 7 - Bøkeskogen eldresenter", - "latitude": 59.057097, - "longitude": 10.029484 - }, - { - "lokalId": "2ed1eaf2-3cb7-47c0-bf56-e2536b036815", - "romnr": 789, - "plasser": 265, - "adresse": "Byskogv. 5 - Byskogen Bo- og aktivitetssenter", - "latitude": 59.053505, - "longitude": 10.036693 - }, - { - "lokalId": "ed81c068-a806-40fe-9720-da17282d8624", - "romnr": 791, - "plasser": 128, - "adresse": "Øvre Dr. Holms vei 44 - KUF-huset", - "latitude": 59.056225, - "longitude": 10.047809 - }, - { - "lokalId": "3c482130-3752-4480-9446-ecb164ece9ba", - "romnr": 793, - "plasser": 220, - "adresse": "Nordbyveien 27 - Hagatun grendehus", - "latitude": 59.077853, - "longitude": 10.049947 - }, - { - "lokalId": "264c5903-71df-47c1-bdb4-05a582e645b1", - "romnr": 794, - "plasser": 220, - "adresse": "Rosendalsgt. 17 - Langestrand skole", - "latitude": 59.048978, - "longitude": 10.011399 - }, - { - "lokalId": "e7a139a6-6582-4986-9da2-d8d13f17eacc", - "romnr": 795, - "plasser": 850, - "adresse": "Ahlefeldtsgt. 1 - Mesterfjellet", - "latitude": 59.049386, - "longitude": 10.041182 - }, - { - "lokalId": "fcf81be7-c342-4214-a08d-218d065da7ed", - "romnr": 796, - "plasser": 425, - "adresse": "Skallestadv. 38 - Hårkollhallen", - "latitude": 59.200725, - "longitude": 10.442214 - }, - { - "lokalId": "ca9264db-5a82-4904-9944-f6dbd1f3313e", - "romnr": 797, - "plasser": 42, - "adresse": "Tinghaugv. 14 - Nøtterøy kulturhus, nedre rom", - "latitude": 59.225079, - "longitude": 10.409499 - }, - { - "lokalId": "1c8c9782-1a18-475c-9d3a-a7880c61a7ed", - "romnr": 798, - "plasser": 225, - "adresse": "Tinghaugv. 14 - Nøtterøy kulturhus, øvre rom", - "latitude": 59.225024, - "longitude": 10.409453 - }, - { - "lokalId": "0bf67a70-c797-4b99-9516-6ef9f2edb7fa", - "romnr": 799, - "plasser": 415, - "adresse": "Harpunv. 3 - Optura A/S (off.)", - "latitude": 59.262825, - "longitude": 10.390622 - }, - { - "lokalId": "dfeff471-85d2-4f2f-a119-b9b4c7d173a7", - "romnr": 802, - "plasser": 195, - "adresse": "Sportsv. 31 - Off. Bugården u-skole", - "latitude": 59.134069, - "longitude": 10.187642 - }, - { - "lokalId": "d9be3b5e-b011-4732-b46c-db393892eae2", - "romnr": 803, - "plasser": 1170, - "adresse": "Dronningensgt. 24 - Off. Forsmannssenteret", - "latitude": 59.132868, - "longitude": 10.21984 - }, - { - "lokalId": "eb68ddcf-0464-4e34-831e-1efd59a180cc", - "romnr": 804, - "plasser": 293, - "adresse": "Lyhmannsv. 2 - Off. Framnes Eldresenter", - "latitude": 59.118233, - "longitude": 10.234024 - }, - { - "lokalId": "34674a45-67ed-4ccf-951e-ba69a63e6207", - "romnr": 805, - "plasser": 1233, - "adresse": "Maurveien 14 - Off. Gjekstad (garasjeanlegg)", - "latitude": 59.131677, - "longitude": 10.264337 - }, - { - "lokalId": "5ce1823a-3eec-40b7-955f-b09677a2069e", - "romnr": 806, - "plasser": 400, - "adresse": "Gokstadv. 5 - Off.", - "latitude": 59.134373, - "longitude": 10.229432 - }, - { - "lokalId": "096655bc-6260-46ef-ae0f-b85747c75635", - "romnr": 807, - "plasser": 600, - "adresse": "Høstsgt. 3 - Off.", - "latitude": 59.129605, - "longitude": 10.214538 - }, - { - "lokalId": "712a4c9c-fd0f-4779-ab52-177d2cc9e47b", - "romnr": 808, - "plasser": 200, - "adresse": "Hystadv. 167 - Off. Jotun A/S", - "latitude": 59.111364, - "longitude": 10.222859 - }, - { - "lokalId": "9139f600-e7cf-46f0-877f-3df577e4091d", - "romnr": 809, - "plasser": 300, - "adresse": "Rødslia 28 - I krysset ved Fagerstadveien", - "latitude": 59.115648, - "longitude": 10.240034 - }, - { - "lokalId": "1071e773-5a36-477d-87d9-a00ce5d35baf", - "romnr": 810, - "plasser": 400, - "adresse": "Sandefjordsv. 3 - Off. Sandefjord Rådhus", - "latitude": 59.128743, - "longitude": 10.219716 - }, - { - "lokalId": "ea2c071c-139a-42fd-a241-2fa45a02f601", - "romnr": 813, - "plasser": 1500, - "adresse": "Haugar", - "latitude": 59.268637, - "longitude": 10.410234 - }, - { - "lokalId": "3db39a05-3227-47e4-86fe-f3726f0eae21", - "romnr": 814, - "plasser": 150, - "adresse": "Larvev. 31 - Hogsnes Grendehus, rom 1 (off)", - "latitude": 59.26682, - "longitude": 10.356823 - }, - { - "lokalId": "94a80dc3-3c75-4883-a2d4-f22036e62233", - "romnr": 815, - "plasser": 150, - "adresse": "Larvev. 31 - Hogsnes Grendehus, rom 2 (off)", - "latitude": 59.26682, - "longitude": 10.356823 - }, - { - "lokalId": "99ccff09-c015-47a0-ba6d-865b17824def", - "romnr": 816, - "plasser": 962, - "adresse": "Slottsfjelltunnelen (off)", - "latitude": 59.270745, - "longitude": 10.401937 - }, - { - "lokalId": "b16aa85d-9318-460d-bb86-432f66ed7a71", - "romnr": 817, - "plasser": 215, - "adresse": "Trimveien 14 - ved Slagenhallen", - "latitude": 59.269527, - "longitude": 10.462225 - }, - { - "lokalId": "7e320f2d-4de5-438a-b451-0ab115ba5cb1", - "romnr": 831, - "plasser": 800, - "adresse": "Dronningensgate 52", - "latitude": 68.437973, - "longitude": 17.429275 - }, - { - "lokalId": "2e1fd4f8-f4f5-4208-840b-7811da72b777", - "romnr": 917, - "plasser": 215, - "adresse": "Torget 6", - "latitude": 58.451713, - "longitude": 6.001131 - }, - { - "lokalId": "f5dc9a2e-a4d7-4005-afdd-fcce77fe0ac3", - "romnr": 933, - "plasser": 240, - "adresse": "Jernbaneveien 22", - "latitude": 58.455565, - "longitude": 6.002466 - }, - { - "lokalId": "071349d7-e899-4718-85af-6dfbd4299bc7", - "romnr": 934, - "plasser": 88, - "adresse": "Sokndalsveien 26", - "latitude": 58.447808, - "longitude": 6.006581 - }, - { - "lokalId": "a839751e-51bf-4697-8ec9-aa659eaf64b0", - "romnr": 1220, - "plasser": 270, - "adresse": "Røyrvika 10", - "latitude": 58.736629, - "longitude": 5.655472 - }, - { - "lokalId": "b3150f2e-1589-4f1c-a0b6-1643ea849f68", - "romnr": 1255, - "plasser": 60, - "adresse": "Bedriftsvegen 6", - "latitude": 58.785422, - "longitude": 5.697514 - }, - { - "lokalId": "b692f1ac-e55c-436e-8a01-bdef8b810a8f", - "romnr": 1286, - "plasser": 180, - "adresse": "Kleppevarden 2", - "latitude": 58.774921, - "longitude": 5.627087 - }, - { - "lokalId": "c3df37b1-1adc-4fcd-90ba-8f194c0232af", - "romnr": 1423, - "plasser": 30, - "adresse": "Horghallvegen 25, HORGHALLEN", - "latitude": 63.15113, - "longitude": 10.283676 - }, - { - "lokalId": "baffc78f-6c35-4d46-be4c-eec0049ba024", - "romnr": 1434, - "plasser": 50, - "adresse": "Tømmesdalsvegen 70, HOVIN SKOLE/SAMF.HUS", - "latitude": 63.106671, - "longitude": 10.226629 - }, - { - "lokalId": "443aac13-ea31-41fd-a3f6-7a553f256862", - "romnr": 1435, - "plasser": 60, - "adresse": "Per Bortens veg 21, MELHUS SKYSSTASJON", - "latitude": 63.28477, - "longitude": 10.276954 - }, - { - "lokalId": "2da7587c-44f0-4c20-9914-6484370d6d2f", - "romnr": 1515, - "plasser": 200, - "adresse": "STATENS HUS ORKANGER", - "latitude": 63.297739, - "longitude": 9.847558 - }, - { - "lokalId": "9763b9a2-e781-46b6-925f-82207c6cc206", - "romnr": 1516, - "plasser": 33, - "adresse": "TORGSENTERET, ORKANGER", - "latitude": 63.305067, - "longitude": 9.849685 - }, - { - "lokalId": "c5259e37-3e49-413a-8f34-c6aca0468ca3", - "romnr": 1517, - "plasser": 40, - "adresse": "Orkdalsveien 82", - "latitude": 63.301909, - "longitude": 9.848431 - }, - { - "lokalId": "7b56309c-8595-4f67-9b1c-b72c4ef69293", - "romnr": 1563, - "plasser": 16, - "adresse": "Sollihagaen 17, RØROS KJØLESERVICE", - "latitude": 62.571786, - "longitude": 11.369025 - }, - { - "lokalId": "f2362ae1-bb9a-4018-b1ce-e94651dd91f2", - "romnr": 1568, - "plasser": 20, - "adresse": "Lorentz Lossius gata 45, RØROSMUSEET", - "latitude": 62.577088, - "longitude": 11.391251 - }, - { - "lokalId": "a9a1b57f-b1bc-4016-9b50-c113ea290893", - "romnr": 1628, - "plasser": 170, - "adresse": "Teletunet, Opphaug, Offentlig", - "latitude": 63.725351, - "longitude": 9.687023 - }, - { - "lokalId": "7fcaa9f0-fc08-46a7-b6fb-1a2d83500d7d", - "romnr": 1681, - "plasser": 315, - "adresse": "Kaigaten 8 OFF T-ROM hotellet", - "latitude": 70.37253, - "longitude": 31.103902 - }, - { - "lokalId": "bd92c299-14bc-4e46-bf49-8144058702f8", - "romnr": 1688, - "plasser": 136, - "adresse": "BANEHEIVEIEN", - "latitude": 58.149152, - "longitude": 7.982371 - }, - { - "lokalId": "4fae9780-f765-4fc0-99a0-1e38c11f465e", - "romnr": 1707, - "plasser": 195, - "adresse": "BYDALSVEIEN 19", - "latitude": 58.148045, - "longitude": 7.922027 - }, - { - "lokalId": "271b4f52-2053-4bbd-bf72-dfdf99435a7f", - "romnr": 1740, - "plasser": 65, - "adresse": "FREDRIK FRANSSONSVEI 4", - "latitude": 58.182995, - "longitude": 8.083974 - }, - { - "lokalId": "b22c8975-2250-436a-ac57-6ca4e70a5188", - "romnr": 1755, - "plasser": 235, - "adresse": "GROSTØLVEIEN 4 E", - "latitude": 58.200181, - "longitude": 8.03204 - }, - { - "lokalId": "8cdb7ae6-39a7-478d-ac53-5ba35e7a8980", - "romnr": 1756, - "plasser": 500, - "adresse": "GRÅGÅSVEIEN 43", - "latitude": 58.121466, - "longitude": 7.936556 - }, - { - "lokalId": "52c634f3-32e0-4d27-895e-3f9e8a51f03e", - "romnr": 1776, - "plasser": 170, - "adresse": "RAVNEDALSVEIEN 34", - "latitude": 58.155102, - "longitude": 7.972959 - }, - { - "lokalId": "d390adf0-b68f-4ed1-bc44-2146cbe203ab", - "romnr": 1795, - "plasser": 50, - "adresse": "HÅNESVEIEN 61", - "latitude": 58.181109, - "longitude": 8.08888 - }, - { - "lokalId": "741fe60c-0401-47d1-8559-6503245da9aa", - "romnr": 1799, - "plasser": 390, - "adresse": "INDUSTRIGATA 6", - "latitude": 58.151396, - "longitude": 8.025719 - }, - { - "lokalId": "2896f65b-8106-4a2c-9c0f-ccc7b4d65c72", - "romnr": 1818, - "plasser": 170, - "adresse": "JORDBÆRVEIEN 2", - "latitude": 58.175809, - "longitude": 8.09532 - }, - { - "lokalId": "eb74ad26-64b8-4be3-8805-5605c759c9fd", - "romnr": 1822, - "plasser": 184, - "adresse": "KARUSSVEIEN 7", - "latitude": 58.129267, - "longitude": 7.946368 - }, - { - "lokalId": "6e8126e5-d179-4d8c-b8f6-d9cff8cca8c8", - "romnr": 1832, - "plasser": 465, - "adresse": "KIRSTEN FLAGSTADSVEI 30", - "latitude": 58.12539, - "longitude": 7.953968 - }, - { - "lokalId": "00631cd5-e0bc-4b72-a827-a388dea3f008", - "romnr": 1857, - "plasser": 243, - "adresse": "KONGENSGATE 2", - "latitude": 58.142947, - "longitude": 7.99551 - }, - { - "lokalId": "99c2e1ad-8b17-46d7-87dd-037117fc4f75", - "romnr": 1874, - "plasser": 191, - "adresse": "KRISTIAN 4. DES GATE 35-37", - "latitude": 58.148304, - "longitude": 7.991811 - }, - { - "lokalId": "a7cd6fa4-50d7-4e46-b8fc-0599b7c0684f", - "romnr": 1890, - "plasser": 120, - "adresse": "LINDEBØSKOGEN", - "latitude": 58.08907, - "longitude": 7.999181 - }, - { - "lokalId": "322932aa-f80d-4e72-a83a-ce259a89a397", - "romnr": 1895, - "plasser": 95, - "adresse": "MAGNUS BARFOTSVEI 7", - "latitude": 58.177356, - "longitude": 8.043248 - }, - { - "lokalId": "bf826545-0846-44f3-84fc-814eec51c5f0", - "romnr": 1897, - "plasser": 360, - "adresse": "MALMVEIEN 4", - "latitude": 58.14858, - "longitude": 7.955773 - }, - { - "lokalId": "532a6aae-032a-4dd3-9540-ed3eaf7b2d44", - "romnr": 1908, - "plasser": 330, - "adresse": "MARKENSGATE 42", - "latitude": 58.147248, - "longitude": 7.990485 - }, - { - "lokalId": "5ff2b3ed-e6e0-44ab-83ab-13008e7c08e2", - "romnr": 1919, - "plasser": 200, - "adresse": "MÆBØ", - "latitude": 58.07591, - "longitude": 8.00126 - }, - { - "lokalId": "492f0ddc-fdff-4b0f-aff2-42a975d8fdf7", - "romnr": 1922, - "plasser": 300, - "adresse": "MØLLEVANNSVEIEN 50", - "latitude": 58.148601, - "longitude": 7.970139 - }, - { - "lokalId": "8c063d0a-8a57-484d-b3c5-6285fa0efc8c", - "romnr": 1931, - "plasser": 220, - "adresse": "ODDERHEI 1", - "latitude": 58.13434, - "longitude": 8.076467 - }, - { - "lokalId": "67da452e-4a66-40b8-a13e-d9244d63197e", - "romnr": 1938, - "plasser": 230, - "adresse": "PRESTHEIA 10", - "latitude": 58.166579, - "longitude": 8.010932 - }, - { - "lokalId": "d6095314-c5eb-4066-b20e-aa617f6ad2e8", - "romnr": 1951, - "plasser": 170, - "adresse": "RØDSTRUPEVEIEN 17", - "latitude": 58.133828, - "longitude": 7.960984 - }, - { - "lokalId": "da484bb2-229e-422c-8343-8752f72768b5", - "romnr": 1971, - "plasser": 170, - "adresse": "SKIBÅSEN 2", - "latitude": 58.173421, - "longitude": 8.126058 - }, - { - "lokalId": "9338937c-5210-4195-aea9-2789f6f91241", - "romnr": 1998, - "plasser": 70, - "adresse": "SLETTHEIVEIEN 63 B", - "latitude": 58.13564, - "longitude": 7.935866 - }, - { - "lokalId": "c03754b0-168e-45ae-af1d-b5466c8b811b", - "romnr": 2014, - "plasser": 234, - "adresse": "STRØMMEVEIEN 85-87", - "latitude": 58.15468, - "longitude": 8.081448 - }, - { - "lokalId": "7db195b7-25cb-4f84-a0a5-c3e54a44327b", - "romnr": 2057, - "plasser": 130, - "adresse": "VARDÅSVEIEN 115/117", - "latitude": 58.145409, - "longitude": 8.059191 - }, - { - "lokalId": "f0e4671e-497b-47ec-b237-afdae17d1e95", - "romnr": 2082, - "plasser": 150, - "adresse": "VIGVOLLÅSEN 16", - "latitude": 58.171563, - "longitude": 8.088489 - }, - { - "lokalId": "20dfa406-488e-4efa-98cb-ca51e83088de", - "romnr": 2083, - "plasser": 150, - "adresse": "VOIE RINGVEI 108", - "latitude": 58.101744, - "longitude": 7.947829 - }, - { - "lokalId": "6eb44464-e192-4a2c-8647-cdb83df46912", - "romnr": 2091, - "plasser": 120, - "adresse": "ØSTERVEIEN 30 B", - "latitude": 58.152844, - "longitude": 8.009103 - }, - { - "lokalId": "d1a161a1-7d18-466b-ae1d-b1c3b9ab2b19", - "romnr": 2094, - "plasser": 220, - "adresse": "ØSTERØYA 8", - "latitude": 58.077045, - "longitude": 8.001411 - }, - { - "lokalId": "5f373b9c-5b99-4a5a-8005-bb32b11f1e60", - "romnr": 2098, - "plasser": 190, - "adresse": "ØSTRE RINGVEI 8", - "latitude": 58.148571, - "longitude": 8.021041 - }, - { - "lokalId": "7e9704ce-7521-4856-b88b-b8102309fad0", - "romnr": 2104, - "plasser": 100, - "adresse": "ØSTRE STRANDGATE 67", - "latitude": 58.14818, - "longitude": 8.006068 - }, - { - "lokalId": "2e3a4c51-7250-47c9-a05f-37e41c33f61e", - "romnr": 2108, - "plasser": 360, - "adresse": "ØVRE BRATTBAKKEN 1", - "latitude": 58.178745, - "longitude": 8.091319 - }, - { - "lokalId": "b4d4e635-6e81-497c-b57c-15ec289d7977", - "romnr": 2127, - "plasser": 235, - "adresse": "TANGVALL", - "latitude": 58.096339, - "longitude": 7.814256 - }, - { - "lokalId": "84870d27-4956-425a-8579-50af0887c129", - "romnr": 2128, - "plasser": 133, - "adresse": "TANGVALL", - "latitude": 58.096339, - "longitude": 7.814256 - }, - { - "lokalId": "2f61f1be-c1f8-49df-b310-adccb913b228", - "romnr": 2180, - "plasser": 235, - "adresse": "SKOLEVEGEN 9", - "latitude": 58.275797, - "longitude": 7.9773 - }, - { - "lokalId": "4566c24b-3815-4246-a4a4-53111e239b6b", - "romnr": 2183, - "plasser": 75, - "adresse": "Drivenesvegen 24", - "latitude": 58.257678, - "longitude": 7.964487 - }, - { - "lokalId": "6e916337-a9da-4549-ab0f-c2b948f24416", - "romnr": 2197, - "plasser": 100, - "adresse": "BURÅSVEIEN 59", - "latitude": 58.031973, - "longitude": 7.434499 - }, - { - "lokalId": "954002a0-01e0-4497-a6a5-e343bd3a3108", - "romnr": 2205, - "plasser": 150, - "adresse": "HOLBÆK ERIKSENSGATE 10", - "latitude": 58.029529, - "longitude": 7.460786 - }, - { - "lokalId": "7cc0bc51-b655-49a8-9766-f14526f994c1", - "romnr": 2224, - "plasser": 60, - "adresse": "SOMMERKROVEIEN 18", - "latitude": 58.025063, - "longitude": 7.472404 - }, - { - "lokalId": "950e1cff-2b5b-43a0-8c34-3cd98463e881", - "romnr": 2254, - "plasser": 400, - "adresse": "SELJEVEIEN 60", - "latitude": 58.030949, - "longitude": 7.490129 - }, - { - "lokalId": "7665fcb4-0b50-4df2-b4d6-374ec1dd6a0b", - "romnr": 2286, - "plasser": 30, - "adresse": "STÅLESEN BYGG AS-AUTOHALLEN Vanse", - "latitude": 58.093131, - "longitude": 6.69617 - }, - { - "lokalId": "cf44933f-c1e9-4071-99fa-5cfbcf1ec5c0", - "romnr": 2293, - "plasser": 100, - "adresse": "SVEGESKOGEN 17", - "latitude": 58.283261, - "longitude": 6.643839 - }, - { - "lokalId": "a9169e28-4828-44e0-b409-1b4ba4e990a8", - "romnr": 2306, - "plasser": 220, - "adresse": "Urgata 2", - "latitude": 58.296542, - "longitude": 6.665628 - }, - { - "lokalId": "415201ca-7e6a-4f32-8b30-be2f5987547d", - "romnr": 2310, - "plasser": 190, - "adresse": "RAULIVEIEN 2", - "latitude": 58.295212, - "longitude": 6.669011 - }, - { - "lokalId": "1c859126-a2df-4603-83da-be76ba1e160d", - "romnr": 2344, - "plasser": 35, - "adresse": "ROSFJORD", - "latitude": 58.12144, - "longitude": 7.058307 - }, - { - "lokalId": "2f023362-b3c7-4654-9e33-fdff01e03fa6", - "romnr": 2468, - "plasser": 160, - "adresse": "Myraveien 2 (Brannstasjonen)", - "latitude": 66.312793, - "longitude": 14.148941 - }, - { - "lokalId": "49a011ae-4543-4c6a-8f35-bc40a8b5a0e5", - "romnr": 2483, - "plasser": 230, - "adresse": "Kveldssolgata 99 (Hauknes skole - offentlig)", - "latitude": 66.285491, - "longitude": 14.054265 - }, - { - "lokalId": "19e029c6-95a8-4242-acab-a00c37d84356", - "romnr": 2502, - "plasser": 415, - "adresse": "Alstadveien 19, Alstad Ungdomsskole", - "latitude": 67.275052, - "longitude": 14.486811 - }, - { - "lokalId": "fdabca67-bd03-4934-a950-0ad808126aba", - "romnr": 2506, - "plasser": 160, - "adresse": "Salten gummiservice A/S, Tømrerveien 8", - "latitude": 67.275244, - "longitude": 14.400001 - }, - { - "lokalId": "6a75dbe5-12d6-4758-82be-bbdd6a482434", - "romnr": 2519, - "plasser": 600, - "adresse": "Gråholten", - "latitude": 67.274848, - "longitude": 14.378414 - }, - { - "lokalId": "bed1bd5e-2f13-474a-a4c1-f45a47263210", - "romnr": 2521, - "plasser": 340, - "adresse": "Torvet, Sjøgaten, Bodø", - "latitude": 67.284683, - "longitude": 14.381687 - }, - { - "lokalId": "5b255d31-093a-4ff2-b686-5a9085ae1341", - "romnr": 2522, - "plasser": 520, - "adresse": "Holstbakken Alta", - "latitude": 69.973665, - "longitude": 23.301067 - }, - { - "lokalId": "66084972-7d02-4076-95c3-9c2384a3ce63", - "romnr": 2524, - "plasser": 1154, - "adresse": "Rensås Syd, Bodø", - "latitude": 67.283731, - "longitude": 14.40508 - }, - { - "lokalId": "a530f56d-cbce-493c-ada9-53294b01b9ac", - "romnr": 2525, - "plasser": 300, - "adresse": "Dreyfushammaren, Burøyveien, Bodø", - "latitude": 67.297966, - "longitude": 14.395184 - }, - { - "lokalId": "bfca52a9-e485-4a79-81d5-35b603cfd896", - "romnr": 2527, - "plasser": 500, - "adresse": "Rensås Nord, Parkveien, Bodø", - "latitude": 67.283004, - "longitude": 14.400086 - }, - { - "lokalId": "34fdefbc-3ae6-4b1d-8c9c-46e40cdecd5b", - "romnr": 2571, - "plasser": 450, - "adresse": "Gymnaset, Gymnasveien 5, Fauske", - "latitude": 67.262641, - "longitude": 15.375249 - }, - { - "lokalId": "869bc6c8-7c6a-4559-bfce-6963f3ce8a2f", - "romnr": 2625, - "plasser": 410, - "adresse": "Strandgata 20", - "latitude": 66.314566, - "longitude": 14.132171 - }, - { - "lokalId": "95c6d533-2997-4897-b08c-77817319bae6", - "romnr": 2645, - "plasser": 320, - "adresse": "Selfors Barneskole", - "latitude": 66.326036, - "longitude": 14.180946 - }, - { - "lokalId": "1e166204-285b-4e51-b0a1-342925bc8851", - "romnr": 2662, - "plasser": 240, - "adresse": "Ytteren Somatiske sykehjem - off.", - "latitude": 66.340782, - "longitude": 14.138547 - }, - { - "lokalId": "9c663030-0d50-4b2b-adac-68f4efdd4ca0", - "romnr": 3247, - "plasser": 600, - "adresse": "Flintergata 6", - "latitude": 58.852, - "longitude": 5.733833 - }, - { - "lokalId": "8860fab7-f876-43c3-bd76-072d886d0fb1", - "romnr": 3249, - "plasser": 420, - "adresse": "Roald Amundsens gate 28A", - "latitude": 58.861441, - "longitude": 5.743306 - }, - { - "lokalId": "62cc4598-9caf-42dd-b0ee-d2c5b4662063", - "romnr": 3255, - "plasser": 155, - "adresse": "Nikkelveien 13", - "latitude": 58.869807, - "longitude": 5.731038 - }, - { - "lokalId": "ae844a34-00e7-463c-af2b-232a37ab3024", - "romnr": 3289, - "plasser": 20, - "adresse": "Sjøveien 4", - "latitude": 58.884679, - "longitude": 5.745893 - }, - { - "lokalId": "11dad206-5544-461f-8e97-591c92d56e04", - "romnr": 3325, - "plasser": 80, - "adresse": "Iglemyrveien 3", - "latitude": 58.842988, - "longitude": 5.756187 - }, - { - "lokalId": "c2bd8ed7-1656-4e91-964c-4452714d2a8b", - "romnr": 3553, - "plasser": 138, - "adresse": "Moafjæra 1d", - "latitude": 63.739158, - "longitude": 11.28213 - }, - { - "lokalId": "9782250a-c7d5-4742-b770-883908b08f30", - "romnr": 3680, - "plasser": 110, - "adresse": "Wergelandsveien 27", - "latitude": 63.475072, - "longitude": 10.914986 - }, - { - "lokalId": "1918f122-3286-49b4-a812-c44ec3142ff9", - "romnr": 4005, - "plasser": 98, - "adresse": "Haugen 25", - "latitude": 58.825497, - "longitude": 5.726609 - }, - { - "lokalId": "293c692f-46d9-4499-a620-2dc535fb16c3", - "romnr": 4008, - "plasser": 225, - "adresse": "Somaveien 4", - "latitude": 58.872972, - "longitude": 5.725455 - }, - { - "lokalId": "a8c44d20-fc0d-4207-ac12-b072681c0c37", - "romnr": 4013, - "plasser": 570, - "adresse": "Olabakken 2", - "latitude": 58.825574, - "longitude": 5.713467 - }, - { - "lokalId": "68d1e0ad-6982-4165-b28a-e0b41e2e9839", - "romnr": 4026, - "plasser": 241, - "adresse": "Luramyrveien 61", - "latitude": 58.883893, - "longitude": 5.726643 - }, - { - "lokalId": "7870d7f3-4dd5-44cd-8c51-2d4430f72772", - "romnr": 4193, - "plasser": 230, - "adresse": "Samuel J. Sandveds vei 16", - "latitude": 58.841264, - "longitude": 5.72609 - }, - { - "lokalId": "f47bc94b-74be-48fa-bdfa-4a275ba195c2", - "romnr": 4194, - "plasser": 200, - "adresse": "Smørbukkveien 11", - "latitude": 58.884865, - "longitude": 5.734584 - }, - { - "lokalId": "bc723bec-c1b1-4a3d-accb-b4faedcf87af", - "romnr": 4241, - "plasser": 150, - "adresse": "Damsgårdsgaten 4A", - "latitude": 58.453424, - "longitude": 6.006291 - }, - { - "lokalId": "23c43b1b-f74a-4252-8c5a-3c90ebf7aa4b", - "romnr": 4291, - "plasser": 235, - "adresse": "Sam Eydesgt. 163, IINGOLFLAND SKOLE", - "latitude": 59.879548, - "longitude": 8.614609 - }, - { - "lokalId": "4b805b16-49fd-4688-8965-290cb14c5462", - "romnr": 4303, - "plasser": 200, - "adresse": "Kaiveien 23", - "latitude": 58.929458, - "longitude": 5.85136 - }, - { - "lokalId": "b68f5c59-30ba-4ea1-8263-64749ad93e58", - "romnr": 4305, - "plasser": 200, - "adresse": "Kaiveien 25", - "latitude": 58.93021, - "longitude": 5.851264 - }, - { - "lokalId": "020ca2ab-af00-43e3-8cc2-d7b9340ef717", - "romnr": 4695, - "plasser": 100, - "adresse": "Skansen kjøpesenter, T. Kveldulvsons gate 24", - "latitude": 66.022369, - "longitude": 12.635415 - }, - { - "lokalId": "f98f0b93-efa2-48b3-8ff2-0ca06d6f8b8b", - "romnr": 4703, - "plasser": 120, - "adresse": "Skole/Samfunnshus, Skolegata 1", - "latitude": 65.468137, - "longitude": 12.211094 - }, - { - "lokalId": "b703762b-dbd7-4eba-9555-65dd8ae97d0c", - "romnr": 4741, - "plasser": 736, - "adresse": "Mysenhallen/ Smedgata 19", - "latitude": 59.553801, - "longitude": 11.333741 - }, - { - "lokalId": "d7e76a10-635a-472f-89d6-d115536d9122", - "romnr": 4855, - "plasser": 300, - "adresse": "Karsches gate 3 \"Tilflukt\"", - "latitude": 59.668901, - "longitude": 9.651787 - }, - { - "lokalId": "58dc9f73-9d2e-463f-bf2f-b2cbf87a4ff8", - "romnr": 4908, - "plasser": 172, - "adresse": "Soknedalsveien 7", - "latitude": 60.169954, - "longitude": 10.243451 - }, - { - "lokalId": "ae3be434-c0db-4985-8440-eecb9ac6fa8e", - "romnr": 4963, - "plasser": 537, - "adresse": "Betzy Kjelsbergs vei 182", - "latitude": 59.757079, - "longitude": 10.131048 - }, - { - "lokalId": "abd7d06c-3cee-403a-bb4a-a051c14b109d", - "romnr": 4969, - "plasser": 1200, - "adresse": "Prins Oscarsgate / Brantenborg.", - "latitude": 59.74564, - "longitude": 10.221617 - }, - { - "lokalId": "41edbd5e-744d-4fd8-9ff8-604014e36945", - "romnr": 5463, - "plasser": 600, - "adresse": "Nedre Tverrgate 1 - Nedre Eiker Samfunnshus", - "latitude": 59.748786, - "longitude": 10.012376 - }, - { - "lokalId": "aa0c74f3-6a5b-4801-b4ab-154118afa078", - "romnr": 5617, - "plasser": 385, - "adresse": "Stasjonsgata 24", - "latitude": 59.770967, - "longitude": 9.908552 - }, - { - "lokalId": "185a05cb-c4f7-403d-bfad-eab4fc8e840e", - "romnr": 5630, - "plasser": 320, - "adresse": "Hellefoss veien 2", - "latitude": 59.773209, - "longitude": 9.908353 - }, - { - "lokalId": "77d3f950-9162-4456-8551-ff64806be211", - "romnr": 5647, - "plasser": 220, - "adresse": "Skotselvveien 29", - "latitude": 59.770037, - "longitude": 9.898972 - }, - { - "lokalId": "5a4154aa-678c-4e79-b1c3-9651aaf1d70a", - "romnr": 5652, - "plasser": 14, - "adresse": "Havnegt.16", - "latitude": 64.466916, - "longitude": 11.494325 - }, - { - "lokalId": "3f2b574d-d0f8-4fc5-8659-4570c33c0edb", - "romnr": 5653, - "plasser": 25, - "adresse": "Abel Meyers gate 3", - "latitude": 64.464827, - "longitude": 11.494884 - }, - { - "lokalId": "191f5c70-7f01-4457-ac9f-eef16da90c4f", - "romnr": 5655, - "plasser": 60, - "adresse": "Overh.vn.8", - "latitude": 64.469204, - "longitude": 11.50363 - }, - { - "lokalId": "ae27f566-5ac9-4763-982f-4e2baac9662d", - "romnr": 5656, - "plasser": 70, - "adresse": "Kirkegt.5", - "latitude": 64.466466, - "longitude": 11.49507 - }, - { - "lokalId": "240ca620-f28a-44e8-94aa-ac368ba1f469", - "romnr": 5657, - "plasser": 78, - "adresse": "Strandvn.7", - "latitude": 64.468497, - "longitude": 11.479279 - }, - { - "lokalId": "fb140d42-3b57-4a12-a1f9-f5d64e99cc9f", - "romnr": 5658, - "plasser": 15, - "adresse": "Abel Meyers gate 21", - "latitude": 64.467607, - "longitude": 11.497318 - }, - { - "lokalId": "1d3d72ef-7cd1-49c8-9e84-5f381828ebca", - "romnr": 5732, - "plasser": 2000, - "adresse": "Jarl Hildrums veg 6, Kleppen", - "latitude": 64.474148, - "longitude": 11.521925 - }, - { - "lokalId": "9b602ba9-0351-45e6-85c9-b6901c7bf2b3", - "romnr": 5815, - "plasser": 400, - "adresse": "Sundelinveien 72 (Sandnes Skole)", - "latitude": 69.668159, - "longitude": 29.952576 - }, - { - "lokalId": "30d5dd6f-4e44-4258-9d14-dd6268f965a0", - "romnr": 5832, - "plasser": 340, - "adresse": "Rådhuset, Sivert Nilsensgate 24", - "latitude": 65.467761, - "longitude": 12.205777 - }, - { - "lokalId": "4216edda-adcf-4e28-9cad-cb360eeb18cf", - "romnr": 5962, - "plasser": 230, - "adresse": "Gåsungen Barnehage - Gåsåsen 20", - "latitude": 58.459572, - "longitude": 8.747527 - }, - { - "lokalId": "c665543f-ff90-458b-a4cf-1f9d7d57c108", - "romnr": 6056, - "plasser": 35, - "adresse": "Birkenes kommunehus - Smedens Kjerr 30", - "latitude": 58.335013, - "longitude": 8.23294 - }, - { - "lokalId": "dc509365-2c71-47e7-9f21-c22dc2cda05c", - "romnr": 6074, - "plasser": 20, - "adresse": "Herefoss Skole - Torsbumoen 14", - "latitude": 58.519123, - "longitude": 8.353128 - }, - { - "lokalId": "d1baaff4-6b80-43ff-b55c-5c8fcc7e55b4", - "romnr": 6157, - "plasser": 240, - "adresse": "Risør Idrettshall - Caspersens vei 25", - "latitude": 58.714551, - "longitude": 9.215198 - }, - { - "lokalId": "af2660f5-e13b-4dcd-8acf-93b9e5b2b076", - "romnr": 6160, - "plasser": 470, - "adresse": "Risør videregående skole - Sirisveien 8 Blokk A", - "latitude": 58.720064, - "longitude": 9.216877 - }, - { - "lokalId": "b55d268f-6bed-478a-8500-c70d7513e4e7", - "romnr": 6206, - "plasser": 90, - "adresse": "Bjorbekktunet - Off - Bjorbekk - Johan Landmarksve", - "latitude": 58.440072, - "longitude": 8.706223 - }, - { - "lokalId": "9454d973-8531-420d-a170-d57cb7389823", - "romnr": 6207, - "plasser": 26, - "adresse": "Meny - Off - Stoa - Åsbieveien 2", - "latitude": 58.458411, - "longitude": 8.712128 - }, - { - "lokalId": "ea723bda-d386-44ea-ad71-207950af90d8", - "romnr": 6222, - "plasser": 90, - "adresse": "Idrettens Hus - Off - Kåre Eriksen A/S - Åsbievn 1", - "latitude": 58.458295, - "longitude": 8.720036 - }, - { - "lokalId": "5516bb9a-4d84-4ea1-b28a-483cf4be508f", - "romnr": 6342, - "plasser": 264, - "adresse": "Høvågheimen Bo og aktivitetsenter - Høvåg", - "latitude": 58.170947, - "longitude": 8.245037 - }, - { - "lokalId": "1c168707-1644-48ab-9864-4cca2dca14bf", - "romnr": 6356, - "plasser": 28, - "adresse": "Lillesand rådhus - Østregate 2", - "latitude": 58.249877, - "longitude": 8.378622 - }, - { - "lokalId": "93e3950b-4523-4147-8646-420a5388dce1", - "romnr": 6394, - "plasser": 1300, - "adresse": "Kulturhuset - Hestetorget,Storgaten 33", - "latitude": 58.342862, - "longitude": 8.590395 - }, - { - "lokalId": "f1f92f26-0bf8-4a9c-ba6a-96d311fb4a14", - "romnr": 6442, - "plasser": 228, - "adresse": "Eide Skole - Johan Markussens vei 90", - "latitude": 58.2689, - "longitude": 8.504972 - }, - { - "lokalId": "3e9b2e4e-307b-4793-8b73-de3d3c53865c", - "romnr": 6547, - "plasser": 50, - "adresse": "Oddensenteret (kjeller) - Vesterled 4", - "latitude": 58.337942, - "longitude": 8.592676 - }, - { - "lokalId": "0040b298-85a8-4ac3-9231-26d042ff50e2", - "romnr": 6720, - "plasser": 400, - "adresse": "Froland Idrettshall - Mjølhusmoen - Rislandsvn 10", - "latitude": 58.515114, - "longitude": 8.632786 - }, - { - "lokalId": "43ca8211-7024-40cf-b21c-d41cab8117bd", - "romnr": 6749, - "plasser": 106, - "adresse": "Dønnestadgården - Bibliotekveien 4", - "latitude": 58.626552, - "longitude": 8.928731 - }, - { - "lokalId": "6f8919e3-6a6b-40ce-a5ff-e6c3d45f6bb7", - "romnr": 6815, - "plasser": 2400, - "adresse": "Arendalstunnelen - Parkeringstunnell -Torvet/Barb", - "latitude": 58.4619, - "longitude": 8.766067 - }, - { - "lokalId": "0a5cbf36-68c3-459b-a68e-7adc42875cff", - "romnr": 6819, - "plasser": 1800, - "adresse": "Birkenlundhallen - Kirkefjell 11", - "latitude": 58.471025, - "longitude": 8.791073 - }, - { - "lokalId": "63687f3c-be87-4a69-8bfc-c65f4a18670b", - "romnr": 6870, - "plasser": 630, - "adresse": "Westermannsveita 4 Tidl. Nordre gate 1", - "latitude": 63.431029, - "longitude": 10.39903 - }, - { - "lokalId": "5a75f46d-a078-4666-b333-b33b0140b6e7", - "romnr": 6963, - "plasser": 265, - "adresse": "Dr.Randersgate 5, Askim", - "latitude": 59.583244, - "longitude": 11.160927 - }, - { - "lokalId": "15d72df6-7510-4d30-96ae-4bed5f112003", - "romnr": 6964, - "plasser": 260, - "adresse": "Dr. Randersgate 2, Askim", - "latitude": 59.582696, - "longitude": 11.161255 - }, - { - "lokalId": "799edf55-2bfc-4855-b34f-b91efafac1b6", - "romnr": 6966, - "plasser": 260, - "adresse": "Rom skole/ Gramveien 63A, Askim", - "latitude": 59.590274, - "longitude": 11.128584 - }, - { - "lokalId": "6626ae01-6a88-46bd-8468-690d9ee6d11b", - "romnr": 7021, - "plasser": 90, - "adresse": "endresens vei 4, Varangerbotn", - "latitude": 70.172568, - "longitude": 28.558419 - }, - { - "lokalId": "483bb2ca-ddf9-48f9-8981-03bbd7bd099c", - "romnr": 7098, - "plasser": 1079, - "adresse": "Melløs stadion, Nordahl Griegsgt 22", - "latitude": 59.420957, - "longitude": 10.670078 - }, - { - "lokalId": "226e3d7e-431a-41c1-905c-baa2e9eb2dca", - "romnr": 7103, - "plasser": 1000, - "adresse": "Blinken/ Jeløgata 3", - "latitude": 59.433847, - "longitude": 10.654783 - }, - { - "lokalId": "36730a9b-b40a-4013-8fc8-d675bb8a0b02", - "romnr": 7105, - "plasser": 400, - "adresse": "Malakoff vg. skole, Dyreveien 9", - "latitude": 59.427789, - "longitude": 10.668656 - }, - { - "lokalId": "cca467f7-d3a8-423f-88bd-5e46ebfb1830", - "romnr": 7106, - "plasser": 250, - "adresse": "Reier skole/Brageveien 9", - "latitude": 59.429391, - "longitude": 10.63015 - }, - { - "lokalId": "e049a7a3-46c0-49d8-83b3-0f2ac8b06f0e", - "romnr": 7107, - "plasser": 300, - "adresse": "Krapfoss skole /Anton Krogsveldsvei 4", - "latitude": 59.43334, - "longitude": 10.68175 - }, - { - "lokalId": "bdc85bd6-a3b6-47ef-8be4-4987bdb4c186", - "romnr": 7114, - "plasser": 450, - "adresse": "Parkeringshus Piasenteret/ Møllergata 1", - "latitude": 59.437466, - "longitude": 10.667859 - }, - { - "lokalId": "f8e069a3-b2a0-449d-b083-6d94cb7bc955", - "romnr": 7158, - "plasser": 168, - "adresse": "Wilh Bugges Veg 5", - "latitude": 61.221816, - "longitude": 6.075034 - }, - { - "lokalId": "bb7be8ae-e375-44bd-b3f8-6c2f2f37ca5b", - "romnr": 7254, - "plasser": 122, - "adresse": "Haugevegen 31", - "latitude": 59.420374, - "longitude": 5.263855 - }, - { - "lokalId": "7d4ffef2-7cec-4ffc-8b83-b5f67b5e26d6", - "romnr": 7270, - "plasser": 78, - "adresse": "Storbukt Skole Honningsvåg", - "latitude": 70.995711, - "longitude": 25.980348 - }, - { - "lokalId": "6fa14e4f-c7d5-421e-b6a4-2c992cd860c0", - "romnr": 7409, - "plasser": 448, - "adresse": "Baksalen Skole Hammerfest", - "latitude": 70.657017, - "longitude": 23.705373 - }, - { - "lokalId": "8d2f8e12-7b54-4406-b3c2-901d4d7ca1ff", - "romnr": 7411, - "plasser": 400, - "adresse": "Reindalen skole", - "latitude": 70.691759, - "longitude": 23.700527 - }, - { - "lokalId": "51b58414-4cd3-4366-a8f9-461e1de8f2cc", - "romnr": 7412, - "plasser": 228, - "adresse": "Bokollektivet Rypefjord", - "latitude": 70.637318, - "longitude": 23.677216 - }, - { - "lokalId": "f8afc4ca-cfb9-4311-8b45-57852ccf038b", - "romnr": 7413, - "plasser": 622, - "adresse": "Sivilforsvarets lager Nybakken 9 Hammerfest", - "latitude": 70.663789, - "longitude": 23.692815 - }, - { - "lokalId": "de6da9d8-07d6-4b77-9cb1-6da2a45e0e33", - "romnr": 7436, - "plasser": 211, - "adresse": "Porsanger Rådhus Lakselv", - "latitude": 70.052199, - "longitude": 24.956483 - }, - { - "lokalId": "e74a5f0f-2f8b-4573-bc00-86f76e58fc8b", - "romnr": 7438, - "plasser": 170, - "adresse": "Flerbrukshallen Lakselv", - "latitude": 70.048491, - "longitude": 24.967072 - }, - { - "lokalId": "b9c2ce15-8f3c-42f9-a82b-12a969dc7f9c", - "romnr": 7443, - "plasser": 280, - "adresse": "Helsetun Lakselv", - "latitude": 70.052063, - "longitude": 24.952458 - }, - { - "lokalId": "7af66c10-dded-4578-9fd7-1c1a995cd4af", - "romnr": 7464, - "plasser": 570, - "adresse": "Idrettsanlegg Honningsvåg", - "latitude": 70.976088, - "longitude": 25.97941 - }, - { - "lokalId": "29e7d3f9-024c-4be4-871d-b8a9c27639e8", - "romnr": 7574, - "plasser": 130, - "adresse": "Brubakken 2", - "latitude": 61.118083, - "longitude": 10.461766 - }, - { - "lokalId": "fed6eff0-1d85-4cf4-8e25-79db88c43193", - "romnr": 7856, - "plasser": 550, - "adresse": "Storgata 10", - "latitude": 60.794817, - "longitude": 10.69013 - }, - { - "lokalId": "77af968f-88ce-439e-88a9-61f105a70f48", - "romnr": 7956, - "plasser": 65, - "adresse": "Mattisrudsvingen 9", - "latitude": 60.783142, - "longitude": 10.648554 - }, - { - "lokalId": "bdf7f784-6c98-410e-b388-144da1e6b15c", - "romnr": 8044, - "plasser": 30, - "adresse": "Rosteinvegen 7", - "latitude": 60.833774, - "longitude": 10.055507 - }, - { - "lokalId": "03464253-44f2-4407-a1d5-150c0f0f49a2", - "romnr": 8050, - "plasser": 550, - "adresse": "Prøvenvegen 65", - "latitude": 60.715098, - "longitude": 10.605248 - }, - { - "lokalId": "81c435a1-b0aa-426e-aafc-504a2081072f", - "romnr": 8060, - "plasser": 50, - "adresse": "Jørstadmovegen 690", - "latitude": 61.155827, - "longitude": 10.391659 - }, - { - "lokalId": "e7d7298e-5a53-4aee-be80-d173feb1e209", - "romnr": 8061, - "plasser": 250, - "adresse": "Gudbrandsdalsvegen 190", - "latitude": 61.128144, - "longitude": 10.451822 - }, - { - "lokalId": "1ef9a75e-1d3b-432e-b5b1-ea27d531afa0", - "romnr": 8062, - "plasser": 150, - "adresse": "Nordsetervegen 45", - "latitude": 61.122977, - "longitude": 10.470935 - }, - { - "lokalId": "e35a768a-25a8-4492-908a-919cc5488673", - "romnr": 8063, - "plasser": 300, - "adresse": "Jul Pettersens gate 2", - "latitude": 61.117916, - "longitude": 10.462682 - }, - { - "lokalId": "ad26e0b2-7a30-408a-bdb8-175a2b426346", - "romnr": 8064, - "plasser": 616, - "adresse": "Kirkegata 74", - "latitude": 61.116166, - "longitude": 10.462264 - }, - { - "lokalId": "42542c87-f569-4a7c-bc6f-a1b79f59a59e", - "romnr": 8823, - "plasser": 650, - "adresse": "v/ Seimsdalstunnellen", - "latitude": 61.236154, - "longitude": 7.691842 - }, - { - "lokalId": "2978ebbb-6cdc-4bbc-9f75-2168e1692f4c", - "romnr": 8825, - "plasser": 550, - "adresse": "Storevegen 13 (Lykkja )", - "latitude": 61.311211, - "longitude": 7.80573 - }, - { - "lokalId": "948dd61c-c5f9-4da7-9ed9-8187e91d0e58", - "romnr": 9067, - "plasser": 30, - "adresse": "Petter Dassgt 15", - "latitude": 65.838383, - "longitude": 13.196375 - }, - { - "lokalId": "cb085b04-7a18-43a8-b434-49ed266d72c3", - "romnr": 9071, - "plasser": 40, - "adresse": "Strandgt. 31-33 (offentlig)", - "latitude": 65.836379, - "longitude": 13.190012 - }, - { - "lokalId": "e3d132d6-586f-4a7d-9c28-0930ed9e0615", - "romnr": 9092, - "plasser": 122, - "adresse": "Erling Skjalgssons gate 25", - "latitude": 59.420405, - "longitude": 5.265106 - }, - { - "lokalId": "5bae63ac-f45a-48bf-a5a0-0ac2653bbe65", - "romnr": 9093, - "plasser": 220, - "adresse": "Spannavegen 135", - "latitude": 59.398251, - "longitude": 5.303656 - }, - { - "lokalId": "071b67c6-d8a9-4061-b118-e2bdf578d28f", - "romnr": 9123, - "plasser": 200, - "adresse": "Kvalamarka 26", - "latitude": 59.436944, - "longitude": 5.257753 - }, - { - "lokalId": "3011fe06-fc9f-4b22-bcc7-6b6216277591", - "romnr": 9214, - "plasser": 265, - "adresse": "Skjoldavegen 318", - "latitude": 59.409627, - "longitude": 5.329626 - }, - { - "lokalId": "181e5379-4c7f-416c-b1a7-1efa60123ae5", - "romnr": 9357, - "plasser": 235, - "adresse": "Spannavegen 75", - "latitude": 59.404753, - "longitude": 5.298109 - }, - { - "lokalId": "fcda4c93-242a-47c3-9f9e-a5ecd49e9d58", - "romnr": 9398, - "plasser": 220, - "adresse": "Marker rådhus/ Storgata 60", - "latitude": 59.47952, - "longitude": 11.659117 - }, - { - "lokalId": "e8053161-2c4c-4f14-a6b9-85fd33f17c99", - "romnr": 9403, - "plasser": 2000, - "adresse": "Kulås/ Sandesundveien 10C", - "latitude": 59.280082, - "longitude": 11.107723 - }, - { - "lokalId": "65effa39-a999-4d99-b83b-ec6b2902d06e", - "romnr": 9404, - "plasser": 900, - "adresse": "Spikern/ Alvimveien 19", - "latitude": 59.27118, - "longitude": 11.089701 - }, - { - "lokalId": "0ad6bea2-809b-49fb-b727-d6aba741b399", - "romnr": 9405, - "plasser": 300, - "adresse": "Sagahuset/ Sandesundveien 1", - "latitude": 59.28341, - "longitude": 11.108994 - }, - { - "lokalId": "a8a2befb-6692-4b85-bdbe-2137e220e285", - "romnr": 9407, - "plasser": 477, - "adresse": "Skjeberg rådhus/ Rådhusveien 17", - "latitude": 59.266996, - "longitude": 11.169627 - }, - { - "lokalId": "c78ee9bb-0063-479e-8953-f954f09109ce", - "romnr": 9408, - "plasser": 89, - "adresse": "Dahles bilforretn/ Herbergveien 4", - "latitude": 59.288717, - "longitude": 11.087792 - }, - { - "lokalId": "486751a8-dbe5-4d1e-a0d2-9a44a1467000", - "romnr": 9410, - "plasser": 40, - "adresse": "Hafslundsøy skole/ Hagastuveien 60", - "latitude": 59.285791, - "longitude": 11.14699 - }, - { - "lokalId": "5e5cbfb4-1680-4e45-b144-c19f0f39e340", - "romnr": 9412, - "plasser": 100, - "adresse": "Borg bil/ Glomveien 5", - "latitude": 59.290141, - "longitude": 11.086135 - }, - { - "lokalId": "3f50f828-85b0-4649-9efb-9c166b013c6a", - "romnr": 9413, - "plasser": 183, - "adresse": "Tingvollheimen aldersheim/ Tuneveien 70", - "latitude": 59.294775, - "longitude": 11.074037 - }, - { - "lokalId": "83e303e1-11d1-4037-b8b1-3d07afe11272", - "romnr": 9474, - "plasser": 388, - "adresse": "Håreksgt. 13", - "latitude": 65.835442, - "longitude": 13.195049 - }, - { - "lokalId": "387a6bc7-4a9a-4085-8662-76b48b82c727", - "romnr": 9505, - "plasser": 25, - "adresse": "Strandgata 36 (offentlig)", - "latitude": 65.836672, - "longitude": 13.190495 - }, - { - "lokalId": "1b5fb39f-ddbe-4ea9-a178-8715f934434a", - "romnr": 9508, - "plasser": 15, - "adresse": "C.M. Havigsgt. 28 (offentlig)", - "latitude": 65.837097, - "longitude": 13.191516 - }, - { - "lokalId": "9333e182-b38c-45f1-822e-12938cdcf0c1", - "romnr": 9517, - "plasser": 310, - "adresse": "Fearnleysgt. 14 (Mosjøen viderg.skole- off.)", - "latitude": 65.837793, - "longitude": 13.193526 - }, - { - "lokalId": "b7b8a046-c0eb-47de-8065-1460a413c9d4", - "romnr": 9521, - "plasser": 220, - "adresse": "Stortingmannsv. 20 (Kulstad skole - Off.)", - "latitude": 65.866093, - "longitude": 13.19978 - }, - { - "lokalId": "db29b8b3-2944-4d84-8f03-095bb70b42f5", - "romnr": 9528, - "plasser": 172, - "adresse": "Olderskog skole - Skule Svendsv. 17", - "latitude": 65.821638, - "longitude": 13.217572 - }, - { - "lokalId": "1f3634c6-a68e-4c55-9a25-dff95e3b9d65", - "romnr": 9529, - "plasser": 100, - "adresse": "Skjervgata 43 - Rådhuset i Vefsn (off.)", - "latitude": 65.840847, - "longitude": 13.199839 - }, - { - "lokalId": "34a653bc-e887-441a-9b93-f6c89fabe02a", - "romnr": 9548, - "plasser": 222, - "adresse": "Torjus Gards veg 7", - "latitude": 59.428182, - "longitude": 5.267023 - }, - { - "lokalId": "346e80c1-1fb4-4e36-8ade-01875a4b0c5c", - "romnr": 9550, - "plasser": 220, - "adresse": "Tittelsnesvegen 332", - "latitude": 59.468892, - "longitude": 5.284654 - }, - { - "lokalId": "b3e6e4fc-b57d-47b9-a7a5-73bd20f87831", - "romnr": 9670, - "plasser": 500, - "adresse": "Florvågvegen 3, Løfjell, Kleppestø", - "latitude": 60.409259, - "longitude": 5.228322 - }, - { - "lokalId": "fff8c7dd-eb9e-48d1-8f74-8fc6d28e8369", - "romnr": 9806, - "plasser": 65, - "adresse": "Kvassnesv. 52 Pb. 157", - "latitude": 60.544934, - "longitude": 5.293043 - }, - { - "lokalId": "d87c6802-023b-4427-90f0-3c3a48a9cdeb", - "romnr": 9852, - "plasser": 100, - "adresse": "Bu og rehab. senter", - "latitude": 59.781823, - "longitude": 5.487313 - }, - { - "lokalId": "0b36e3c4-23cb-48b7-aecb-523bf296f706", - "romnr": 9915, - "plasser": 233, - "adresse": "Strandavegen 334", - "latitude": 60.658698, - "longitude": 6.434672 - }, - { - "lokalId": "871f4efd-fde7-4fc9-97a8-223dda6cd476", - "romnr": 9952, - "plasser": 698, - "adresse": "Miltzowsgt. 2", - "latitude": 60.62794, - "longitude": 6.419728 - }, - { - "lokalId": "bdc42977-f258-47e6-a633-c2af7878611b", - "romnr": 9983, - "plasser": 100, - "adresse": "Tjørnahaugane 60", - "latitude": 60.616448, - "longitude": 6.536621 - }, - { - "lokalId": "94ba0b4b-c0f2-4632-bbbb-187b38d8d57c", - "romnr": 10113, - "plasser": 360, - "adresse": "Elvavegen 2, Ytre Arna", - "latitude": 60.460226, - "longitude": 5.438147 - }, - { - "lokalId": "57d2c6a3-1d4b-4e5b-91c2-5c2d4f740445", - "romnr": 10212, - "plasser": 758, - "adresse": "Håkonsgaten 5", - "latitude": 60.391032, - "longitude": 5.317521 - }, - { - "lokalId": "9f18b131-7635-4bad-a9f3-4809a3a83701", - "romnr": 10387, - "plasser": 600, - "adresse": "Kalfarveien 76", - "latitude": 60.386629, - "longitude": 5.348519 - }, - { - "lokalId": "769fc494-b223-4947-8af2-32b56922225d", - "romnr": 10507, - "plasser": 500, - "adresse": "Nykirkealmenning ovenfor nr 19", - "latitude": 60.396579, - "longitude": 5.311762 - }, - { - "lokalId": "80e3c0ee-5e24-4729-97b8-938a9582ba94", - "romnr": 10508, - "plasser": 1400, - "adresse": "Holbergsalm. v/Strandgt 96", - "latitude": 60.395896, - "longitude": 5.316373 - }, - { - "lokalId": "88d0c98f-6dd0-4ede-bc0d-ef9b08d77050", - "romnr": 10511, - "plasser": 650, - "adresse": "Dokkeskjærskaien 1", - "latitude": 60.388199, - "longitude": 5.310968 - }, - { - "lokalId": "8f9df418-e25f-4898-a460-e0e4acc731b0", - "romnr": 10512, - "plasser": 3500, - "adresse": "Sydnestunnelen", - "latitude": 60.38943, - "longitude": 5.321949 - }, - { - "lokalId": "a137fbcb-b1a6-419d-91a1-664473af4228", - "romnr": 10516, - "plasser": 850, - "adresse": "Nordnes, v/ Haugeveien 32", - "latitude": 60.398272, - "longitude": 5.305496 - }, - { - "lokalId": "a41cc9dd-9069-4fb1-b45e-299ee1169810", - "romnr": 10788, - "plasser": 150, - "adresse": "Bønesberget 1", - "latitude": 60.332294, - "longitude": 5.30858 - }, - { - "lokalId": "8715ca46-6bd1-41ee-8ef5-83970b6a7e62", - "romnr": 11235, - "plasser": 150, - "adresse": "Søreidtunet 2", - "latitude": 60.314876, - "longitude": 5.271338 - }, - { - "lokalId": "8314551c-5b0b-4fec-9ff3-888f0ece65a0", - "romnr": 11236, - "plasser": 175, - "adresse": "Nordåsbrotet 2", - "latitude": 60.305625, - "longitude": 5.308297 - }, - { - "lokalId": "52d7fa87-2ed3-40b4-ad46-554cd04cde35", - "romnr": 11284, - "plasser": 2000, - "adresse": "Haukelandsveien 30", - "latitude": 60.371759, - "longitude": 5.360393 - }, - { - "lokalId": "ee8d87c3-8342-44c5-9886-8a0bb6d09e37", - "romnr": 11285, - "plasser": 2000, - "adresse": "Kronstadhøyden - vis a vis Møllendalsv nr 8", - "latitude": 60.379365, - "longitude": 5.338758 - }, - { - "lokalId": "92432e29-01d2-48e1-b071-2c2fa0dbe345", - "romnr": 11289, - "plasser": 422, - "adresse": "Vilhelm Bjerknes' vei 22", - "latitude": 60.353994, - "longitude": 5.360502 - }, - { - "lokalId": "7b4461f3-aaef-429c-91da-4e64d65ea349", - "romnr": 11343, - "plasser": 1300, - "adresse": "Fageråsveien, vis a vis Fageråsv 20", - "latitude": 60.359363, - "longitude": 5.354027 - }, - { - "lokalId": "bf26feb7-923f-4005-94a0-ccac32a5e42b", - "romnr": 11424, - "plasser": 240, - "adresse": "Kanalveien 54", - "latitude": 60.364797, - "longitude": 5.346087 - }, - { - "lokalId": "5d764578-4baa-4dce-a040-f3769920bfbd", - "romnr": 11663, - "plasser": 755, - "adresse": "Flaktveitsvingane 17", - "latitude": 60.464214, - "longitude": 5.359736 - }, - { - "lokalId": "0fc3c4f4-1897-48a8-9734-4a2eb385fd56", - "romnr": 11675, - "plasser": 750, - "adresse": "Florida-Nygårdsparken, Jahnebakken 5", - "latitude": 60.382697, - "longitude": 5.332261 - }, - { - "lokalId": "7816cd4d-3bfc-41ad-9714-4797ceaecc1e", - "romnr": 11677, - "plasser": 1800, - "adresse": "St. Jørgen - St. Jakob, Danckert Krohnsgt", - "latitude": 60.392466, - "longitude": 5.334108 - }, - { - "lokalId": "f0eede3f-8fb7-4e65-b041-80072dd0204e", - "romnr": 11681, - "plasser": 1050, - "adresse": "St. Markus, Damsgårdsveien 70", - "latitude": 60.379838, - "longitude": 5.323122 - }, - { - "lokalId": "d5557e8a-a3ca-46e1-ad37-f7d68fd64bd6", - "romnr": 11686, - "plasser": 1500, - "adresse": "Rothaugen, Nye Sandviksv v/ 69a", - "latitude": 60.403998, - "longitude": 5.321513 - }, - { - "lokalId": "c58cacd7-1520-4669-b0be-087af4941a8b", - "romnr": 11688, - "plasser": 230, - "adresse": "Hølbekken 1, Arnatveit", - "latitude": 60.408357, - "longitude": 5.47878 - }, - { - "lokalId": "8ae5f367-1913-49e8-83e5-e955fe3eb0ec", - "romnr": 11692, - "plasser": 455, - "adresse": "Olsvikåsen 49, Olsvik", - "latitude": 60.381112, - "longitude": 5.217063 - }, - { - "lokalId": "d819b930-74da-47a9-be1a-cfd230a950b0", - "romnr": 11741, - "plasser": 465, - "adresse": "Rådhusgata 4", - "latitude": 69.472222, - "longitude": 25.518868 - }, - { - "lokalId": "5f3ac9c4-f56c-4988-b914-33905efcf7cb", - "romnr": 11754, - "plasser": 2000, - "adresse": "Fossliåsen", - "latitude": 63.480034, - "longitude": 10.933616 - }, - { - "lokalId": "de36c8bc-90de-4897-9060-3baa98298d62", - "romnr": 11824, - "plasser": 350, - "adresse": "Lande skole/ Triangelveien 5", - "latitude": 59.290321, - "longitude": 11.104092 - }, - { - "lokalId": "082c2441-ce96-47c9-8543-27306959c841", - "romnr": 11848, - "plasser": 200, - "adresse": "Siriusveien 10", - "latitude": 63.394464, - "longitude": 10.415986 - }, - { - "lokalId": "38d2a1d0-7079-415b-84c3-964d8fca4df2", - "romnr": 12016, - "plasser": 200, - "adresse": "Torvet 4-6", - "latitude": 59.284283, - "longitude": 11.110528 - }, - { - "lokalId": "d2a5c489-2f89-4832-9898-aaac31687161", - "romnr": 12021, - "plasser": 42, - "adresse": "Finn Sørli/ Hundskinnveien 92", - "latitude": 59.285444, - "longitude": 11.079418 - }, - { - "lokalId": "4effbc5f-a0a3-49d2-89f9-0a84d6d789eb", - "romnr": 12025, - "plasser": 87, - "adresse": "Erikmarka 3", - "latitude": 62.44375, - "longitude": 6.214557 - }, - { - "lokalId": "927ef9c3-2338-4359-b64f-8cae9a22ed3c", - "romnr": 12072, - "plasser": 500, - "adresse": "Keiser Wilhelms gate 11", - "latitude": 62.470973, - "longitude": 6.153836 - }, - { - "lokalId": "784be7db-0817-4fe1-8d6c-432b0d9aad09", - "romnr": 12163, - "plasser": 123, - "adresse": "Blindheimsgeilane 1", - "latitude": 62.445808, - "longitude": 6.363423 - }, - { - "lokalId": "600e66df-d12c-4d71-8ef2-a1534901bfb6", - "romnr": 12196, - "plasser": 300, - "adresse": "Storgata 16", - "latitude": 62.473695, - "longitude": 6.158668 - }, - { - "lokalId": "df03582e-bd79-4598-a279-67424860b65a", - "romnr": 12245, - "plasser": 500, - "adresse": "Gangstøvikvegen 27", - "latitude": 62.474085, - "longitude": 6.227913 - }, - { - "lokalId": "21c6887e-6d07-4789-b3b1-c13fca7717cf", - "romnr": 12272, - "plasser": 383, - "adresse": "Fjellgata 45", - "latitude": 62.472098, - "longitude": 6.175156 - }, - { - "lokalId": "18365474-66d5-49ca-8721-ae66bbbf81ef", - "romnr": 12304, - "plasser": 315, - "adresse": "Torggata 52", - "latitude": 60.794365, - "longitude": 11.072283 - }, - { - "lokalId": "bb0bdd03-1776-40df-8a41-96c728d969b5", - "romnr": 12306, - "plasser": 216, - "adresse": "Parkgata 33", - "latitude": 60.793351, - "longitude": 11.079709 - }, - { - "lokalId": "9dee91f4-3688-4d50-99f4-1e63ea308b34", - "romnr": 12307, - "plasser": 270, - "adresse": "Morteruds gate 22 - 24", - "latitude": 60.795423, - "longitude": 11.066359 - }, - { - "lokalId": "61de7828-aba3-4d1b-b967-845c145cf61d", - "romnr": 12308, - "plasser": 320, - "adresse": "Storhamargata 23 B-C", - "latitude": 60.794926, - "longitude": 11.065353 - }, - { - "lokalId": "6f70750c-a65d-4fb6-8033-dae3a62593ae", - "romnr": 12309, - "plasser": 80, - "adresse": "Rollsløkkvegen 19", - "latitude": 60.800691, - "longitude": 11.084808 - }, - { - "lokalId": "e688cf83-a179-4792-b7f7-30934162edbf", - "romnr": 12311, - "plasser": 50, - "adresse": "Aslak Bolts gate 42", - "latitude": 60.797859, - "longitude": 11.048915 - }, - { - "lokalId": "d144dde7-e60d-4693-b22e-2168c1a27c25", - "romnr": 12312, - "plasser": 158, - "adresse": "Torggata 31", - "latitude": 60.793802, - "longitude": 11.073934 - }, - { - "lokalId": "2849b0de-b4ce-47b0-96c7-1e1805e023ec", - "romnr": 12313, - "plasser": 49, - "adresse": "Finsalvegen 3", - "latitude": 60.804176, - "longitude": 11.132714 - }, - { - "lokalId": "87f19be5-4dd5-4774-9151-acf467bbef24", - "romnr": 12314, - "plasser": 35, - "adresse": "Elvesletta 25", - "latitude": 60.842298, - "longitude": 11.094855 - }, - { - "lokalId": "376ba9ad-c19b-45dc-9eef-bf26cf51afea", - "romnr": 12315, - "plasser": 15, - "adresse": "Midtstranda 1", - "latitude": 60.797947, - "longitude": 11.107548 - }, - { - "lokalId": "d208f3e4-3de4-4990-9071-c69c2e458005", - "romnr": 12325, - "plasser": 200, - "adresse": "Øvre Flatåsveg 2 A", - "latitude": 63.372677, - "longitude": 10.345811 - }, - { - "lokalId": "66624647-ece8-497a-88e0-fe141de7a7d0", - "romnr": 12330, - "plasser": 45, - "adresse": "Øvre Flatåsveg 16", - "latitude": 63.373192, - "longitude": 10.333224 - }, - { - "lokalId": "83ed37dd-6fad-482a-9304-1dc437eacf4d", - "romnr": 12333, - "plasser": 280, - "adresse": "Gamle Jonsvannsveien 12", - "latitude": 63.411511, - "longitude": 10.452723 - }, - { - "lokalId": "29d86544-0a39-476a-88be-a0f71069b65e", - "romnr": 12438, - "plasser": 459, - "adresse": "Infanteriveien 11, Setermoen brannstasjon", - "latitude": 68.860116, - "longitude": 18.354632 - }, - { - "lokalId": "183603f8-b340-4fbd-9692-ff9e0ba9f66a", - "romnr": 12456, - "plasser": 225, - "adresse": "Gesellveien 5", - "latitude": 59.653414, - "longitude": 9.63991 - }, - { - "lokalId": "cd02331f-fe44-4a2c-87ad-bc9f7fcdf3c0", - "romnr": 12704, - "plasser": 1250, - "adresse": "Lykkeberg/ St.Croix gate 31", - "latitude": 59.211252, - "longitude": 10.941203 - }, - { - "lokalId": "cfa9f5e8-195e-4410-822a-c9d57baec9c1", - "romnr": 12705, - "plasser": 1345, - "adresse": "Damyr/ Sportsveien 18", - "latitude": 59.21285, - "longitude": 10.948606 - }, - { - "lokalId": "d22a0437-178c-4d18-ac00-7d5c0561814b", - "romnr": 12706, - "plasser": 5384, - "adresse": "St. hansfjellet/ Farmansgate 16", - "latitude": 59.214646, - "longitude": 10.934136 - }, - { - "lokalId": "c3587616-a180-4054-8214-bb9081b19d43", - "romnr": 12707, - "plasser": 716, - "adresse": "Kongstenhallen", - "latitude": 59.200707, - "longitude": 10.961142 - }, - { - "lokalId": "df620830-e4e4-442f-bb78-0f5083257be2", - "romnr": 12708, - "plasser": 750, - "adresse": "Smertu/ Løypeveien 18", - "latitude": 59.2042, - "longitude": 10.936221 - }, - { - "lokalId": "7851bf7f-832e-425e-a4e7-5efffb4ba071", - "romnr": 12709, - "plasser": 1200, - "adresse": "Gressvik/ Storveien 103A", - "latitude": 59.214315, - "longitude": 10.902453 - }, - { - "lokalId": "d9804de5-f0ed-452e-814b-4104b7c64aab", - "romnr": 12710, - "plasser": 97, - "adresse": "Aktivitetshuset/ Gressvik torg 13", - "latitude": 59.216214, - "longitude": 10.906206 - }, - { - "lokalId": "b25053a2-6132-458c-b783-abbb487cda22", - "romnr": 12711, - "plasser": 100, - "adresse": "Rolvsøyahallen", - "latitude": 59.267677, - "longitude": 10.991211 - }, - { - "lokalId": "c77c9b95-7e38-4a72-953b-075d32a46afa", - "romnr": 13266, - "plasser": 165, - "adresse": "Bleikerveien 64 (Asker Tennisklubb)", - "latitude": 59.827695, - "longitude": 10.447574 - }, - { - "lokalId": "d7079e62-d506-4a68-a91e-3c5c89b7c570", - "romnr": 13361, - "plasser": 756, - "adresse": "Kulturhuset", - "latitude": 69.231288, - "longitude": 17.98805 - }, - { - "lokalId": "1a609376-125e-4bfb-bd68-696225d8acef", - "romnr": 13469, - "plasser": 65, - "adresse": "Øvermarka 24", - "latitude": 60.8436, - "longitude": 11.043208 - }, - { - "lokalId": "ffd6ac5e-53c6-40ac-913b-241c2626815b", - "romnr": 13470, - "plasser": 55, - "adresse": "Nygata 9", - "latitude": 60.881794, - "longitude": 10.939832 - }, - { - "lokalId": "b2391e67-12ce-4c07-95b6-b9863689b046", - "romnr": 13471, - "plasser": 343, - "adresse": "Brugata 3", - "latitude": 60.885432, - "longitude": 10.939129 - }, - { - "lokalId": "06ec3426-428f-4357-a048-668ee514dcc7", - "romnr": 13472, - "plasser": 52, - "adresse": "Ljøstadvegen 15", - "latitude": 60.715074, - "longitude": 11.204224 - }, - { - "lokalId": "129f5a0f-9ad4-40bc-bdab-57b3dac6d5d9", - "romnr": 13479, - "plasser": 1372, - "adresse": "Strømsø torg 7", - "latitude": 59.740131, - "longitude": 10.200576 - }, - { - "lokalId": "110aa298-bb7d-45a5-bc0d-4cb00a58f000", - "romnr": 13510, - "plasser": 313, - "adresse": "Sørreisa Rådhus/Helsesenter", - "latitude": 69.147198, - "longitude": 18.1559 - }, - { - "lokalId": "eb43f667-dcb2-4a70-b31e-589948272d34", - "romnr": 13511, - "plasser": 173, - "adresse": "Øyjordneset", - "latitude": 69.154384, - "longitude": 18.156183 - }, - { - "lokalId": "5d466183-5e6b-4157-a76d-5b4ac9a7b497", - "romnr": 13513, - "plasser": 175, - "adresse": "Dyrøy sykehjem, Brøstabotn", - "latitude": 69.091034, - "longitude": 17.69045 - }, - { - "lokalId": "ec478664-a4f5-4c13-a125-f2b17ec1aa4c", - "romnr": 13637, - "plasser": 1200, - "adresse": "Bergstien 37", - "latitude": 59.748307, - "longitude": 10.203019 - }, - { - "lokalId": "deec4abb-09e7-4ff4-ab8f-d043f1a6620d", - "romnr": 13649, - "plasser": 465, - "adresse": "Oscars gate 4", - "latitude": 70.074609, - "longitude": 29.750277 - }, - { - "lokalId": "552f184d-c045-4f9c-a2e1-447da99d8fc1", - "romnr": 14233, - "plasser": 372, - "adresse": "Garderobehuset, Narvik stadion, Lillevikveien 9", - "latitude": 68.442373, - "longitude": 17.406735 - }, - { - "lokalId": "fcd7cba7-d097-4bef-bf0f-6c36e79830d4", - "romnr": 14249, - "plasser": 120, - "adresse": "Fotvegen 16", - "latitude": 59.290624, - "longitude": 5.288196 - }, - { - "lokalId": "53c612ca-f463-41f6-96b4-af6beca8437b", - "romnr": 14250, - "plasser": 445, - "adresse": "Ankenes Bo- og serviceservice Krobakken 5", - "latitude": 68.422775, - "longitude": 17.349114 - }, - { - "lokalId": "4f007bac-8c81-4cca-a25f-f82cbe6685ba", - "romnr": 14279, - "plasser": 63, - "adresse": "Neptunveien 3-5", - "latitude": 59.760513, - "longitude": 10.03575 - }, - { - "lokalId": "c7036287-c719-4a24-a64d-398e01f344d7", - "romnr": 14355, - "plasser": 18, - "adresse": "Austre Veaveg 217", - "latitude": 59.292976, - "longitude": 5.226173 - }, - { - "lokalId": "50e15ba1-c4a6-4741-8b49-7311f3868969", - "romnr": 14476, - "plasser": 400, - "adresse": "Hagebyen skole", - "latitude": 68.813691, - "longitude": 16.543533 - }, - { - "lokalId": "c3383e3d-c735-4250-8b0a-9205233f2eac", - "romnr": 14487, - "plasser": 400, - "adresse": "Rådhuset Harstad OFFENTLIG", - "latitude": 68.798741, - "longitude": 16.53915 - }, - { - "lokalId": "fe6d439c-8114-4e5a-8ebe-2b49639428fa", - "romnr": 14559, - "plasser": 281, - "adresse": "Stokkeveien 2", - "latitude": 59.791903, - "longitude": 10.258623 - }, - { - "lokalId": "bfe4fed1-4064-47cd-a878-52121f124c21", - "romnr": 14566, - "plasser": 850, - "adresse": "Kanebogen skole OFFENTLIG", - "latitude": 68.779884, - "longitude": 16.55882 - }, - { - "lokalId": "ac17fbb5-c7cd-4c37-9cf5-1c6255c18da8", - "romnr": 14854, - "plasser": 320, - "adresse": "Kong Øysteinhall, Rækøyveien 40. Kabelvåg", - "latitude": 68.207975, - "longitude": 14.46518 - }, - { - "lokalId": "eda2e4d1-8016-4720-9616-7d7cb011a471", - "romnr": 14855, - "plasser": 3300, - "adresse": "VåganHallen, Løkthaugveien 10. Svolvær", - "latitude": 68.237656, - "longitude": 14.567376 - }, - { - "lokalId": "159c38d2-dbba-4d2c-b6b6-b4c4486885f6", - "romnr": 14871, - "plasser": 83, - "adresse": "Vesterngata 18", - "latitude": 60.170464, - "longitude": 10.268181 - }, - { - "lokalId": "5c571011-5b0f-485d-937a-6c603fb9e7e3", - "romnr": 14905, - "plasser": 90, - "adresse": "Skulegata 29", - "latitude": 59.652094, - "longitude": 6.357013 - }, - { - "lokalId": "b9cfdd00-82f8-4e74-b9bf-45869dbbad24", - "romnr": 14908, - "plasser": 125, - "adresse": "Saudavegen 279", - "latitude": 59.645393, - "longitude": 6.31035 - }, - { - "lokalId": "00aba34c-24b4-4d88-bc00-ad8adbfb5655", - "romnr": 14911, - "plasser": 150, - "adresse": "Rustå 31", - "latitude": 59.657841, - "longitude": 6.380221 - }, - { - "lokalId": "0609ac19-3913-458a-babf-b8b90a004912", - "romnr": 14918, - "plasser": 85, - "adresse": "Åbødalsveien 79", - "latitude": 59.656027, - "longitude": 6.363345 - }, - { - "lokalId": "c351b9f4-7a9f-42ac-aa98-942bf4b27b26", - "romnr": 14959, - "plasser": 814, - "adresse": "Karl Rasmussens plass 1", - "latitude": 70.077958, - "longitude": 29.739806 - }, - { - "lokalId": "9df20d2c-d3ad-41d2-81c5-859e20f416da", - "romnr": 14979, - "plasser": 600, - "adresse": "Skulegata 23", - "latitude": 59.651065, - "longitude": 6.357565 - }, - { - "lokalId": "49495ccf-97e9-4806-abb1-608585bf7554", - "romnr": 14981, - "plasser": 78, - "adresse": "Sjøen 45", - "latitude": 59.640103, - "longitude": 6.30552 - }, - { - "lokalId": "3646208a-0a6e-4309-bb72-35fefec26e89", - "romnr": 14984, - "plasser": 40, - "adresse": "Åbødalsveien 77", - "latitude": 59.655203, - "longitude": 6.363862 - }, - { - "lokalId": "10390a33-a17c-4de3-99be-f1dc77ba1bad", - "romnr": 15004, - "plasser": 100, - "adresse": "Sortlandshallen", - "latitude": 68.696152, - "longitude": 15.407296 - }, - { - "lokalId": "59406cab-1952-4a73-811f-bf668f1f8de1", - "romnr": 15005, - "plasser": 300, - "adresse": "Torvmyrveien 4A", - "latitude": 59.000947, - "longitude": 5.619654 - }, - { - "lokalId": "0be3386f-7f4f-4232-8da7-13688b493071", - "romnr": 15006, - "plasser": 270, - "adresse": "Torvmyrveien 40", - "latitude": 59.00893, - "longitude": 5.650053 - }, - { - "lokalId": "cfc1ace9-76e9-4300-b623-4a2593bb1aad", - "romnr": 15059, - "plasser": 2000, - "adresse": "Storevardvegen", - "latitude": 58.940876, - "longitude": 5.578049 - }, - { - "lokalId": "b670476d-0c81-4ade-bd14-b98f0515b97c", - "romnr": 15232, - "plasser": 2080, - "adresse": "Kirkebakken 18", - "latitude": 58.96844, - "longitude": 5.737782 - }, - { - "lokalId": "1a71c377-8a40-4c5d-8fd5-f6b9f8312728", - "romnr": 15236, - "plasser": 200, - "adresse": "Morgedalsveien 60", - "latitude": 58.951392, - "longitude": 5.695868 - }, - { - "lokalId": "04fe19e2-9c2b-4d27-96b4-f52cc49bdb81", - "romnr": 15239, - "plasser": 220, - "adresse": "Jupiterveien 30C", - "latitude": 58.947102, - "longitude": 5.700146 - }, - { - "lokalId": "2c781068-a801-4786-b18f-017d16d3c6fc", - "romnr": 15240, - "plasser": 500, - "adresse": "Lagårdsveien 77", - "latitude": 58.958333, - "longitude": 5.739728 - }, - { - "lokalId": "a590197a-9eb7-4660-a3c9-e07a9a9244ac", - "romnr": 15243, - "plasser": 5000, - "adresse": "Nedre Storaberget 1", - "latitude": 58.896774, - "longitude": 5.722953 - }, - { - "lokalId": "379ec27f-5699-41d4-a8a3-e4afc11484e9", - "romnr": 15245, - "plasser": 4000, - "adresse": "Nedre Strandgate", - "latitude": 58.973005, - "longitude": 5.725783 - }, - { - "lokalId": "93fb5dd2-ee84-46a3-ac83-bbeb9f36d6e7", - "romnr": 15246, - "plasser": 380, - "adresse": "Olav Liljekransstien 20", - "latitude": 58.944657, - "longitude": 5.698082 - }, - { - "lokalId": "ffa50c4c-cb44-458c-83de-8948d4531652", - "romnr": 15247, - "plasser": 380, - "adresse": "Hallingstien 38", - "latitude": 58.946174, - "longitude": 5.697058 - }, - { - "lokalId": "e1d932bf-27b4-4f02-8301-fae2e301efe4", - "romnr": 15250, - "plasser": 3930, - "adresse": "Skagenkaien 29", - "latitude": 58.972125, - "longitude": 5.731114 - }, - { - "lokalId": "71d153cc-a683-47c5-8619-ae6d34572b3b", - "romnr": 15251, - "plasser": 3140, - "adresse": "Eskelandsveien 35", - "latitude": 58.986136, - "longitude": 5.67812 - }, - { - "lokalId": "9da896af-9d7b-47de-ba1e-16723bdb4394", - "romnr": 15255, - "plasser": 72, - "adresse": "Avaldsnesvegen 72", - "latitude": 59.291226, - "longitude": 5.303296 - }, - { - "lokalId": "50e3e29b-79a2-4a21-8617-ffa1c4b0cdf2", - "romnr": 15258, - "plasser": 66, - "adresse": "Austre Karmøyveg 120", - "latitude": 59.280605, - "longitude": 5.300107 - }, - { - "lokalId": "5d9c7d1a-d71d-4965-9834-6e77c0eb81f2", - "romnr": 15260, - "plasser": 608, - "adresse": "Lotheparkvegen", - "latitude": 59.421294, - "longitude": 5.270669 - }, - { - "lokalId": "783b92ee-fc7f-42ce-8318-2e0793475c4a", - "romnr": 15347, - "plasser": 150, - "adresse": "Turist og konferansesenteret, Børøya", - "latitude": 68.570795, - "longitude": 14.92446 - }, - { - "lokalId": "b4bab817-3cfc-48af-9fc9-196af69fdaf1", - "romnr": 15468, - "plasser": 420, - "adresse": "Søndre Torv 7 B", - "latitude": 60.165649, - "longitude": 10.254797 - }, - { - "lokalId": "9dc780ee-1e06-433f-a82a-e8603fae95aa", - "romnr": 15471, - "plasser": 417, - "adresse": "Valhallveien 15", - "latitude": 60.182025, - "longitude": 10.196138 - }, - { - "lokalId": "1fb030cc-d777-4263-81a7-d9cf2a48b74d", - "romnr": 15476, - "plasser": 170, - "adresse": "Ringeriksgata 13", - "latitude": 60.157429, - "longitude": 10.262372 - }, - { - "lokalId": "6ea7900f-78a6-48a3-b9dc-52ebc33790d8", - "romnr": 15481, - "plasser": 100, - "adresse": "Dronning Åstas gate 12", - "latitude": 60.153285, - "longitude": 10.252902 - }, - { - "lokalId": "72f51a02-eef7-4d55-8e62-2299a791398a", - "romnr": 15597, - "plasser": 385, - "adresse": "Krokenveien 19", - "latitude": 60.180368, - "longitude": 10.259675 - }, - { - "lokalId": "f1af3458-1971-4b77-8b60-3fe819a0ec52", - "romnr": 15611, - "plasser": 330, - "adresse": "Wergelands gate 9", - "latitude": 59.748398, - "longitude": 10.19372 - }, - { - "lokalId": "00665953-a9db-44c3-82ff-8b58d01e697f", - "romnr": 15714, - "plasser": 150, - "adresse": "Søfteland", - "latitude": 60.2363, - "longitude": 5.452183 - }, - { - "lokalId": "e1386977-fd37-41e7-b140-84445e4907e6", - "romnr": 15715, - "plasser": 550, - "adresse": "Rådhus. Idrett og Svømmehall", - "latitude": 69.314139, - "longitude": 16.118059 - }, - { - "lokalId": "f86ac2c2-7916-4c4f-8e93-64e55ad21a31", - "romnr": 15717, - "plasser": 136, - "adresse": "Askviknes Sykehjem", - "latitude": 60.175596, - "longitude": 5.38207 - }, - { - "lokalId": "b833ca4b-52d7-4218-8f6c-348561490856", - "romnr": 15730, - "plasser": 500, - "adresse": "Os", - "latitude": 60.188854, - "longitude": 5.469669 - }, - { - "lokalId": "8f0ae2fb-a6da-406b-9c2c-84dd5145da40", - "romnr": 15745, - "plasser": 150, - "adresse": "Betanien Hospital", - "latitude": 59.209066, - "longitude": 9.596141 - }, - { - "lokalId": "b2d09ab0-d33e-41fa-9212-345efac5118d", - "romnr": 15746, - "plasser": 390, - "adresse": "Gampedalen", - "latitude": 59.20229, - "longitude": 9.617147 - }, - { - "lokalId": "b1f54198-34c4-42ba-96c5-eb1f8f599457", - "romnr": 15748, - "plasser": 325, - "adresse": "Nedre Hjellegt 9", - "latitude": 59.206353, - "longitude": 9.606643 - }, - { - "lokalId": "4e73e1b9-2771-4bd8-9f30-d931d483bd1d", - "romnr": 15749, - "plasser": 498, - "adresse": "Schweigaards gate 11", - "latitude": 59.21216, - "longitude": 9.605392 - }, - { - "lokalId": "44e8d95c-c810-4499-92d6-cc5270319428", - "romnr": 15750, - "plasser": 400, - "adresse": "Tamburlunden Gråtenmoen Terrasse 16", - "latitude": 59.185667, - "longitude": 9.616471 - }, - { - "lokalId": "a7a7c665-e70a-460c-90f8-804ced8c151b", - "romnr": 15752, - "plasser": 500, - "adresse": "Bånnåsen 21", - "latitude": 59.136651, - "longitude": 9.660227 - }, - { - "lokalId": "06c97fed-31e3-4088-ae82-35177c2896ba", - "romnr": 15753, - "plasser": 1120, - "adresse": "Helleberget, Huken, Crøgerlia 21", - "latitude": 59.137459, - "longitude": 9.644708 - }, - { - "lokalId": "cdb937f2-fc60-424f-bad8-d58cf6e66742", - "romnr": 15754, - "plasser": 490, - "adresse": "Klevstrand", - "latitude": 59.115603, - "longitude": 9.653273 - }, - { - "lokalId": "d5f60040-8208-4ee8-be96-9b398004b187", - "romnr": 15755, - "plasser": 540, - "adresse": "Lysthusåsen, Kleiva 4", - "latitude": 59.142081, - "longitude": 9.644843 - }, - { - "lokalId": "2a199dad-b9f7-47e0-a4d5-e20cf0c604d4", - "romnr": 15756, - "plasser": 130, - "adresse": "Politihuset, Rådhusgt 5", - "latitude": 59.141775, - "longitude": 9.657483 - }, - { - "lokalId": "4d5cf968-d970-4177-b09d-681e84793899", - "romnr": 15757, - "plasser": 2000, - "adresse": "Sentrumstunellen/Porsgrunntunnelen", - "latitude": 59.135636, - "longitude": 9.660491 - }, - { - "lokalId": "021367f0-00f6-49de-8269-74f1aa792a85", - "romnr": 15758, - "plasser": 140, - "adresse": "Slottsbrugt 25", - "latitude": 59.14586, - "longitude": 9.664885 - }, - { - "lokalId": "e57fadb2-3a57-49bc-b732-129d40dc3d4e", - "romnr": 15759, - "plasser": 1040, - "adresse": "Trosvik, Brevik", - "latitude": 59.054237, - "longitude": 9.691015 - }, - { - "lokalId": "abebabd2-9bac-4f67-adfa-8f4c25fdccfa", - "romnr": 15760, - "plasser": 1000, - "adresse": "Renseanlegget, Ytre Strandvei 15", - "latitude": 58.873599, - "longitude": 9.414783 - }, - { - "lokalId": "3bf7f665-a23d-4c03-b0ea-c5df79537f91", - "romnr": 15761, - "plasser": 220, - "adresse": "Årø skole, Nøkkeldalen 4", - "latitude": 58.890679, - "longitude": 9.356233 - }, - { - "lokalId": "ce5b04a6-24d9-4dca-b988-82950c5b9cb5", - "romnr": 15762, - "plasser": 55, - "adresse": "Kalstad KBO, Kalstadveien 16E", - "latitude": 58.871025, - "longitude": 9.388295 - }, - { - "lokalId": "c08496d0-3ab5-428f-b814-ff2a02cde9f5", - "romnr": 15765, - "plasser": 728, - "adresse": "Potekjeller tomta, Bøenstomten 10", - "latitude": 59.879335, - "longitude": 8.591391 - }, - { - "lokalId": "c5ffe2c5-41cc-479e-8b85-5baf0b2de0ba", - "romnr": 15766, - "plasser": 200, - "adresse": "Rjukan politikammer, Sam Eydes gate 71", - "latitude": 59.878593, - "longitude": 8.589474 - }, - { - "lokalId": "6f37425c-7109-4fad-98f9-f7c4b3e6de23", - "romnr": 15767, - "plasser": 420, - "adresse": "Heddal u/skole, Rogneståvegen 6", - "latitude": 59.569637, - "longitude": 9.211249 - }, - { - "lokalId": "09d3c381-fe94-423c-a867-c58684aad60b", - "romnr": 15768, - "plasser": 1900, - "adresse": "Ravnsflog, Tinnesgata 18", - "latitude": 59.560759, - "longitude": 9.254196 - }, - { - "lokalId": "02b61699-0710-4e4e-ac73-c76ae85c5c6c", - "romnr": 15769, - "plasser": 700, - "adresse": "Storgt 59", - "latitude": 59.561301, - "longitude": 9.261607 - }, - { - "lokalId": "34e71c7b-d2ff-45e8-9d0c-c76d049d9600", - "romnr": 15770, - "plasser": 180, - "adresse": "Stathelle Service Senter", - "latitude": 59.042874, - "longitude": 9.692843 - }, - { - "lokalId": "85abf213-51b3-4c1f-8cfc-368f1f9fe059", - "romnr": 15771, - "plasser": 1138, - "adresse": "Kvæfjordhallen, Husebyveien 3", - "latitude": 68.772839, - "longitude": 16.179076 - }, - { - "lokalId": "a1644a4c-3376-4f0c-9639-d35782d9a8dc", - "romnr": 16127, - "plasser": 465, - "adresse": "Nils Leuchsvei 40", - "latitude": 59.946683, - "longitude": 10.619218 - }, - { - "lokalId": "89fba71a-d961-44d1-8652-e422d933761f", - "romnr": 16213, - "plasser": 1550, - "adresse": "Rødskiferveien 1 Kolsås Senter", - "latitude": 59.916573, - "longitude": 10.50076 - }, - { - "lokalId": "74a68b66-d7bf-4eea-8165-e8567953f486", - "romnr": 16352, - "plasser": 225, - "adresse": "Østerndalen 23 Grini næringspark", - "latitude": 59.947496, - "longitude": 10.601794 - }, - { - "lokalId": "bc28dfa9-41ef-4e29-9c17-88b95c254621", - "romnr": 16719, - "plasser": 1130, - "adresse": "St. Hanshaugen, Ullevålsveien", - "latitude": 59.925364, - "longitude": 10.738991 - }, - { - "lokalId": "ec8244e3-dcc8-4269-a09a-d6dce62966a0", - "romnr": 16951, - "plasser": 197, - "adresse": "Tareveien 7 Tennishallen", - "latitude": 67.258352, - "longitude": 15.352302 - }, - { - "lokalId": "3b8b00cf-c39a-4f8b-b6bf-a77550648db4", - "romnr": 16958, - "plasser": 255, - "adresse": "Tinkeliheia 1, (Finneid nærmiljøsenter)", - "latitude": 67.259782, - "longitude": 15.444636 - }, - { - "lokalId": "9b5d2b64-46e1-4051-97f2-af06016bcf20", - "romnr": 16973, - "plasser": 1750, - "adresse": "Tullinsgate 2", - "latitude": 59.918829, - "longitude": 10.734887 - }, - { - "lokalId": "9bf053fa-788c-4258-b721-6245e4033cd9", - "romnr": 17007, - "plasser": 214, - "adresse": "Løpsmark Skole Nonshaugen 87, 8015 Bodø", - "latitude": 67.310299, - "longitude": 14.447098 - }, - { - "lokalId": "4fdce490-e4f5-401a-9de2-647e43f67d36", - "romnr": 17014, - "plasser": 400, - "adresse": "Bjørndalsveien 14 (Mørkvedmarka skole)", - "latitude": 67.290673, - "longitude": 14.588055 - }, - { - "lokalId": "15e3a9d8-87ff-4e93-b539-424b61be22ce", - "romnr": 17356, - "plasser": 80, - "adresse": "Peter Bondes veg 78", - "latitude": 60.624576, - "longitude": 6.388827 - }, - { - "lokalId": "2c87f1ba-0371-4c69-b12f-a51a2d23d14d", - "romnr": 17526, - "plasser": 320, - "adresse": "Risilveien 71, Vestbyhallen", - "latitude": 59.597822, - "longitude": 10.709967 - }, - { - "lokalId": "7e999c54-b386-4690-b2ca-8ebbeeb75c45", - "romnr": 17609, - "plasser": 590, - "adresse": "Frognhallen, Belsjøveien 2", - "latitude": 59.66884, - "longitude": 10.634824 - }, - { - "lokalId": "b23f45b7-7ef4-4ea2-8f30-e618e274e2b1", - "romnr": 17676, - "plasser": 100, - "adresse": "Tjørnahaugane 60", - "latitude": 60.616448, - "longitude": 6.536621 - }, - { - "lokalId": "33613aa6-caa9-40a6-a5aa-15a83c30cedd", - "romnr": 17701, - "plasser": 240, - "adresse": "Moerveien 1- Skoleveien 1 Ås Rådhus", - "latitude": 59.664228, - "longitude": 10.790869 - }, - { - "lokalId": "21d3e8f2-f0b6-4932-a434-4bea860f9458", - "romnr": 17829, - "plasser": 258, - "adresse": "Allbrukshus Langhus, Galmle Vevelstadvei 34", - "latitude": 59.758799, - "longitude": 10.841281 - }, - { - "lokalId": "ac9d6b55-c600-43a7-94b7-432ebeaa002f", - "romnr": 18081, - "plasser": 240, - "adresse": "Rektor Oldens gate 2", - "latitude": 58.955198, - "longitude": 5.692018 - }, - { - "lokalId": "f09d6171-8177-4459-bae6-dc18bd497c5a", - "romnr": 18533, - "plasser": 300, - "adresse": "Fred Olsensgt 11", - "latitude": 59.910517, - "longitude": 10.748667 - }, - { - "lokalId": "f87aa2ba-ed34-4d46-a1ba-4e252f3da671", - "romnr": 18616, - "plasser": 7060, - "adresse": "Holmlia bad, Holmlia sentervei 34", - "latitude": 59.834469, - "longitude": 10.792393 - }, - { - "lokalId": "513daacc-01a0-41a6-959d-0a4b08799a8c", - "romnr": 20209, - "plasser": 250, - "adresse": "Bentsegt 21-25", - "latitude": 59.937644, - "longitude": 10.759794 - }, - { - "lokalId": "927a95b8-be35-4f76-bcde-32f911d541e4", - "romnr": 20242, - "plasser": 230, - "adresse": "Grav Gårdsvei 5", - "latitude": 59.93033, - "longitude": 10.606651 - }, - { - "lokalId": "7f799fd7-e3c9-4243-ae98-45891f5402dd", - "romnr": 21027, - "plasser": 40, - "adresse": "K.O. Bjerklis veg 9", - "latitude": 60.807854, - "longitude": 11.03274 - }, - { - "lokalId": "e7e88a06-c5da-43a7-82c6-0824aca1ee4a", - "romnr": 20243, - "plasser": 233, - "adresse": "Peter Lorangesvei 1, Stabekk Bosenter", - "latitude": 59.904457, - "longitude": 10.586712 - }, - { - "lokalId": "953a32f7-d8ff-43a4-8377-3965f5c6cb80", - "romnr": 20244, - "plasser": 553, - "adresse": "Skollerudveien 7, Helset hallen", - "latitude": 59.942985, - "longitude": 10.50811 - }, - { - "lokalId": "5bc4785d-c8d1-40ed-a73d-7fafdf08ff1a", - "romnr": 20245, - "plasser": 5500, - "adresse": "Vogellund 7, Holmenhallen", - "latitude": 59.853057, - "longitude": 10.484845 - }, - { - "lokalId": "211131f2-add4-40ae-b119-16f31546dd16", - "romnr": 20246, - "plasser": 250, - "adresse": "Veståsen 18, Katolsk kapell", - "latitude": 59.935334, - "longitude": 10.60407 - }, - { - "lokalId": "adb8b934-7f7f-4e2e-90c9-a4e68e6c0cd1", - "romnr": 20247, - "plasser": 1336, - "adresse": "Gml Ringeriksvei 44", - "latitude": 59.918199, - "longitude": 10.584346 - }, - { - "lokalId": "1974f202-ec50-4c0c-82a1-8ffe50db83e6", - "romnr": 20248, - "plasser": 260, - "adresse": "Bjerkelundsveien 18 A", - "latitude": 59.929936, - "longitude": 10.614801 - }, - { - "lokalId": "5e3a1c9b-ccaf-4f83-933d-71d7e8fa120b", - "romnr": 20249, - "plasser": 233, - "adresse": "Nedre Toppenhaug 56", - "latitude": 59.940538, - "longitude": 10.507814 - }, - { - "lokalId": "c921185b-add1-4acb-ac5b-5669b1536436", - "romnr": 20250, - "plasser": 4200, - "adresse": "Hamangskråningen, Brynsveien 2 Sandvika", - "latitude": 59.894621, - "longitude": 10.512613 - }, - { - "lokalId": "0a20e1a1-3320-494b-a218-e16614f2bf08", - "romnr": 20251, - "plasser": 243, - "adresse": "Sleiverudåsen 3", - "latitude": 59.945117, - "longitude": 10.504182 - }, - { - "lokalId": "8c93f8c3-a6d6-4fc0-9064-54f597fb9022", - "romnr": 20253, - "plasser": 2800, - "adresse": "T-banen - Valkyrien stasjon", - "latitude": 59.928429, - "longitude": 10.717619 - }, - { - "lokalId": "b16f5ed2-5715-4158-9801-401e0739f339", - "romnr": 20254, - "plasser": 5600, - "adresse": "Klemetsrud, Lofsrudveien 6", - "latitude": 59.848756, - "longitude": 10.829616 - }, - { - "lokalId": "0969e8cc-0196-42ca-99ae-6ad4d2908d58", - "romnr": 20255, - "plasser": 404, - "adresse": "Jordalsgt 11", - "latitude": 59.908953, - "longitude": 10.785588 - }, - { - "lokalId": "8b553950-90aa-4790-9d5b-7aa7af16d0ea", - "romnr": 20256, - "plasser": 555, - "adresse": "Majorstuveien 38", - "latitude": 59.928313, - "longitude": 10.714875 - }, - { - "lokalId": "0363ba51-a591-435b-96d8-daa87601bd2e", - "romnr": 20257, - "plasser": 4300, - "adresse": "Maridalsveien 14", - "latitude": 59.92252, - "longitude": 10.750318 - }, - { - "lokalId": "a2832514-ef82-47cc-8fd0-a83eca60643c", - "romnr": 20258, - "plasser": 472, - "adresse": "Olav Schousvei 2-8 plan A", - "latitude": 59.930605, - "longitude": 10.781977 - }, - { - "lokalId": "7d52ca22-2975-4e74-be7b-aed5010a8ca4", - "romnr": 20259, - "plasser": 385, - "adresse": "Nobelsgt 30", - "latitude": 59.921882, - "longitude": 10.700768 - }, - { - "lokalId": "de1ea4fc-1183-4602-abcb-3ddb68a7caba", - "romnr": 20261, - "plasser": 486, - "adresse": "Vøyensvingen 4", - "latitude": 59.929862, - "longitude": 10.752136 - }, - { - "lokalId": "95625b93-ad68-4dc9-afd4-bb29f9792b58", - "romnr": 20262, - "plasser": 260, - "adresse": "Kingosgt 17", - "latitude": 59.930057, - "longitude": 10.751484 - }, - { - "lokalId": "82263184-bc90-4aba-bf6b-df5648691e05", - "romnr": 20263, - "plasser": 2000, - "adresse": "T-banen - Jernbanetorget, nedgang Hotell clarion", - "latitude": 59.912508, - "longitude": 10.751295 - }, - { - "lokalId": "21acd61f-abf7-4e71-ab04-a5bcf6833cbc", - "romnr": 20264, - "plasser": 570, - "adresse": "Akersgata 41", - "latitude": 59.913579, - "longitude": 10.741865 - }, - { - "lokalId": "46943f13-2874-4e21-bfd7-87841b6d967d", - "romnr": 20265, - "plasser": 3000, - "adresse": "Akershus festning - Akershusstranda", - "latitude": 59.908679, - "longitude": 10.734938 - }, - { - "lokalId": "ad71d7da-fb1d-449a-ae81-f12c769d4d5b", - "romnr": 20266, - "plasser": 250, - "adresse": "Fredrik Stangsgt 37", - "latitude": 59.917852, - "longitude": 10.70926 - }, - { - "lokalId": "cb90db04-9382-4175-b0c3-705a9992d171", - "romnr": 20268, - "plasser": 160, - "adresse": "Bogstadveien 30", - "latitude": 59.926167, - "longitude": 10.720795 - }, - { - "lokalId": "6f70b1c4-40a8-4bce-86bf-880db4d2b02f", - "romnr": 20270, - "plasser": 2500, - "adresse": "T-banen - Carl Berners plass", - "latitude": 59.925835, - "longitude": 10.777908 - }, - { - "lokalId": "002cf5ce-3d09-4025-baa2-3a17eef88974", - "romnr": 20272, - "plasser": 3100, - "adresse": "T-banen - Enerhaugen - Åkebergveien", - "latitude": 59.913667, - "longitude": 10.769125 - }, - { - "lokalId": "1f03ff26-3174-4c15-a484-1120450f6ddb", - "romnr": 20275, - "plasser": 330, - "adresse": "Fagerheimgt 22", - "latitude": 59.931105, - "longitude": 10.770913 - }, - { - "lokalId": "a39ad6e5-ba19-4406-9c9c-c438e6d66e80", - "romnr": 20277, - "plasser": 460, - "adresse": "Filipstadveien 15", - "latitude": 59.907938, - "longitude": 10.713782 - }, - { - "lokalId": "cc2bb1a6-db8b-47b4-960e-5ad1ca3b333d", - "romnr": 20280, - "plasser": 2000, - "adresse": "T-banen - Grønland stasjon", - "latitude": 59.913539, - "longitude": 10.758469 - }, - { - "lokalId": "65c7d9a6-70e4-4212-b992-a94a587115db", - "romnr": 20282, - "plasser": 700, - "adresse": "Hegdehaugsveien 36", - "latitude": 59.923521, - "longitude": 10.7254 - }, - { - "lokalId": "6c415fe8-e7bf-492e-9564-090a03d73e86", - "romnr": 20283, - "plasser": 3000, - "adresse": "T-banen - Nationalteater stasjon", - "latitude": 59.915064, - "longitude": 10.733025 - }, - { - "lokalId": "7babca14-1734-42ae-83ab-2a91c983c56e", - "romnr": 20284, - "plasser": 750, - "adresse": "Tollbugata 40 / Nedre Vollgate 11", - "latitude": 59.911594, - "longitude": 10.738037 - }, - { - "lokalId": "2368bf86-a924-4527-8e18-2ca5419e57e7", - "romnr": 20285, - "plasser": 400, - "adresse": "T-banen - Oscarsgate 19", - "latitude": 59.922607, - "longitude": 10.726896 - }, - { - "lokalId": "18302b31-4288-4698-8179-98259df4629d", - "romnr": 20286, - "plasser": 1000, - "adresse": "Schwensensgt 3/5", - "latitude": 59.923207, - "longitude": 10.737924 - }, - { - "lokalId": "62d70710-2f9b-4d61-8cbe-c50b03e32b8b", - "romnr": 20287, - "plasser": 7000, - "adresse": "T-banen - Stortinget stasjon", - "latitude": 59.912796, - "longitude": 10.741858 - }, - { - "lokalId": "ff3edd06-7880-4411-9d3d-19e443ce7e1e", - "romnr": 20288, - "plasser": 760, - "adresse": "Thorvald Meyersgate 7", - "latitude": 59.929734, - "longitude": 10.758476 - }, - { - "lokalId": "78ecd274-838e-44a1-92cb-9effd833e1a9", - "romnr": 20289, - "plasser": 520, - "adresse": "Trondheimsveien 80", - "latitude": 59.924023, - "longitude": 10.77298 - }, - { - "lokalId": "6dd39d41-2fc1-476a-80d1-f50b88d9d883", - "romnr": 20290, - "plasser": 3340, - "adresse": "Torvet 6, Lillestrøm", - "latitude": 59.956095, - "longitude": 11.04845 - }, - { - "lokalId": "4f7ded7a-e781-4349-92d5-f1ebe0912705", - "romnr": 20292, - "plasser": 4600, - "adresse": "Tæruddalen 85, Tærudhallen", - "latitude": 60.013707, - "longitude": 11.016283 - }, - { - "lokalId": "395e3d35-c128-405d-9322-f477af3c4d8c", - "romnr": 20293, - "plasser": 4000, - "adresse": "Nordlifaret 50, Skårerhallen", - "latitude": 59.923349, - "longitude": 10.967033 - }, - { - "lokalId": "d92142c8-af77-4f22-b119-073e1cd84782", - "romnr": 20296, - "plasser": 70, - "adresse": "Storgata 11 - Offentlig", - "latitude": 60.141733, - "longitude": 11.173662 - }, - { - "lokalId": "c025a30b-5e53-4818-a429-80406140e890", - "romnr": 20300, - "plasser": 580, - "adresse": "Vigernesgt, Vigernes skole", - "latitude": 59.955269, - "longitude": 11.071618 - }, - { - "lokalId": "9fe34f8a-c589-41e4-afc7-76c2ac653619", - "romnr": 20301, - "plasser": 100, - "adresse": "Engaveien 6 - Vitåsen (Garderåsen)", - "latitude": 59.942091, - "longitude": 11.13152 - }, - { - "lokalId": "42e8885b-6373-4945-ac4b-505ccc07d5bc", - "romnr": 20302, - "plasser": 300, - "adresse": "Strømsveien 74, Gamle rådhus", - "latitude": 59.948457, - "longitude": 11.01185 - }, - { - "lokalId": "839713ba-c119-42c7-9785-2b0d94001329", - "romnr": 20665, - "plasser": 500, - "adresse": "Sveavegen 1-25 - Jessheimtunet - Offentlig", - "latitude": 60.140963, - "longitude": 11.178132 - }, - { - "lokalId": "57b4df40-a6c3-4950-aaa7-60a4deda5833", - "romnr": 20777, - "plasser": 444, - "adresse": "Bøstad skole, Vikingveien 459. Vestvågøy", - "latitude": 68.239238, - "longitude": 13.751838 - }, - { - "lokalId": "ac3f2f62-e9f5-4561-8056-1b51fd2cf344", - "romnr": 20798, - "plasser": 650, - "adresse": "Kjøpmannsgata 34", - "latitude": 63.432078, - "longitude": 10.402294 - }, - { - "lokalId": "6bff1538-2303-4604-9df7-3ac0a4d39dfb", - "romnr": 20806, - "plasser": 357, - "adresse": "Hyttegata 2 \"Bjørneparken\"", - "latitude": 59.666213, - "longitude": 9.648765 - }, - { - "lokalId": "b56e7cfa-eccc-46ba-91e2-dfeed5a41d6f", - "romnr": 20837, - "plasser": 95, - "adresse": "Eidesbråtet 1", - "latitude": 60.056192, - "longitude": 6.546907 - }, - { - "lokalId": "c0cbc75d-7dd9-40bf-9b6c-fa81393e2711", - "romnr": 20853, - "plasser": 2400, - "adresse": "Flåtestadveien 5a Fjellhallen", - "latitude": 59.773247, - "longitude": 10.803393 - }, - { - "lokalId": "066845c7-c663-449e-860c-119ec8acc03b", - "romnr": 20860, - "plasser": 2000, - "adresse": "Komsa Fjellanlegg Alta Kommune", - "latitude": 69.975548, - "longitude": 23.272672 - }, - { - "lokalId": "c55e0f8e-ba19-44e5-91fd-b3ed168e3322", - "romnr": 20901, - "plasser": 300, - "adresse": "Statlandvegen 13, Nord Statland", - "latitude": 64.4965, - "longitude": 11.142948 - }, - { - "lokalId": "b2b727e1-6292-4240-991c-717457213149", - "romnr": 21023, - "plasser": 35, - "adresse": "Jon Sunds veg 18", - "latitude": 60.888038, - "longitude": 11.521303 - }, - { - "lokalId": "928ce163-777e-4c45-be14-aea4d911c83d", - "romnr": 21024, - "plasser": 73, - "adresse": "Munkedamsvegen 5", - "latitude": 60.878592, - "longitude": 11.576904 - }, - { - "lokalId": "0fb2120a-ec91-4b12-8b8e-4e48d9e33026", - "romnr": 21025, - "plasser": 1540, - "adresse": "Torggata 1", - "latitude": 60.886425, - "longitude": 11.560943 - }, - { - "lokalId": "1d834e1b-8197-473f-8386-b059649bb76d", - "romnr": 21097, - "plasser": 100, - "adresse": "Industrivegen 17", - "latitude": 63.110504, - "longitude": 7.779923 - }, - { - "lokalId": "f6df8442-b1a3-47fc-b599-c644b0696e88", - "romnr": 21148, - "plasser": 66, - "adresse": "Håkon den godes gate 30", - "latitude": 63.745956, - "longitude": 11.299777 - }, - { - "lokalId": "af7faecc-9f35-4dc1-b01a-c850b2646071", - "romnr": 21163, - "plasser": 80, - "adresse": "Trekanten 2, Levanger", - "latitude": 63.749125, - "longitude": 11.309466 - }, - { - "lokalId": "dc369401-4996-43dc-b343-4dfeb8ebe2f3", - "romnr": 21224, - "plasser": 388, - "adresse": "Romsdalsvegen 2", - "latitude": 62.674615, - "longitude": 8.561964 - }, - { - "lokalId": "a8fe6d3f-6a6a-467f-a0e3-9df58b5e29f7", - "romnr": 21225, - "plasser": 91, - "adresse": "Ringvegen 13", - "latitude": 62.672764, - "longitude": 8.57242 - }, - { - "lokalId": "9e3c45f0-e2a6-4a7b-90bb-8a0cb568c269", - "romnr": 21257, - "plasser": 400, - "adresse": "Strandveien 6 Lysaker", - "latitude": 59.912408, - "longitude": 10.639089 - }, - { - "lokalId": "e74d4611-0fef-420f-9b70-232be3414f03", - "romnr": 21271, - "plasser": 700, - "adresse": "Tangen. Havnegata", - "latitude": 59.729705, - "longitude": 10.231377 - }, - { - "lokalId": "d2ab12bc-c3f9-4e64-b234-645d0bdba829", - "romnr": 21280, - "plasser": 450, - "adresse": "Strøket 15A (Trekanten)", - "latitude": 59.835985, - "longitude": 10.432986 - }, - { - "lokalId": "cad89e8b-bb4e-4f03-814b-883f8f52a919", - "romnr": 25512, - "plasser": 300, - "adresse": "Tangen verft, Gamle Kragerøvei 12", - "latitude": 58.875152, - "longitude": 9.416357 - }, - { - "lokalId": "5f92e392-0bc3-4b8c-99d9-ab5d03a550da", - "romnr": 30651, - "plasser": 55, - "adresse": "Ulvahaugen 2", - "latitude": 61.23349, - "longitude": 7.093615 - }, - { - "lokalId": "041fdfa4-11f8-425b-b5a9-1c1c823dba1a", - "romnr": 30653, - "plasser": 80, - "adresse": "industrivegen 24", - "latitude": 61.590674, - "longitude": 5.001506 - }, - { - "lokalId": "dd81a619-3c2b-44ff-803b-59287511ac41", - "romnr": 30654, - "plasser": 120, - "adresse": "Gate 1 110", - "latitude": 61.93475, - "longitude": 5.113378 - }, - { - "lokalId": "cb93f4f9-4fed-48ac-8263-db2a23f2038c", - "romnr": 30655, - "plasser": 197, - "adresse": "Gate 1 129", - "latitude": 61.936271, - "longitude": 5.114131 - }, - { - "lokalId": "42c2201f-93d0-4216-98e6-8129f8431a60", - "romnr": 30656, - "plasser": 155, - "adresse": "Slettevollsholten 11", - "latitude": 61.601988, - "longitude": 5.132493 - }, - { - "lokalId": "cfd9ed14-261c-4ecd-87c4-b79bb3ed4329", - "romnr": 30657, - "plasser": 111, - "adresse": "Strangata 52", - "latitude": 61.600052, - "longitude": 5.035 - }, - { - "lokalId": "f6a03a38-7b9b-4f60-8ab4-59d416fedcc4", - "romnr": 30658, - "plasser": 160, - "adresse": "Rennekleiva 2", - "latitude": 61.455152, - "longitude": 5.902546 - }, - { - "lokalId": "49987aa8-66f1-4a8a-bfe2-1512ace0a6fe", - "romnr": 30659, - "plasser": 795, - "adresse": "Liavegen 35", - "latitude": 61.449697, - "longitude": 5.836535 - }, - { - "lokalId": "32932953-f5b0-48f4-9967-946dc3ce0931", - "romnr": 30660, - "plasser": 40, - "adresse": "Flåmsdalsvegen16", - "latitude": 60.85617, - "longitude": 7.110099 - }, - { - "lokalId": "bed27b4e-9eee-44d5-8097-934670720009", - "romnr": 30667, - "plasser": 220, - "adresse": "Olav Tryggvasons gate 28", - "latitude": 63.433433, - "longitude": 10.396615 - }, - { - "lokalId": "87cf8d80-3d1a-444b-b108-e5c9f1f44015", - "romnr": 30668, - "plasser": 700, - "adresse": "Otto Svedrups plass 4 - tidl. Øvre Torv 1", - "latitude": 59.892038, - "longitude": 10.526027 - }, - { - "lokalId": "a79ee693-981f-426a-a914-76ed9e592457", - "romnr": 30671, - "plasser": 800, - "adresse": "Nedre Bakklandet 11 - 41", - "latitude": 63.428649, - "longitude": 10.403588 - }, - { - "lokalId": "33c0c24d-de5f-4706-9118-afb1b5292674", - "romnr": 30672, - "plasser": 1740, - "adresse": "Strømsveien 9 Vassøyholtet", - "latitude": 59.941052, - "longitude": 11.002169 - }, - { - "lokalId": "5025d167-cb9f-4126-8ae5-5ba10cee25f4", - "romnr": 30673, - "plasser": 500, - "adresse": "Olav Kyrres gate 6 -Revmatismebygget - St. Olavs H", - "latitude": 63.421146, - "longitude": 10.392047 - }, - { - "lokalId": "61589d1b-11c6-44a1-9eaf-ff392d6779d0", - "romnr": 30677, - "plasser": 780, - "adresse": "Østre Rosten 39 - Rostenhallen", - "latitude": 63.359645, - "longitude": 10.380829 - }, - { - "lokalId": "259b29bb-77ba-42ea-9df6-5405f232b941", - "romnr": 30678, - "plasser": 513, - "adresse": "Hasselbakken 5A og 5 C", - "latitude": 63.436822, - "longitude": 10.495529 - }, - { - "lokalId": "dc2ed73e-4802-43e9-819d-250c420845ae", - "romnr": 30679, - "plasser": 330, - "adresse": "Oppdal Helsesenter", - "latitude": 62.598982, - "longitude": 9.692609 - }, - { - "lokalId": "adbad10f-2a59-4e2d-a5bd-eeb886f6cdf5", - "romnr": 31679, - "plasser": 200, - "adresse": "T - Senteret, Offentlig T-rom", - "latitude": 63.687737, - "longitude": 9.665849 - }, - { - "lokalId": "21d80bec-acc5-48c9-bbf8-5bf913d19502", - "romnr": 31749, - "plasser": 820, - "adresse": "Paradisveien 22", - "latitude": 58.962346, - "longitude": 5.74174 - }, - { - "lokalId": "42c44dfc-20bd-4432-a134-4f9d15272a56", - "romnr": 32760, - "plasser": 1210, - "adresse": "Olav Schousvei 2-8 plan B", - "latitude": 59.930605, - "longitude": 10.781977 - }, - { - "lokalId": "5203bf2a-ff96-4ea6-842d-0f6770521dd5", - "romnr": 36834, - "plasser": 1000, - "adresse": "Skippergata/ Johan Stangsplass 1", - "latitude": 59.118183, - "longitude": 11.390087 - }, - { - "lokalId": "32c9e3e8-b9c6-466a-9eb4-b0c3bc0b51a1", - "romnr": 36835, - "plasser": 760, - "adresse": "Rødsfjellet/ Peder Ankersgt 7B", - "latitude": 59.122614, - "longitude": 11.378465 - }, - { - "lokalId": "93a0a02b-1971-4f15-988a-0b9a51e8f570", - "romnr": 36836, - "plasser": 1000, - "adresse": "Oskleiva 18", - "latitude": 59.127712, - "longitude": 11.394312 - }, - { - "lokalId": "d05a7428-3170-4da2-b2c8-e7f5893e9896", - "romnr": 36837, - "plasser": 75, - "adresse": "Marker U. skole/ Skolegata 21,Ørje", - "latitude": 59.478394, - "longitude": 11.657639 - }, - { - "lokalId": "5748270c-6c2e-478d-86ef-6fdd7cbce281", - "romnr": 36838, - "plasser": 114, - "adresse": "Nordmyrveien 4/ Spydeberg", - "latitude": 59.615456, - "longitude": 11.077031 - }, - { - "lokalId": "06499b33-6731-4e7e-8b8c-21cafbd84810", - "romnr": 36839, - "plasser": 200, - "adresse": "Allaktivitetshuset Spydeberg/ Stasjonsgata 33", - "latitude": 59.619931, - "longitude": 11.081442 - }, - { - "lokalId": "7e59664b-b94d-4a10-9d8f-f57cc3de2f96", - "romnr": 36840, - "plasser": 118, - "adresse": "Grendehusveien 2J/ Saltnes", - "latitude": 59.295256, - "longitude": 10.762224 - }, - { - "lokalId": "16985b7e-4a7c-4ba3-bdac-e7f95d43c4e9", - "romnr": 36841, - "plasser": 32, - "adresse": "Råde Herredshus/ Skråtorpveien 2A", - "latitude": 59.351645, - "longitude": 10.871347 - }, - { - "lokalId": "489dd901-54f9-45f5-b950-090519c2bd78", - "romnr": 36842, - "plasser": 2000, - "adresse": "Øreåsen/Årvollskogen 97", - "latitude": 59.419264, - "longitude": 10.696867 - }, - { - "lokalId": "13527cb7-868c-4360-8169-08f3d9472051", - "romnr": 36843, - "plasser": 50, - "adresse": "Helse og sosialsenter", - "latitude": 59.488821, - "longitude": 10.871204 - }, - { - "lokalId": "9adcfe8d-9eef-42a6-a49c-56eb647dc24a", - "romnr": 36844, - "plasser": 90, - "adresse": "Citadel/ Jernbanegata 4, Mysen", - "latitude": 59.553859, - "longitude": 11.324296 - }, - { - "lokalId": "986d43df-ad16-4236-97c2-74788aa01225", - "romnr": 36845, - "plasser": 114, - "adresse": "Opsahlveien 1, Mysen", - "latitude": 59.553145, - "longitude": 11.338877 - }, - { - "lokalId": "0df92ef5-2c1b-4aab-ac0e-272292721cee", - "romnr": 36846, - "plasser": 124, - "adresse": "Rakkestad og Degernes brannkas/ Storgt 13", - "latitude": 59.426576, - "longitude": 11.342833 - }, - { - "lokalId": "da71bbf6-ffda-4122-ae4a-6bfd29fdfa10", - "romnr": 37868, - "plasser": 100, - "adresse": "Hunstadsenteret Hunstad Øst, offentlig", - "latitude": 67.282152, - "longitude": 14.541747 - }, - { - "lokalId": "f06e4620-fe8e-4b55-bc41-a5d0b218491d", - "romnr": 37869, - "plasser": 100, - "adresse": "Jernbanevegen 7", - "latitude": 60.986082, - "longitude": 9.237485 - }, - { - "lokalId": "9177fe37-d3fd-43b6-8335-a343357c6a52", - "romnr": 37870, - "plasser": 252, - "adresse": "Hammersengvegen 62", - "latitude": 61.132025, - "longitude": 10.462942 - }, - { - "lokalId": "e077a983-2a58-4f13-ac97-17f7d8c34ec6", - "romnr": 37879, - "plasser": 120, - "adresse": "Gullfakse Barnehage - Nordbødalen 12", - "latitude": 58.473322, - "longitude": 8.78905 - }, - { - "lokalId": "82fc7ba2-b7dc-4acd-8387-776610241c24", - "romnr": 37891, - "plasser": 40, - "adresse": "Rådhuset, Leknes", - "latitude": 68.148173, - "longitude": 13.611536 - }, - { - "lokalId": "04b738d5-d442-4f93-ba21-baf6d3d92519", - "romnr": 37914, - "plasser": 288, - "adresse": "Skogbrukets hus, fagerlidal 18", - "latitude": 69.068362, - "longitude": 18.524782 - }, - { - "lokalId": "687c93b6-7df5-47cb-a046-5db4b0e4aaeb", - "romnr": 37918, - "plasser": 185, - "adresse": "Hestmyrvegen 10", - "latitude": 59.381326, - "longitude": 5.318194 - }, - { - "lokalId": "a07d59c9-b800-4213-b798-e03aedf50dd9", - "romnr": 38930, - "plasser": 856, - "adresse": "Fannestrandvegen 49 b", - "latitude": 62.739478, - "longitude": 7.182817 - }, - { - "lokalId": "4b9804e7-bd95-4334-b006-9757446c3405", - "romnr": 38931, - "plasser": 2000, - "adresse": "Kirkebakken 4A", - "latitude": 62.739064, - "longitude": 7.160301 - }, - { - "lokalId": "9950b52e-0a32-4494-a65e-88fadbeb5f67", - "romnr": 38932, - "plasser": 1000, - "adresse": "Keiser Wilhelms gate 25", - "latitude": 62.471598, - "longitude": 6.157965 - }, - { - "lokalId": "3206d3f9-9aae-42df-9126-5c1a5fe13dd6", - "romnr": 38933, - "plasser": 70, - "adresse": "Kometvegen 5", - "latitude": 62.742701, - "longitude": 7.229242 - }, - { - "lokalId": "edd5b3fd-2a57-48be-96c6-6e1ceed48ff4", - "romnr": 38934, - "plasser": 100, - "adresse": "Årøsetervegen 17", - "latitude": 62.77184, - "longitude": 7.287248 - }, - { - "lokalId": "54ffc195-6f01-4cf0-b844-59d5aa1d3b41", - "romnr": 38937, - "plasser": 150, - "adresse": "Høglivegen 8", - "latitude": 62.786868, - "longitude": 7.530679 - }, - { - "lokalId": "a93df078-e2c5-4d2e-8db6-27d39d3f8e99", - "romnr": 38938, - "plasser": 80, - "adresse": "Baklivegen 4", - "latitude": 62.787671, - "longitude": 7.537625 - }, - { - "lokalId": "7af88e63-9a81-4cd8-bbcd-5a3dd360b567", - "romnr": 38939, - "plasser": 95, - "adresse": "Hospitalgata 6 \"gamle sykehus\"", - "latitude": 70.37379, - "longitude": 31.096688 - }, - { - "lokalId": "60afe513-12f6-4538-8289-6982d3fe75cb", - "romnr": 38945, - "plasser": 70, - "adresse": "Skorgeneset 31 / Møre og Romsdal Kornsilo", - "latitude": 62.576562, - "longitude": 7.120192 - }, - { - "lokalId": "b6cf560d-7e89-4a6e-a075-da5d88f1f4c6", - "romnr": 38947, - "plasser": 48, - "adresse": "Tresfjordvegen 215", - "latitude": 62.558244, - "longitude": 7.131404 - }, - { - "lokalId": "568b397e-5d67-4a80-bbb1-cb3bc1c3b6fa", - "romnr": 38950, - "plasser": 40, - "adresse": "Skulevegen 4", - "latitude": 62.616826, - "longitude": 7.089993 - }, - { - "lokalId": "f43fd6f5-6655-45e0-9b71-5bf821b931aa", - "romnr": 38952, - "plasser": 100, - "adresse": "Brugata 10", - "latitude": 62.621344, - "longitude": 7.089782 - }, - { - "lokalId": "55f831c9-5dec-4bcc-9446-9f6613ef8bf1", - "romnr": 38954, - "plasser": 65, - "adresse": "Nybøvegen 2", - "latitude": 62.601077, - "longitude": 6.931241 - }, - { - "lokalId": "09ba909c-c20a-4ef1-8a65-6a0c77111dd0", - "romnr": 38956, - "plasser": 60, - "adresse": "Skulebakken 1", - "latitude": 62.521179, - "longitude": 7.126651 - }, - { - "lokalId": "ac5149a3-d229-4ba8-b920-c68d2bdf7132", - "romnr": 38958, - "plasser": 75, - "adresse": "Neremsvegen 4", - "latitude": 62.522356, - "longitude": 7.126866 - }, - { - "lokalId": "cf2a7b37-0768-42be-a0e2-f90790be41fc", - "romnr": 38960, - "plasser": 35, - "adresse": "Remmemsvegen 85", - "latitude": 62.610433, - "longitude": 7.098366 - }, - { - "lokalId": "5093f892-126e-4f30-ba59-7dcc6ca113b4", - "romnr": 38963, - "plasser": 50, - "adresse": "Vollan 1", - "latitude": 62.568252, - "longitude": 7.688251 - }, - { - "lokalId": "2e3d29a8-eb7e-463b-8078-b01389700251", - "romnr": 38964, - "plasser": 40, - "adresse": "Sørsidevegen 747", - "latitude": 62.501345, - "longitude": 7.565888 - }, - { - "lokalId": "61328730-f5e9-4b31-826e-216a7812df57", - "romnr": 38966, - "plasser": 481, - "adresse": "Vollan 8A", - "latitude": 62.568237, - "longitude": 7.686241 - }, - { - "lokalId": "31a3030e-6d3d-4c76-906e-392e6609c8f5", - "romnr": 38967, - "plasser": 20, - "adresse": "Prestgardsvegen 140", - "latitude": 62.906298, - "longitude": 6.920707 - }, - { - "lokalId": "6fb90bc2-0cf0-47ec-a198-b0430c91f8a4", - "romnr": 38969, - "plasser": 90, - "adresse": "Lundhaugvegen 34", - "latitude": 62.905197, - "longitude": 6.915688 - }, - { - "lokalId": "04777990-c63b-41cd-a636-e026e9f5041e", - "romnr": 38971, - "plasser": 90, - "adresse": "Torget 13A", - "latitude": 62.854445, - "longitude": 7.163943 - }, - { - "lokalId": "b087468f-1504-4b1b-a179-2bc73f672064", - "romnr": 38973, - "plasser": 55, - "adresse": "Torget 14", - "latitude": 62.854089, - "longitude": 7.164355 - }, - { - "lokalId": "478edb97-bbc5-466e-b2c2-19bda7788398", - "romnr": 38975, - "plasser": 20, - "adresse": "Malmefjordvegen 18", - "latitude": 62.83544, - "longitude": 7.235397 - }, - { - "lokalId": "b30254aa-b072-4875-b304-4f1ef6c1390c", - "romnr": 38976, - "plasser": 22, - "adresse": "Myrbostadvegen 72", - "latitude": 62.855366, - "longitude": 7.188842 - }, - { - "lokalId": "fb7be88f-773e-4fc7-b8aa-da4c9c7c7fc7", - "romnr": 38977, - "plasser": 578, - "adresse": "Brendhaugen 7", - "latitude": 62.336748, - "longitude": 5.876821 - }, - { - "lokalId": "466d2898-ec2f-426d-b97f-df3503096690", - "romnr": 38981, - "plasser": 60, - "adresse": "Stemsvegen 13", - "latitude": 62.97589, - "longitude": 7.163689 - }, - { - "lokalId": "39977567-ee17-45a5-895f-72902d0d2fb9", - "romnr": 39987, - "plasser": 210, - "adresse": "Engasveien 27 (Rørvik samfunnshus )", - "latitude": 64.863633, - "longitude": 11.242084 - }, - { - "lokalId": "d7a61629-990e-4ec4-b2c9-e228828b1ef2", - "romnr": 39990, - "plasser": 75, - "adresse": "Valderøyvegen 1129", - "latitude": 62.526601, - "longitude": 6.12256 - }, - { - "lokalId": "340fbd10-b35f-4e25-b087-d6cdd7f2db8e", - "romnr": 39993, - "plasser": 143, - "adresse": "Sloghaugvegen 3", - "latitude": 62.440436, - "longitude": 6.190999 - }, - { - "lokalId": "b9bd9144-abe9-45ea-b590-b39643c3df13", - "romnr": 39995, - "plasser": 167, - "adresse": "Molværsvegen 74", - "latitude": 62.437853, - "longitude": 6.183652 - }, - { - "lokalId": "cde9c3b8-6c04-4173-8d7a-3807e5d1645b", - "romnr": 39997, - "plasser": 83, - "adresse": "Solavågseidet 76", - "latitude": 62.427553, - "longitude": 6.315725 - }, - { - "lokalId": "21d54e53-a487-4484-88a7-79f18bae9f1b", - "romnr": 40000, - "plasser": 181, - "adresse": "Langmyra 2", - "latitude": 62.180762, - "longitude": 6.083092 - }, - { - "lokalId": "2cdb86e3-2f27-47e5-bb6e-6b2bd6dd35a9", - "romnr": 40001, - "plasser": 100, - "adresse": "Torvmyrane 5", - "latitude": 62.182863, - "longitude": 6.084963 - }, - { - "lokalId": "6c0d6313-1b3c-42b0-9f4a-175589f71ec2", - "romnr": 40002, - "plasser": 257, - "adresse": "Vikegata 17", - "latitude": 62.200506, - "longitude": 6.124657 - }, - { - "lokalId": "7233c2f2-d3ef-47ec-94b7-cdaa5ed3567b", - "romnr": 41007, - "plasser": 333, - "adresse": "Aurdalslia 30, Aurdalslia skole", - "latitude": 60.30723, - "longitude": 5.2816 - }, - { - "lokalId": "2b46db25-af44-4d29-a315-c086259018d7", - "romnr": 44298, - "plasser": 300, - "adresse": "Enggata 37", - "latitude": 60.730839, - "longitude": 10.60403 - }, - { - "lokalId": "beeee155-6084-4ccc-9c1a-3bcf0c60b352", - "romnr": 46455, - "plasser": 200, - "adresse": "Peder Hiorts gate 18 B, RØROS SKOLE", - "latitude": 62.57574, - "longitude": 11.375244 - }, - { - "lokalId": "e8c9c459-861e-4ac3-a1d7-8450384627a0", - "romnr": 46465, - "plasser": 222, - "adresse": "Jærveien 107", - "latitude": 58.840201, - "longitude": 5.723395 - }, - { - "lokalId": "d2e62169-a1e2-4c8c-8720-bd44a199b9fd", - "romnr": 48494, - "plasser": 450, - "adresse": "Mellomveien 5", - "latitude": 63.437722, - "longitude": 10.430205 - }, - { - "lokalId": "2337e2f4-2589-4901-8b0f-797519393f97", - "romnr": 48512, - "plasser": 9, - "adresse": "Melkeveien 1", - "latitude": 63.783681, - "longitude": 11.455099 - }, - { - "lokalId": "f9d58eda-ccfe-4c29-a67f-46314ad6bead", - "romnr": 48513, - "plasser": 42, - "adresse": "Magnus den godes vei 9", - "latitude": 63.78636, - "longitude": 11.469815 - }, - { - "lokalId": "4d160e7a-ae2f-42fc-af31-00e9d109bb6b", - "romnr": 48514, - "plasser": 49, - "adresse": "Hellbakkvegen 6", - "latitude": 63.774763, - "longitude": 11.746377 - }, - { - "lokalId": "da4f909e-5dd0-4225-a406-781150e8fa52", - "romnr": 48515, - "plasser": 109, - "adresse": "Gamle fergeveg 2", - "latitude": 63.790091, - "longitude": 11.469551 - }, - { - "lokalId": "44f4226d-b0a2-46b1-870b-129c8d015b41", - "romnr": 48516, - "plasser": 42, - "adresse": "Kirkegata 29", - "latitude": 63.745747, - "longitude": 11.296883 - }, - { - "lokalId": "88a06fe3-9478-469d-bb56-64b8e72622ef", - "romnr": 48518, - "plasser": 75, - "adresse": "Håkon den godes gate 17/19", - "latitude": 63.745562, - "longitude": 11.298084 - }, - { - "lokalId": "480d14e6-4166-4aa6-b055-16a283d4499a", - "romnr": 48527, - "plasser": 120, - "adresse": "Nedregårdsvegen 1", - "latitude": 62.464238, - "longitude": 6.363682 - }, - { - "lokalId": "706720c6-71c5-4567-b20a-fc265ba614c2", - "romnr": 48535, - "plasser": 30, - "adresse": "Tingvold Park hotell, Gamle Kongeveg 47", - "latitude": 64.023746, - "longitude": 11.491062 - }, - { - "lokalId": "0f331b8a-44c6-4898-9f9f-a21296b15d8e", - "romnr": 48536, - "plasser": 205, - "adresse": "Guldbergaunet, Svedjanvegen", - "latitude": 64.017505, - "longitude": 11.514335 - }, - { - "lokalId": "00de4af4-5f78-495a-b013-9fce3b9ff296", - "romnr": 48537, - "plasser": 150, - "adresse": "Nedre Sannan", - "latitude": 64.010981, - "longitude": 11.499408 - }, - { - "lokalId": "e9d71ef8-2f81-4d29-aaf2-5e6a6607309e", - "romnr": 48538, - "plasser": 70, - "adresse": "Skolegata 22", - "latitude": 64.013296, - "longitude": 11.499952 - }, - { - "lokalId": "d70a487e-b787-49ad-a996-2926394a1e99", - "romnr": 48561, - "plasser": 274, - "adresse": "Åkersgata 4/Ticogården", - "latitude": 62.67298, - "longitude": 8.565769 - }, - { - "lokalId": "cbf7edbc-f63f-4062-8c06-134b516be1f7", - "romnr": 48565, - "plasser": 108, - "adresse": "Kjøpmannsgata 15", - "latitude": 62.371386, - "longitude": 6.027618 - }, - { - "lokalId": "2cbd75a9-a131-4060-8b6c-2a2348ccb158", - "romnr": 48566, - "plasser": 58, - "adresse": "Strandgata 72-74", - "latitude": 62.370041, - "longitude": 6.028899 - }, - { - "lokalId": "4a167787-177e-4078-a27f-6dc8e341c5fe", - "romnr": 48570, - "plasser": 20, - "adresse": "Strandgata 106-114", - "latitude": 62.37351, - "longitude": 6.02943 - }, - { - "lokalId": "30dfd473-5d8e-45f2-961b-767586ee7226", - "romnr": 48571, - "plasser": 270, - "adresse": "Gymnasvegen 2", - "latitude": 62.146149, - "longitude": 6.068552 - }, - { - "lokalId": "b0bc19f5-625f-4878-936f-f6b6ace09ab5", - "romnr": 48572, - "plasser": 62, - "adresse": "Dalevegen 24", - "latitude": 62.199002, - "longitude": 6.134409 - }, - { - "lokalId": "b506c2d6-a2da-4298-b6cd-cfff216a9066", - "romnr": 48573, - "plasser": 448, - "adresse": "Sjøgata 3", - "latitude": 62.340599, - "longitude": 5.852225 - }, - { - "lokalId": "3b52a0e0-37b5-42cb-94e8-02829ead1d03", - "romnr": 48576, - "plasser": 22, - "adresse": "Spjelkavikvegen 130", - "latitude": 62.454662, - "longitude": 6.358058 - }, - { - "lokalId": "f80e14e3-5fe5-41a0-a94e-77670f8c61df", - "romnr": 48577, - "plasser": 40, - "adresse": "Skulevegen 16", - "latitude": 62.440022, - "longitude": 6.211076 - }, - { - "lokalId": "6964ab63-02db-48c8-8a21-528d707535a8", - "romnr": 48578, - "plasser": 81, - "adresse": "Valderhaug 4", - "latitude": 62.500695, - "longitude": 6.134682 - }, - { - "lokalId": "1f7ec0bc-1ef4-4e37-926b-bf2414dbaa1c", - "romnr": 48580, - "plasser": 50, - "adresse": "Morkafura 8", - "latitude": 62.171096, - "longitude": 6.038961 - }, - { - "lokalId": "51f05b0f-7065-4298-8fdb-b00543fdf871", - "romnr": 48604, - "plasser": 7000, - "adresse": "Fjellanlegg - Tromsø parkering", - "latitude": 69.650827, - "longitude": 18.954024 - }, - { - "lokalId": "f1bc391f-3be9-4323-bade-930cdaaefb65", - "romnr": 48632, - "plasser": 375, - "adresse": "Tømmeråsv. 3 - Svelvik Samfunnshus (off)", - "latitude": 59.606648, - "longitude": 10.405089 - }, - { - "lokalId": "f2ca9fd6-7c7e-483a-95ce-2e5ca480e41f", - "romnr": 48661, - "plasser": 0, - "adresse": "Mikkelsmyrveien 3B", - "latitude": 58.028221, - "longitude": 7.476917 - }, - { - "lokalId": "c3572f1d-8cd6-468c-afd7-a3bcb9a8d05f", - "romnr": 5826, - "plasser": 150, - "adresse": "Dr. Wessels gate 7", - "latitude": 69.727773, - "longitude": 30.041818 - } -] \ No newline at end of file diff --git a/pwa/public/manifest.webmanifest b/pwa/public/manifest.webmanifest index eff6a67..37b1419 100644 --- a/pwa/public/manifest.webmanifest +++ b/pwa/public/manifest.webmanifest @@ -2,20 +2,21 @@ "name": "Tilfluktsrom", "short_name": "Tilfluktsrom", "description": "Find the nearest public shelter in Norway", - "start_url": "/", + "start_url": ".", + "scope": ".", "display": "standalone", "orientation": "portrait", "theme_color": "#1A1A2E", "background_color": "#1A1A2E", "icons": [ { - "src": "/icons/icon-192.png", + "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { - "src": "/icons/icon-512.png", + "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" diff --git a/pwa/src/app.ts b/pwa/src/app.ts index e1f1038..ceceb58 100644 --- a/pwa/src/app.ts +++ b/pwa/src/app.ts @@ -9,7 +9,7 @@ import type { Shelter, ShelterWithDistance, LatLon } from './types'; import { t } from './i18n/i18n'; -import { formatDistance } from './util/distance-utils'; +import { formatDistance, distanceMeters, bearingDegrees } from './util/distance-utils'; import { findNearest } from './location/shelter-finder'; import * as repo from './data/shelter-repository'; import * as locationProvider from './location/location-provider'; @@ -20,6 +20,7 @@ import * as shelterList from './ui/shelter-list'; import * as statusBar from './ui/status-bar'; import * as loading from './ui/loading-overlay'; import * as mapCache from './cache/map-cache-manager'; +import * as civilDefenseDialog from './ui/civil-defense-dialog'; const NEAREST_COUNT = 3; @@ -36,11 +37,28 @@ let firstLocationFix = true; let userSelectedShelter = false; export async function init(): Promise { + applyA11yLabels(); setupMap(); setupCompass(); setupShelterList(); setupButtons(); await loadData(); + handleDeepLink(); +} + +/** Set localized aria-labels and wire the about button. */ +function applyA11yLabels(): void { + document.getElementById('about-btn')?.setAttribute('aria-label', t('action_civil_defense_info')); + document.getElementById('about-btn')?.addEventListener('click', () => { + navigator.vibrate?.(10); + civilDefenseDialog.showCivilDefenseInfo(); + }); + document.getElementById('map-container')?.setAttribute('aria-label', t('a11y_map')); + document.getElementById('compass-container')?.setAttribute('aria-label', t('a11y_compass')); + document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info')); + document.getElementById('shelter-list')?.setAttribute('aria-label', t('a11y_nearest_shelters')); + document.getElementById('refresh-btn')?.setAttribute('aria-label', t('action_refresh')); + document.getElementById('toggle-fab')?.setAttribute('aria-label', t('action_toggle_view')); } function setupMap(): void { @@ -76,6 +94,7 @@ function setupButtons(): void { // Toggle map/compass const toggleFab = document.getElementById('toggle-fab')!; toggleFab.addEventListener('click', async () => { + navigator.vibrate?.(10); isCompassMode = !isCompassMode; const mapContainer = document.getElementById('map-container')!; @@ -90,6 +109,7 @@ function setupButtons(): void { } mapContainer.style.display = 'none'; compassContainer.classList.add('active'); + compassView.resize(); toggleFab.textContent = '\uD83D\uDDFA\uFE0F'; // map emoji compassProvider.startCompass(onHeadingUpdate); } else { @@ -112,6 +132,7 @@ function setupButtons(): void { const cacheRetryBtn = document.getElementById('cache-retry-btn')!; cacheRetryBtn.textContent = t('action_cache_now'); cacheRetryBtn.addEventListener('click', () => { + navigator.vibrate?.(10); if (currentLocation && navigator.onLine) { startCaching(currentLocation.latitude, currentLocation.longitude); } @@ -120,6 +141,7 @@ function setupButtons(): void { // Reset view button const resetBtn = document.getElementById('reset-view-btn')!; resetBtn.addEventListener('click', () => { + navigator.vibrate?.(10); const selected = nearestShelters[selectedShelterIndex] ?? null; mapView.resetView(selected, currentLocation); resetBtn.classList.remove('visible'); @@ -256,6 +278,8 @@ function updateSelectedShelter(isUserAction: boolean): void { ].join(' \u00B7 '); // Update mini arrow + const miniArrow = document.getElementById('mini-arrow')!; + miniArrow.setAttribute('aria-label', t('direction_arrow_description', dist)); updateMiniArrow(selected.bearingDegrees - deviceHeading); // Update compass view @@ -263,6 +287,7 @@ function updateSelectedShelter(isUserAction: boolean): void { document.getElementById('compass-address')!.textContent = selected.shelter.adresse; compassView.setDirection(selected.bearingDegrees - deviceHeading); + compassView.setNorthAngle(-deviceHeading); // Update shelter list selection shelterList.updateList(nearestShelters, selectedShelterIndex); @@ -285,6 +310,7 @@ function onHeadingUpdate(heading: number): void { const angle = selected.bearingDegrees - heading; compassView.setDirection(angle); + compassView.setNorthAngle(-heading); updateMiniArrow(angle); } @@ -373,3 +399,67 @@ async function forceRefresh(): Promise { statusBar.setStatus(t('update_failed')); } } + +/** + * Handle /shelter/{lokalId} deep links. + * Called after loadData() so allShelters is populated. + */ +function handleDeepLink(): void { + const match = window.location.pathname.match(/^\/shelter\/(.+)$/); + if (!match) return; + + const lokalId = decodeURIComponent(match[1]); + + // Clean the URL so refresh doesn't re-trigger + window.history.replaceState({}, '', '/'); + + const shelter = allShelters.find((s) => s.lokalId === lokalId); + if (!shelter) { + statusBar.setStatus(t('error_shelter_not_found')); + return; + } + + selectShelterByData(shelter); +} + +/** + * Select a specific shelter, even if it's not in the current nearest-3 list. + * Used for deep link targets. + */ +function selectShelterByData(shelter: Shelter): void { + // Check if it's already in nearestShelters + const existingIdx = nearestShelters.findIndex( + (s) => s.shelter.lokalId === shelter.lokalId, + ); + + if (existingIdx >= 0) { + userSelectedShelter = true; + selectedShelterIndex = existingIdx; + } else { + // Compute distance/bearing if we have a location, otherwise use placeholder + let dist = NaN; + let bearing = 0; + if (currentLocation) { + dist = distanceMeters( + currentLocation.latitude, currentLocation.longitude, + shelter.latitude, shelter.longitude, + ); + bearing = bearingDegrees( + currentLocation.latitude, currentLocation.longitude, + shelter.latitude, shelter.longitude, + ); + } + + // Prepend to the list so it becomes the selected shelter + nearestShelters.unshift({ + shelter, + distanceMeters: dist, + bearingDegrees: bearing, + }); + userSelectedShelter = true; + selectedShelterIndex = 0; + shelterList.updateList(nearestShelters, selectedShelterIndex); + } + + updateSelectedShelter(true); +} diff --git a/pwa/src/cache/map-cache-manager.ts b/pwa/src/cache/map-cache-manager.ts index 09bb584..83217be 100644 --- a/pwa/src/cache/map-cache-manager.ts +++ b/pwa/src/cache/map-cache-manager.ts @@ -94,9 +94,10 @@ export async function cacheMapArea( // Restore original view map.setView(originalCenter, originalZoom, { animate: false }); + // Round coordinates to 1 decimal (~11km) to limit location precision in storage saveCacheMeta({ - lat, - lon, + lat: Math.round(lat * 10) / 10, + lon: Math.round(lon * 10) / 10, radius: CACHE_RADIUS_DEGREES, complete: true, }); diff --git a/pwa/src/config.ts b/pwa/src/config.ts new file mode 100644 index 0000000..a0ccb93 --- /dev/null +++ b/pwa/src/config.ts @@ -0,0 +1,2 @@ +/** Deep link domain — single source of truth for the PWA. */ +export const DEEP_LINK_DOMAIN = 'tilfluktsrom.naiv.no'; diff --git a/pwa/src/i18n/en.ts b/pwa/src/i18n/en.ts index 1a25dda..6a77154 100644 --- a/pwa/src/i18n/en.ts +++ b/pwa/src/i18n/en.ts @@ -46,4 +46,47 @@ export const en: Record = { 'No cached data available. Connect to the internet to download shelter data.', update_success: 'Shelter data updated', update_failed: 'Update failed \u2014 using cached data', + error_shelter_not_found: 'Shelter not found', + + // Accessibility + direction_arrow_description: 'Direction to shelter, %s away', + a11y_map: 'Map', + a11y_compass: 'Compass', + a11y_shelter_info: 'Shelter info', + a11y_nearest_shelters: 'Nearest shelters', + + // Civil defense + action_civil_defense_info: 'Civil defense information', + civil_defense_title: 'What to do if the alarm sounds', + civil_defense_step1_title: '1. Important message signal', + civil_defense_step1_body: 'Three series of short blasts with one minute of silence between each series. This means: seek information immediately. Turn on DAB radio, TV, or check official sources online.', + civil_defense_step2_title: '2. Air raid alarm', + civil_defense_step2_body: 'Short blasts lasting approximately one minute. This means immediate danger of attack — seek shelter now. Go to the nearest shelter, basement, or inner room immediately.', + civil_defense_step3_title: '3. Go indoors and find shelter', + civil_defense_step3_body: 'Get indoors. Close all windows, doors, and ventilation openings. Use this app to find the nearest public shelter. The compass and map work offline. If no shelter is nearby, go to a basement or an inner room away from windows.', + civil_defense_step4_title: '4. Listen to NRK on DAB radio', + civil_defense_step4_body: 'Tune in to NRK P1 on DAB radio for official updates and instructions from authorities. DAB radio works even when mobile networks and the internet are down.', + civil_defense_step5_title: '5. All clear', + civil_defense_step5_body: 'One continuous tone lasting approximately 30 seconds. The danger or attack is over. Continue to follow instructions from authorities.', + civil_defense_source: 'Source: DSB (Norwegian Directorate for Civil Protection)', + + // About + about_title: 'About Tilfluktsrom', + about_description: + 'Tilfluktsrom helps you find the nearest public shelter in Norway. The app works offline after initial setup.', + about_privacy_title: 'Privacy', + about_privacy_body: + 'This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services. Your GPS location is used only on your device to find nearby shelters and is never sent to any server.', + about_data_title: 'Data sources', + about_data_body: + 'Shelter data: DSB (Norwegian Directorate for Civil Protection), distributed via Geonorge. Map tiles: OpenStreetMap. Both are cached locally for offline use.', + about_stored_title: 'Stored on your device', + about_stored_body: + 'Shelter database (public data), map tiles for offline use, and map cache metadata. No data leaves your device except requests to download shelter data and map tiles.', + about_copyright: 'Copyright © Ole-Morten Duesund', + about_open_source: 'Open source — kode.naiv.no/olemd/tilfluktsrom', + action_about: 'About', + action_close: 'Close', + action_clear_cache: 'Clear cached data', + cache_cleared: 'All cached data cleared', }; diff --git a/pwa/src/i18n/i18n.ts b/pwa/src/i18n/i18n.ts index 129c53d..8d0376b 100644 --- a/pwa/src/i18n/i18n.ts +++ b/pwa/src/i18n/i18n.ts @@ -11,21 +11,22 @@ const locales: Record> = { en, nb, nn }; let currentLocale = 'en'; -/** Detect and set locale from browser preferences. */ +/** Detect and set locale from browser preferences, update document lang. */ export function initLocale(): void { const langs = navigator.languages ?? [navigator.language]; for (const lang of langs) { const code = lang.toLowerCase().split('-')[0]; if (code in locales) { currentLocale = code; - return; + break; } // nb and nn both start with "n" — also match "no" as Bokmål if (code === 'no') { currentLocale = 'nb'; - return; + break; } } + document.documentElement.lang = currentLocale; } /** Get current locale code. */ diff --git a/pwa/src/i18n/nb.ts b/pwa/src/i18n/nb.ts index 6aba9ae..8c285d8 100644 --- a/pwa/src/i18n/nb.ts +++ b/pwa/src/i18n/nb.ts @@ -1,4 +1,4 @@ -/** Norwegian Bokm\u00e5l strings. Ported from res/values-nb/strings.xml. */ +/** Norwegian Bokmål strings. Ported from res/values-nb/strings.xml. */ export const nb: Record = { app_name: 'Tilfluktsrom', @@ -7,38 +7,81 @@ export const nb: Record = { status_updating: 'Oppdaterer\u2026', status_offline: 'Frakoblet modus', status_shelters_loaded: '%d tilfluktsrom lastet', - status_no_location: 'Venter p\u00e5 GPS\u2026', + status_no_location: 'Venter på GPS\u2026', status_caching_map: 'Lagrer kart for frakoblet bruk\u2026', loading_shelters: 'Laster ned tilfluktsromdata\u2026', loading_map: 'Lagrer kartfliser\u2026', loading_map_explanation: - 'Forbereder frakoblet kart.\nKartet vil rulle kort for \u00e5 lagre omgivelsene dine.', - loading_first_time: 'Gj\u00f8r klar for f\u00f8rste gangs bruk\u2026', + 'Forbereder frakoblet kart.\nKartet vil rulle kort for å lagre omgivelsene dine.', + loading_first_time: 'Gjør klar for første gangs bruk\u2026', shelter_capacity: '%d plasser', shelter_room_nr: 'Rom %d', - nearest_shelter: 'N\u00e6rmeste tilfluktsrom', + nearest_shelter: 'Nærmeste tilfluktsrom', no_shelters: 'Ingen tilfluktsromdata tilgjengelig', action_refresh: 'Oppdater data', action_toggle_view: 'Bytt mellom kart og kompassvisning', action_skip: 'Hopp over', action_cache_ok: 'Lagre kart', - action_cache_now: 'Lagre n\u00e5', + action_cache_now: 'Lagre nå', warning_no_map_cache: 'Ingen frakoblet kart lagret. Kartet krever internett.', permission_location_title: 'Posisjonstillatelse kreves', permission_location_message: - 'Denne appen trenger din posisjon for \u00e5 finne n\u00e6rmeste tilfluktsrom. Vennligst gi tilgang til posisjon.', + 'Denne appen trenger din posisjon for å finne nærmeste tilfluktsrom. Vennligst gi tilgang til posisjon.', permission_denied: - 'Posisjonstillatelse avsl\u00e5tt. Appen kan ikke finne tilfluktsrom i n\u00e6rheten uten den.', + 'Posisjonstillatelse avslått. Appen kan ikke finne tilfluktsrom i nærheten uten den.', error_download_failed: 'Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.', error_no_data_offline: - 'Ingen lagrede data tilgjengelig. Koble til internett for \u00e5 laste ned tilfluktsromdata.', + 'Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.', update_success: 'Tilfluktsromdata oppdatert', - update_failed: 'Oppdatering mislyktes \u2014 bruker lagrede data', + update_failed: 'Oppdatering mislyktes — bruker lagrede data', + error_shelter_not_found: 'Fant ikke tilfluktsrommet', + + // Tilgjengelighet + direction_arrow_description: 'Retning til tilfluktsrom, %s unna', + a11y_map: 'Kart', + a11y_compass: 'Kompass', + a11y_shelter_info: 'Tilfluktsrominfo', + a11y_nearest_shelters: 'Nærmeste tilfluktsrom', + + // Sivilforsvar + action_civil_defense_info: 'Sivilforsvarsinformasjon', + civil_defense_title: 'Hva du skal gjøre hvis alarmen går', + civil_defense_step1_title: '1. Viktig melding-signalet', + civil_defense_step1_body: 'Tre serier med korte støt med ett minutts stillhet mellom hver serie. Dette betyr: søk informasjon umiddelbart. Slå på DAB-radio, TV, eller sjekk offisielle kilder på nett.', + civil_defense_step2_title: '2. Flyalarm', + civil_defense_step2_body: 'Korte støt som varer omtrent ett minutt. Dette betyr umiddelbar fare for angrep — søk dekning nå. Gå til nærmeste tilfluktsrom, kjeller eller innerrom umiddelbart.', + civil_defense_step3_title: '3. Gå innendørs og finn dekning', + civil_defense_step3_body: 'Kom deg innendørs. Lukk alle vinduer, dører og ventilasjonsåpninger. Bruk denne appen for å finne nærmeste offentlige tilfluktsrom. Kompasset og kartet fungerer uten internett. Hvis det ikke er noe tilfluktsrom i nærheten, gå til en kjeller eller et innerrom bort fra vinduer.', + civil_defense_step4_title: '4. Lytt til NRK på DAB-radio', + civil_defense_step4_body: 'Lytt til NRK P1 på DAB-radio for offisielle oppdateringer og instruksjoner fra myndighetene. DAB-radio fungerer selv når mobilnettet og internett er nede.', + civil_defense_step5_title: '5. Faren over', + civil_defense_step5_body: 'Én sammenhengende tone på omtrent 30 sekunder. Faren eller angrepet er over. Fortsett å følge instruksjoner fra myndighetene.', + civil_defense_source: 'Kilde: DSB (Direktoratet for samfunnssikkerhet og beredskap)', + + // Om + about_title: 'Om Tilfluktsrom', + about_description: + 'Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen fungerer uten internett etter første oppsett.', + about_privacy_title: 'Personvern', + about_privacy_body: + 'Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester. GPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server.', + about_data_title: 'Datakilder', + about_data_body: + 'Tilfluktsromdata: DSB (Direktoratet for samfunnssikkerhet og beredskap), distribuert via Geonorge. Kartfliser: OpenStreetMap. Begge lagres lokalt for frakoblet bruk.', + about_stored_title: 'Lagret på enheten din', + about_stored_body: + 'Tilfluktsromdatabase (offentlige data), kartfliser for frakoblet bruk og kartbuffer-metadata. Ingen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser.', + about_copyright: 'Opphavsrett © Ole-Morten Duesund', + about_open_source: 'Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom', + action_about: 'Om', + action_close: 'Lukk', + action_clear_cache: 'Slett lagrede data', + cache_cleared: 'Alle lagrede data slettet', }; diff --git a/pwa/src/i18n/nn.ts b/pwa/src/i18n/nn.ts index d66097d..eace6bd 100644 --- a/pwa/src/i18n/nn.ts +++ b/pwa/src/i18n/nn.ts @@ -5,20 +5,20 @@ export const nn: Record = { status_ready: 'Klar', status_loading: 'Lastar tilfluktsromdata\u2026', status_updating: 'Oppdaterer\u2026', - status_offline: 'Fr\u00e5kopla modus', + status_offline: 'Fråkopla modus', status_shelters_loaded: '%d tilfluktsrom lasta', - status_no_location: 'Ventar p\u00e5 GPS\u2026', - status_caching_map: 'Lagrar kart for fr\u00e5kopla bruk\u2026', + status_no_location: 'Ventar på GPS\u2026', + status_caching_map: 'Lagrar kart for fråkopla bruk\u2026', loading_shelters: 'Lastar ned tilfluktsromdata\u2026', loading_map: 'Lagrar kartfliser\u2026', loading_map_explanation: - 'F\u00f8rebur fr\u00e5kopla kart.\nKartet vil rulle kort for \u00e5 lagre omgjevnadene dine.', + 'Førebur fråkopla kart.\nKartet vil rulle kort for å lagre omgjevnadene dine.', loading_first_time: 'Gjer klar for fyrste gongs bruk\u2026', shelter_capacity: '%d plassar', shelter_room_nr: 'Rom %d', - nearest_shelter: 'N\u00e6raste tilfluktsrom', + nearest_shelter: 'Næraste tilfluktsrom', no_shelters: 'Ingen tilfluktsromdata tilgjengeleg', action_refresh: 'Oppdater data', @@ -27,18 +27,61 @@ export const nn: Record = { action_cache_ok: 'Lagre kart', action_cache_now: 'Lagre no', warning_no_map_cache: - 'Ingen fr\u00e5kopla kart lagra. Kartet krev internett.', + 'Ingen fråkopla kart lagra. Kartet krev internett.', permission_location_title: 'Posisjonsløyve krevst', permission_location_message: - 'Denne appen treng posisjonen din for \u00e5 finne n\u00e6raste tilfluktsrom. Ver venleg og gje tilgang til posisjon.', + 'Denne appen treng posisjonen din for å finne næraste tilfluktsrom. Ver venleg og gje tilgang til posisjon.', permission_denied: - 'Posisjonsløyve avsl\u00e5tt. Appen kan ikkje finne tilfluktsrom i n\u00e6rleiken utan det.', + 'Posisjonsløyve avslått. Appen kan ikkje finne tilfluktsrom i nærleiken utan det.', error_download_failed: 'Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.', error_no_data_offline: - 'Ingen lagra data tilgjengeleg. Kopla til internett for \u00e5 laste ned tilfluktsromdata.', + 'Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.', update_success: 'Tilfluktsromdata oppdatert', - update_failed: 'Oppdatering mislukkast \u2014 brukar lagra data', + update_failed: 'Oppdatering mislukkast — brukar lagra data', + error_shelter_not_found: 'Fann ikkje tilfluktsrommet', + + // Tilgjenge + direction_arrow_description: 'Retning til tilfluktsrom, %s unna', + a11y_map: 'Kart', + a11y_compass: 'Kompass', + a11y_shelter_info: 'Tilfluktsrominfo', + a11y_nearest_shelters: 'Nærmaste tilfluktsrom', + + // Sivilforsvar + action_civil_defense_info: 'Sivilforsvarsinformasjon', + civil_defense_title: 'Kva du skal gjere om alarmen går', + civil_defense_step1_title: '1. Viktig melding-signalet', + civil_defense_step1_body: 'Tre seriar med korte støyt med eitt minutt stille mellom kvar serie. Dette tyder: søk informasjon med ein gong. Slå på DAB-radio, TV, eller sjekk offisielle kjelder på nett.', + civil_defense_step2_title: '2. Flyalarm', + civil_defense_step2_body: 'Korte støyt som varar omtrent eitt minutt. Dette tyder umiddelbar fare for åtak — søk dekning no. Gå til næraste tilfluktsrom, kjellar eller innerrom med ein gong.', + civil_defense_step3_title: '3. Gå innandørs og finn dekning', + civil_defense_step3_body: 'Kom deg innandørs. Lukk alle vindauge, dører og ventilasjonsopningar. Bruk denne appen for å finne næraste offentlege tilfluktsrom. Kompasset og kartet fungerer utan internett. Om det ikkje er noko tilfluktsrom i nærleiken, gå til ein kjellar eller eit innerrom bort frå vindauge.', + civil_defense_step4_title: '4. Lytt til NRK på DAB-radio', + civil_defense_step4_body: 'Lytt til NRK P1 på DAB-radio for offisielle oppdateringar og instruksjonar frå styresmaktene. DAB-radio fungerer sjølv når mobilnettet og internett er nede.', + civil_defense_step5_title: '5. Faren over', + civil_defense_step5_body: 'Éin samanhengande tone på omtrent 30 sekund. Faren eller åtaket er over. Hald fram med å følgje instruksjonar frå styresmaktene.', + civil_defense_source: 'Kjelde: DSB (Direktoratet for samfunnstryggleik og beredskap)', + + // Om + about_title: 'Om Tilfluktsrom', + about_description: + 'Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen fungerer utan internett etter fyrste oppsett.', + about_privacy_title: 'Personvern', + about_privacy_body: + 'Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester. GPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar.', + about_data_title: 'Datakjelder', + about_data_body: + 'Tilfluktsromdata: DSB (Direktoratet for samfunnstryggleik og beredskap), distribuert via Geonorge. Kartfliser: OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk.', + about_stored_title: 'Lagra på eininga di', + about_stored_body: + 'Tilfluktsromdatabase (offentlege data), kartfliser for fråkopla bruk og kartbuffer-metadata. Ingen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser.', + about_copyright: 'Opphavsrett © Ole-Morten Duesund', + about_open_source: 'Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom', + action_about: 'Om', + action_close: 'Lukk', + action_clear_cache: 'Slett lagra data', + cache_cleared: 'Alle lagra data sletta', }; diff --git a/pwa/src/styles/main.css b/pwa/src/styles/main.css index 4623941..a7e2dfb 100644 --- a/pwa/src/styles/main.css +++ b/pwa/src/styles/main.css @@ -20,6 +20,21 @@ html, body { -webkit-text-size-adjust: 100%; } +/* --- Focus indicators for screen reader / switch access --- */ +:focus-visible { + outline: 2px solid #FF6B35; + outline-offset: 2px; +} + +/* --- Respect reduced motion preference --- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + /* --- App shell layout --- */ #app { display: flex; @@ -40,17 +55,18 @@ html, body { #status-text { flex: 1; - color: #B0BEC5; + color: #CFD8DC; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#about-btn, #refresh-btn { background: none; border: none; - color: #B0BEC5; + color: #CFD8DC; cursor: pointer; padding: 4px; font-size: 18px; @@ -58,6 +74,7 @@ html, body { flex-shrink: 0; } +#about-btn:hover, #refresh-btn:hover { color: #ECEFF1; } @@ -142,8 +159,8 @@ html, body { height: 40px; border-radius: 50%; background: #16213E; - border: 2px solid #B0BEC5; - color: #B0BEC5; + border: 2px solid #CFD8DC; + color: #CFD8DC; font-size: 18px; cursor: pointer; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); @@ -263,6 +280,18 @@ html, body { .shelter-item.selected { border-color: #FF6B35; background: rgba(255, 107, 53, 0.1); + padding-left: 20px; + position: relative; +} + +.shelter-item.selected::before { + content: '\25B6'; + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + color: #FF6B35; + font-size: 10px; } .shelter-item-address { @@ -356,7 +385,7 @@ html, body { } .leaflet-control-attribution a { - color: #B0BEC5 !important; + color: #CFD8DC !important; } .leaflet-popup-content-wrapper { @@ -370,5 +399,115 @@ html, body { } .leaflet-popup-close-button { - color: #B0BEC5 !important; + color: #CFD8DC !important; +} + +/* --- About dialog --- */ +#about-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.about-content { + background: #16213E; + border-radius: 12px; + padding: 24px; + max-width: 480px; + width: 100%; + max-height: 80vh; + overflow-y: auto; +} + +.about-heading { + color: #FF6B35; + font-size: 20px; + font-weight: bold; + margin-bottom: 12px; +} + +.about-subheading { + color: #ECEFF1; + font-size: 16px; + font-weight: bold; + margin-top: 16px; + margin-bottom: 4px; +} + +.about-para { + color: #90A4AE; + font-size: 14px; + line-height: 1.5; + margin-bottom: 8px; +} + +.about-footer { + margin-top: 20px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.about-small { + color: #90A4AE; + font-size: 11px; + margin-bottom: 2px; +} + +#civil-defense-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.about-link-btn { + display: block; + margin: 16px 0 0; + padding: 0; + background: none; + border: none; + color: #FF6B35; + font-size: 14px; + cursor: pointer; + min-height: 48px; + line-height: 48px; +} + +.about-clear-btn { + display: block; + margin: 12px 0 0; + padding: 8px 16px; + background: transparent; + border: 1px solid #90A4AE; + border-radius: 6px; + color: #90A4AE; + font-size: 13px; + cursor: pointer; +} + +.about-clear-btn:disabled { + border-color: #4a6a5a; + color: #4a6a5a; + cursor: default; +} + +.about-close-btn { + display: block; + margin: 16px auto 0; + padding: 10px 32px; + background: #FF6B35; + border: none; + border-radius: 6px; + color: #FFFFFF; + font-size: 14px; + cursor: pointer; } diff --git a/pwa/src/ui/about-dialog.ts b/pwa/src/ui/about-dialog.ts new file mode 100644 index 0000000..6788df3 --- /dev/null +++ b/pwa/src/ui/about-dialog.ts @@ -0,0 +1,122 @@ +/** + * About dialog: app info, privacy statement, data sources, copyright. + * Includes a "Clear cached data" button that removes all local storage. + * Opens as a modal overlay, same pattern as loading-overlay. + */ + +import { t } from '../i18n/i18n'; + +let overlay: HTMLDivElement | null = null; +let previousFocus: HTMLElement | null = null; + +/** Show the about dialog. */ +export function showAbout(): void { + if (overlay) return; + + previousFocus = document.activeElement as HTMLElement | null; + + overlay = document.createElement('div'); + overlay.id = 'about-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', t('about_title')); + + const content = document.createElement('div'); + content.className = 'about-content'; + + content.appendChild(heading(t('about_title'))); + content.appendChild(para(t('about_description'))); + + content.appendChild(subheading(t('about_privacy_title'))); + content.appendChild(para(t('about_privacy_body'))); + + content.appendChild(subheading(t('about_data_title'))); + content.appendChild(para(t('about_data_body'))); + + content.appendChild(subheading(t('about_stored_title'))); + content.appendChild(para(t('about_stored_body'))); + + // Clear cache button + const clearBtn = document.createElement('button'); + clearBtn.className = 'about-clear-btn'; + clearBtn.textContent = t('action_clear_cache'); + clearBtn.addEventListener('click', () => clearAllData(clearBtn)); + content.appendChild(clearBtn); + + const footer = document.createElement('div'); + footer.className = 'about-footer'; + footer.appendChild(small(t('about_copyright'))); + footer.appendChild(small(t('about_open_source'))); + content.appendChild(footer); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'about-close-btn'; + closeBtn.textContent = t('action_close'); + closeBtn.addEventListener('click', hideAbout); + content.appendChild(closeBtn); + + overlay.appendChild(content); + document.body.appendChild(overlay); + + closeBtn.focus(); +} + +/** Hide the about dialog and restore focus. */ +export function hideAbout(): void { + if (overlay) { + overlay.remove(); + overlay = null; + } + previousFocus?.focus(); + previousFocus = null; +} + +/** Clear all cached data: IndexedDB, localStorage, and service worker caches. */ +async function clearAllData(btn: HTMLButtonElement): Promise { + btn.disabled = true; + + // Clear localStorage (map cache metadata) + localStorage.clear(); + + // Clear IndexedDB (shelter database) + const dbs = await indexedDB.databases?.() ?? []; + for (const db of dbs) { + if (db.name) indexedDB.deleteDatabase(db.name); + } + + // Clear service worker caches (map tiles, precache) + const cacheNames = await caches.keys(); + for (const name of cacheNames) { + await caches.delete(name); + } + + btn.textContent = t('cache_cleared'); +} + +function heading(text: string): HTMLElement { + const el = document.createElement('h2'); + el.textContent = text; + el.className = 'about-heading'; + return el; +} + +function subheading(text: string): HTMLElement { + const el = document.createElement('h3'); + el.textContent = text; + el.className = 'about-subheading'; + return el; +} + +function para(text: string): HTMLElement { + const el = document.createElement('p'); + el.textContent = text; + el.className = 'about-para'; + return el; +} + +function small(text: string): HTMLElement { + const el = document.createElement('p'); + el.textContent = text; + el.className = 'about-small'; + return el; +} diff --git a/pwa/src/ui/civil-defense-dialog.ts b/pwa/src/ui/civil-defense-dialog.ts new file mode 100644 index 0000000..d022422 --- /dev/null +++ b/pwa/src/ui/civil-defense-dialog.ts @@ -0,0 +1,96 @@ +/** + * Civil defense info dialog: what to do when the alarm sounds. + * Same content as the Android CivilDefenseInfoDialog. + * Links to the about dialog at the bottom. + */ + +import { t } from '../i18n/i18n'; +import { showAbout } from './about-dialog'; + +let overlay: HTMLDivElement | null = null; +let previousFocus: HTMLElement | null = null; + +/** Show the civil defense info dialog. */ +export function showCivilDefenseInfo(): void { + if (overlay) return; + + previousFocus = document.activeElement as HTMLElement | null; + + overlay = document.createElement('div'); + overlay.id = 'civil-defense-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', t('civil_defense_title')); + + const content = document.createElement('div'); + content.className = 'about-content'; + + content.appendChild(heading(t('civil_defense_title'))); + + for (let i = 1; i <= 5; i++) { + content.appendChild(subheading(t(`civil_defense_step${i}_title`))); + content.appendChild(para(t(`civil_defense_step${i}_body`))); + } + + content.appendChild(small(t('civil_defense_source'))); + + // "About this app" link + const aboutLink = document.createElement('button'); + aboutLink.className = 'about-link-btn'; + aboutLink.textContent = t('action_about'); + aboutLink.addEventListener('click', () => { + hideCivilDefenseInfo(); + showAbout(); + }); + content.appendChild(aboutLink); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'about-close-btn'; + closeBtn.textContent = t('action_close'); + closeBtn.addEventListener('click', hideCivilDefenseInfo); + content.appendChild(closeBtn); + + overlay.appendChild(content); + document.body.appendChild(overlay); + + closeBtn.focus(); +} + +/** Hide the dialog and restore focus. */ +export function hideCivilDefenseInfo(): void { + if (overlay) { + overlay.remove(); + overlay = null; + } + previousFocus?.focus(); + previousFocus = null; +} + +function heading(text: string): HTMLElement { + const el = document.createElement('h2'); + el.textContent = text; + el.className = 'about-heading'; + return el; +} + +function subheading(text: string): HTMLElement { + const el = document.createElement('h3'); + el.textContent = text; + el.className = 'about-subheading'; + return el; +} + +function para(text: string): HTMLElement { + const el = document.createElement('p'); + el.textContent = text; + el.className = 'about-para'; + return el; +} + +function small(text: string): HTMLElement { + const el = document.createElement('p'); + el.textContent = text; + el.className = 'about-small'; + el.style.fontStyle = 'italic'; + return el; +} diff --git a/pwa/src/ui/compass-view.ts b/pwa/src/ui/compass-view.ts index accbbee..e0945a1 100644 --- a/pwa/src/ui/compass-view.ts +++ b/pwa/src/ui/compass-view.ts @@ -3,15 +3,20 @@ * Ported from DirectionArrowView.kt — same 7-point arrow polygon. * * Arrow rotation = shelterBearing - deviceHeading + * + * Also draws a discrete north indicator on the perimeter so users can + * validate compass calibration against a known direction. */ const ARROW_COLOR = '#FF6B35'; const OUTLINE_COLOR = '#FFFFFF'; const OUTLINE_WIDTH = 4; +const NORTH_COLOR = 'rgba(207, 216, 220, 0.6)'; // text_secondary at ~60% let canvas: HTMLCanvasElement | null = null; let ctx: CanvasRenderingContext2D | null = null; let currentAngle = 0; +let northAngle: number | null = null; let animFrameId = 0; /** Initialize the compass canvas inside the given container element. */ @@ -20,6 +25,7 @@ export function initCompass(container: HTMLElement): void { canvas.id = 'compass-canvas'; canvas.style.width = '100%'; canvas.style.height = '100%'; + canvas.setAttribute('aria-hidden', 'true'); container.prepend(canvas); resizeCanvas(); window.addEventListener('resize', resizeCanvas); @@ -43,6 +49,14 @@ export function setDirection(degrees: number): void { animFrameId = requestAnimationFrame(draw); } +/** + * Set the angle to north in the view's coordinate space. + * Typically -deviceHeading. Set to null to hide. + */ +export function setNorthAngle(degrees: number): void { + northAngle = degrees; +} + function draw(): void { if (!canvas) return; ctx = canvas.getContext('2d'); @@ -55,6 +69,12 @@ function draw(): void { const size = Math.min(w, h) * 0.4; ctx.clearRect(0, 0, w, h); + + // Draw north indicator behind the main arrow + if (northAngle !== null) { + drawNorthIndicator(ctx, cx, cy, size); + } + ctx.save(); ctx.translate(cx, cy); ctx.rotate((currentAngle * Math.PI) / 180); @@ -79,6 +99,47 @@ function draw(): void { ctx.restore(); } +/** Small triangle and "N" label on the perimeter, pointing inward. */ +function drawNorthIndicator( + c: CanvasRenderingContext2D, + cx: number, + cy: number, + arrowSize: number, +): void { + if (northAngle === null) return; + + const radius = arrowSize * 1.35; + const tickSize = arrowSize * 0.1; + const rad = (northAngle * Math.PI) / 180; + + c.save(); + c.translate(cx, cy); + c.rotate(rad); + + // Small triangle + c.beginPath(); + c.moveTo(0, -radius); + c.lineTo(-tickSize, -radius - tickSize * 1.8); + c.lineTo(tickSize, -radius - tickSize * 1.8); + c.closePath(); + c.fillStyle = NORTH_COLOR; + c.fill(); + + // "N" label + c.font = `${arrowSize * 0.18}px -apple-system, sans-serif`; + c.fillStyle = NORTH_COLOR; + c.textAlign = 'center'; + c.textBaseline = 'bottom'; + c.fillText('N', 0, -radius - tickSize * 2.2); + + c.restore(); +} + +/** Resize the canvas (call when the compass container becomes visible). */ +export function resize(): void { + resizeCanvas(); +} + /** Clean up compass resources. */ export function destroyCompass(): void { window.removeEventListener('resize', resizeCanvas); diff --git a/pwa/src/ui/loading-overlay.ts b/pwa/src/ui/loading-overlay.ts index 4db5908..cafba89 100644 --- a/pwa/src/ui/loading-overlay.ts +++ b/pwa/src/ui/loading-overlay.ts @@ -1,8 +1,16 @@ /** * Loading overlay: spinner + message + OK/Skip buttons. * Same flow as Android: prompt before map caching, user can skip. + * + * Accessibility: the overlay is a modal dialog (role="dialog", aria-modal). + * Focus is moved into the dialog when shown and restored when hidden. */ +import { t } from '../i18n/i18n'; + +/** Element that had focus before the overlay opened. */ +let previousFocus: HTMLElement | null = null; + /** Show the loading overlay with a message and optional spinner. */ export function showLoading(message: string, showSpinner = true): void { const overlay = document.getElementById('loading-overlay')!; @@ -10,10 +18,13 @@ export function showLoading(message: string, showSpinner = true): void { const spinner = document.getElementById('loading-spinner')!; const buttonRow = document.getElementById('loading-button-row')!; + previousFocus = document.activeElement as HTMLElement | null; text.textContent = message; + overlay.setAttribute('aria-label', message); spinner.style.display = showSpinner ? 'block' : 'none'; buttonRow.style.display = 'none'; overlay.style.display = 'flex'; + text.focus(); } /** Show the cache prompt (OK / Skip buttons, no spinner). */ @@ -29,11 +40,16 @@ export function showCachePrompt( const okBtn = document.getElementById('loading-ok-btn')!; const skipBtn = document.getElementById('loading-skip-btn')!; + previousFocus = document.activeElement as HTMLElement | null; text.textContent = message; + overlay.setAttribute('aria-label', message); spinner.style.display = 'none'; buttonRow.style.display = 'flex'; overlay.style.display = 'flex'; + okBtn.textContent = t('action_cache_ok'); + skipBtn.textContent = t('action_skip'); + okBtn.onclick = () => { hideLoading(); onOk(); @@ -42,6 +58,8 @@ export function showCachePrompt( hideLoading(); onSkip(); }; + + okBtn.focus(); } /** Update loading text (e.g. progress). */ @@ -50,8 +68,10 @@ export function updateLoadingText(message: string): void { if (text) text.textContent = message; } -/** Hide the loading overlay. */ +/** Hide the loading overlay and restore focus. */ export function hideLoading(): void { const overlay = document.getElementById('loading-overlay'); if (overlay) overlay.style.display = 'none'; + previousFocus?.focus(); + previousFocus = null; } diff --git a/pwa/src/ui/map-view.ts b/pwa/src/ui/map-view.ts index 52ee6ba..a7d99e1 100644 --- a/pwa/src/ui/map-view.ts +++ b/pwa/src/ui/map-view.ts @@ -6,16 +6,19 @@ */ import L from 'leaflet'; +import markerIcon from 'leaflet/dist/images/marker-icon.png'; +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import type { Shelter, ShelterWithDistance, LatLon } from '../types'; import { t } from '../i18n/i18n'; -// Fix Leaflet default icon paths (broken by bundlers) +// Fix Leaflet default icon paths (broken by bundlers) — use bundled assets // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (L.Icon.Default.prototype as any)._getIconUrl; L.Icon.Default.mergeOptions({ - iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', - iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', - shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', + iconUrl: markerIcon, + iconRetinaUrl: markerIcon2x, + shadowUrl: markerShadow, }); const DEFAULT_ZOOM = 14; diff --git a/pwa/src/ui/shelter-list.ts b/pwa/src/ui/shelter-list.ts index b90755a..ce9f0cf 100644 --- a/pwa/src/ui/shelter-list.ts +++ b/pwa/src/ui/shelter-list.ts @@ -34,8 +34,19 @@ export function updateList( } shelters.forEach((swd, i) => { + const isSelected = i === selectedIndex; const item = document.createElement('button'); - item.className = `shelter-item${i === selectedIndex ? ' selected' : ''}`; + item.className = `shelter-item${isSelected ? ' selected' : ''}`; + item.role = 'listitem'; + if (isSelected) item.setAttribute('aria-current', 'true'); + + const details = [ + formatDistance(swd.distanceMeters), + t('shelter_capacity', swd.shelter.plasser), + t('shelter_room_nr', swd.shelter.romnr), + ].join(' \u00B7 '); + + item.setAttribute('aria-label', `${swd.shelter.adresse}, ${details}`); const addressSpan = document.createElement('span'); addressSpan.className = 'shelter-item-address'; @@ -43,15 +54,12 @@ export function updateList( const detailsSpan = document.createElement('span'); detailsSpan.className = 'shelter-item-details'; - detailsSpan.textContent = [ - formatDistance(swd.distanceMeters), - t('shelter_capacity', swd.shelter.plasser), - t('shelter_room_nr', swd.shelter.romnr), - ].join(' \u00B7 '); + detailsSpan.textContent = details; item.appendChild(addressSpan); item.appendChild(detailsSpan); item.addEventListener('click', () => { + navigator.vibrate?.(10); onSelect?.(i); }); container!.appendChild(item); diff --git a/pwa/src/ui/status-bar.ts b/pwa/src/ui/status-bar.ts index 84dd857..d4ab54b 100644 --- a/pwa/src/ui/status-bar.ts +++ b/pwa/src/ui/status-bar.ts @@ -11,5 +11,8 @@ export function setStatus(text: string): void { /** Set the refresh button click handler. */ export function onRefreshClick(handler: () => void): void { const btn = document.getElementById('refresh-btn'); - if (btn) btn.addEventListener('click', handler); + if (btn) btn.addEventListener('click', () => { + navigator.vibrate?.(10); + handler(); + }); } diff --git a/pwa/vite.config.ts b/pwa/vite.config.ts index 2c345b4..4df550a 100644 --- a/pwa/vite.config.ts +++ b/pwa/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig({ + base: '/', define: { // Injected as a global — changes every build, breaking any stale cache __BUILD_REVISION__: JSON.stringify( @@ -21,7 +22,7 @@ export default defineConfig({ cleanupOutdatedCaches: true, // SPA: serve index.html for all navigation requests - navigateFallback: '/index.html', + navigateFallback: 'index.html', // Vite already hashes JS/CSS filenames — skip Workbox's // cache-bust query parameter for those @@ -39,7 +40,7 @@ export default defineConfig({ maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days }, cacheableResponse: { - statuses: [0, 200], + statuses: [200], }, }, }, diff --git a/version.properties b/version.properties deleted file mode 100644 index 4536802..0000000 --- a/version.properties +++ /dev/null @@ -1,4 +0,0 @@ -versionMajor=1 -versionMinor=2 -versionPatch=0 -versionCode=3