Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22fad9e1db | |||
| 029cfa45f9 | |||
| 015bc0d926 | |||
| ae249d5d47 | |||
| 7d40c9e9a8 | |||
| 012da23628 | |||
| 85c3d6953c | |||
| 8e6dbb6b24 | |||
| 4a95e0e23f | |||
| 97225d1e77 | |||
| a829742fe6 | |||
| 73937c163d | |||
| b34408bc7b | |||
| c1ac68e746 | |||
| 6ba35add2f | |||
| f9f8ac3d60 | |||
| f5c064ab92 | |||
| 8e684f868e | |||
| 9a00f07362 |
455
ARCHITECTURE.md
Normal file
|
|
@ -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.
|
||||
56
CLAUDE.md
|
|
@ -53,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 <longitude> <latitude>` (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`
|
||||
|
|
|
|||
29
README.md
|
|
@ -3,30 +3,34 @@
|
|||
Finn nærmeste offentlige tilfluktsrom i Norge. Appen er bygd for nødsituasjoner og fungerer uten internett etter første gangs bruk.
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png" width="200" alt="Kartvisning med tilfluktsrom i Bergen sentrum" />
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png" width="200" alt="Valgt tilfluktsrom med avstand og kapasitet" />
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png" width="200" alt="Kompassnavigasjon mot tilfluktsrom" />
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png" width="200" alt="Sivilforsvarsinfo: hva du skal gjøre om alarmen går" />
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png" width="180" alt="Kartvisning med tilfluktsrom i Bergen sentrum" />
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png" width="180" alt="Valgt tilfluktsrom med avstand og retningspil" />
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png" width="180" alt="Kompassnavigasjon med nordindikator" />
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png" width="180" alt="Sivilforsvarsinfo: hva du skal gjøre om alarmen går" />
|
||||
<img src="fastlane/metadata/android/nb-NO/images/phoneScreenshots/5_about.png" width="180" alt="Om-side med personvernerklæring" />
|
||||
</p>
|
||||
|
||||
## 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. Fungerer uten internett — bare GPS og kompassensor.
|
||||
**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 med avstand og kapasitet
|
||||
- **Kompassnavigasjon** — retningspil som peker mot valgt tilfluktsrom
|
||||
- **Kompassnavigasjon** — retningspil som peker mot valgt tilfluktsrom, med nordindikator
|
||||
- **Frakoblet kart** — kartfliser lagres automatisk for bruk uten nett
|
||||
- **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
|
||||
|
||||
|
|
@ -52,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).
|
||||
|
||||
|
|
@ -65,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)
|
||||
|
|
@ -90,14 +95,16 @@ 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 persondata og har ingen analyse- eller sporingstjenester. Posisjonsdata brukes bare lokalt på enheten for å finne nærmeste tilfluktsrom. Se [PRIVACY.md](PRIVACY.md) for fullstendig personvernerklæring.
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,13 +14,18 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -211,9 +211,12 @@ class ShelterWidgetProvider : AppWidgetProvider() {
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -69,9 +69,12 @@ class WidgetUpdateWorker(
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -29,13 +29,14 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="tilfluktsrom"
|
||||
android:host="shelter" />
|
||||
android:scheme="https"
|
||||
android:host="${deepLinkHost}"
|
||||
android:pathPrefix="/shelter/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
|
|
|||
|
|
@ -143,12 +143,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle tilfluktsrom://shelter/{lokalId} deep link.
|
||||
* Handle https://{domain}/shelter/{lokalId} deep link.
|
||||
* If shelters are already loaded, select immediately; otherwise store as pending.
|
||||
*/
|
||||
private fun handleDeepLinkIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
if (uri.scheme != "tilfluktsrom" || uri.host != "shelter") return
|
||||
if (uri.scheme != "https" ||
|
||||
uri.host != BuildConfig.DEEP_LINK_DOMAIN ||
|
||||
uri.path?.startsWith("/shelter/") != true) return
|
||||
|
||||
val lokalId = uri.lastPathSegment ?: return
|
||||
// Clear intent data so config changes don't re-trigger
|
||||
|
|
@ -522,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
|
||||
)
|
||||
|
|
@ -668,8 +671,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
|
||||
/**
|
||||
* Share the currently selected shelter via ACTION_SEND.
|
||||
* Includes address, capacity, geo: URI (for non-app recipients),
|
||||
* and a tilfluktsrom:// deep link (for app users).
|
||||
* 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
|
||||
|
|
@ -679,12 +682,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
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
|
||||
shelter.longitude,
|
||||
deepLink
|
||||
)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
|
|
@ -840,6 +845,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
val arrowAngle = bearing - deviceHeading
|
||||
|
||||
binding.directionArrow.setDirection(arrowAngle)
|
||||
binding.directionArrow.setNorthAngle(-deviceHeading)
|
||||
binding.miniArrow.setDirection(arrowAngle)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
43
app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt
Normal file
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package no.naiv.tilfluktsrom.ui
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
|
@ -32,6 +31,13 @@ class CivilDefenseInfoDialog : DialogFragment() {
|
|||
return inflater.inflate(R.layout.dialog_civil_defense, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.findViewById<View>(R.id.aboutLink)?.setOnClickListener {
|
||||
AboutDialog().show(parentFragmentManager, AboutDialog.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.window?.apply {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,14 +31,15 @@
|
|||
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" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/infoButton"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_civil_defense_info"
|
||||
android:src="@drawable/ic_info"
|
||||
|
|
@ -46,8 +47,8 @@
|
|||
|
||||
<ImageButton
|
||||
android:id="@+id/refreshButton"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_refresh"
|
||||
android:src="@drawable/ic_refresh"
|
||||
|
|
@ -71,6 +72,7 @@
|
|||
android:id="@+id/mapView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:contentDescription="@string/a11y_map"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomSheet" />
|
||||
|
||||
|
|
@ -80,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">
|
||||
|
|
@ -223,8 +226,8 @@
|
|||
|
||||
<ImageButton
|
||||
android:id="@+id/shareButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_share"
|
||||
|
|
@ -249,6 +252,8 @@
|
|||
android:background="@color/loading_bg"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:importantForAccessibility="yes"
|
||||
android:accessibilityLiveRegion="assertive"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
|
|
@ -262,6 +267,7 @@
|
|||
android:id="@+id/loadingProgress"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:contentDescription="@string/status_loading"
|
||||
android:indeterminate="true" />
|
||||
|
||||
<TextView
|
||||
|
|
|
|||
104
app/src/main/res/layout/dialog_about.xml
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/status_bar_bg"
|
||||
android:padding="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Title -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/about_title"
|
||||
android:textColor="@color/shelter_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- App description -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/about_description"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<!-- Privacy section -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/about_privacy_title"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/about_privacy_body"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<!-- Data sources section -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/about_data_title"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/about_data_body"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<!-- Stored on device section -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/about_stored_title"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="@string/about_stored_body"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<!-- Copyright -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_copyright"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<!-- Open source -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/about_open_source"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="11sp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
|
@ -114,14 +114,16 @@
|
|||
android:textSize="12sp"
|
||||
android:textStyle="italic" />
|
||||
|
||||
<!-- Copyright notice -->
|
||||
<!-- About link -->
|
||||
<TextView
|
||||
android:id="@+id/aboutLink"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/app_copyright"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="11sp" />
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:text="@string/action_about"
|
||||
android:textColor="@color/shelter_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -65,9 +65,10 @@
|
|||
|
||||
<ImageView
|
||||
android:id="@+id/widgetRefreshButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:padding="8dp"
|
||||
android:contentDescription="@string/action_refresh"
|
||||
android:src="@drawable/ic_refresh" />
|
||||
</LinearLayout>
|
||||
|
|
|
|||
|
|
@ -60,13 +60,15 @@
|
|||
|
||||
<!-- Deling -->
|
||||
<string name="share_subject">Tilfluktsrom</string>
|
||||
<string name="share_body">Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f</string>
|
||||
<string name="share_body">Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s</string>
|
||||
<string name="share_no_shelter">Ingen tilfluktsrom valgt</string>
|
||||
|
||||
<!-- Tilgjengelighet -->
|
||||
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
|
||||
<string name="content_desc_shelter_item">%1$s, %2$s, %3$d plasser</string>
|
||||
<string name="compass_accuracy_warning">Upresist kompass - %s</string>
|
||||
<string name="a11y_map">Tilfluktsromkart</string>
|
||||
<string name="a11y_compass">Kompassnavigasjon</string>
|
||||
|
||||
<!-- Sivilforsvar -->
|
||||
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
||||
|
|
@ -83,6 +85,18 @@
|
|||
<string name="civil_defense_step5_body">Én sammenhengende tone på omtrent 30 sekunder. Faren eller angrepet er over. Fortsett å følge instruksjoner fra myndighetene.</string>
|
||||
<string name="civil_defense_source">Kilde: DSB (Direktoratet for samfunnssikkerhet og beredskap)</string>
|
||||
|
||||
<!-- Om -->
|
||||
<string name="about_title">Om Tilfluktsrom</string>
|
||||
<string name="about_description">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.</string>
|
||||
<string name="about_privacy_title">Personvern</string>
|
||||
<string name="about_privacy_body">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.</string>
|
||||
<string name="about_data_title">Datakilder</string>
|
||||
<string name="about_data_body">Tilfluktsromdata er offentlig informasjon fra DSB (Direktoratet for samfunnssikkerhet og beredskap), distribuert via Geonorge. Kartfliser lastes fra OpenStreetMap. Begge lagres lokalt for frakoblet bruk.</string>
|
||||
<string name="about_stored_title">Lagret på enheten din</string>
|
||||
<string name="about_stored_body">• 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.</string>
|
||||
<string name="about_open_source">Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom</string>
|
||||
<string name="action_about">Om denne appen</string>
|
||||
|
||||
<!-- Opphavsrett -->
|
||||
<string name="app_copyright">Opphavsrett © Ole-Morten Duesund</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -60,13 +60,15 @@
|
|||
|
||||
<!-- Deling -->
|
||||
<string name="share_subject">Tilfluktsrom</string>
|
||||
<string name="share_body">Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f</string>
|
||||
<string name="share_body">Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s</string>
|
||||
<string name="share_no_shelter">Ingen tilfluktsrom valt</string>
|
||||
|
||||
<!-- Tilgjenge -->
|
||||
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
|
||||
<string name="content_desc_shelter_item">%1$s, %2$s, %3$d plassar</string>
|
||||
<string name="compass_accuracy_warning">Upresis kompass - %s</string>
|
||||
<string name="a11y_map">Tilfluktsromkart</string>
|
||||
<string name="a11y_compass">Kompassnavigasjon</string>
|
||||
|
||||
<!-- Sivilforsvar -->
|
||||
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
||||
|
|
@ -83,6 +85,18 @@
|
|||
<string name="civil_defense_step5_body">Éin samanhengande tone på omtrent 30 sekund. Faren eller åtaket er over. Hald fram med å følgje instruksjonar frå styresmaktene.</string>
|
||||
<string name="civil_defense_source">Kjelde: DSB (Direktoratet for samfunnstryggleik og beredskap)</string>
|
||||
|
||||
<!-- Om -->
|
||||
<string name="about_title">Om Tilfluktsrom</string>
|
||||
<string name="about_description">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.</string>
|
||||
<string name="about_privacy_title">Personvern</string>
|
||||
<string name="about_privacy_body">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.</string>
|
||||
<string name="about_data_title">Datakjelder</string>
|
||||
<string name="about_data_body">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.</string>
|
||||
<string name="about_stored_title">Lagra på eininga di</string>
|
||||
<string name="about_stored_body">• 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.</string>
|
||||
<string name="about_open_source">Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom</string>
|
||||
<string name="action_about">Om denne appen</string>
|
||||
|
||||
<!-- Opphavsrett -->
|
||||
<string name="app_copyright">Opphavsrett © Ole-Morten Duesund</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -60,13 +60,15 @@
|
|||
|
||||
<!-- Sharing -->
|
||||
<string name="share_subject">Emergency shelter</string>
|
||||
<string name="share_body">Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f</string>
|
||||
<string name="share_body">Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s</string>
|
||||
<string name="share_no_shelter">No shelter selected</string>
|
||||
|
||||
<!-- Accessibility -->
|
||||
<string name="direction_arrow_description">Direction to shelter, %s away</string>
|
||||
<string name="content_desc_shelter_item">%1$s, %2$s, %3$d places</string>
|
||||
<string name="compass_accuracy_warning">Low accuracy - %s</string>
|
||||
<string name="a11y_map">Shelter map</string>
|
||||
<string name="a11y_compass">Compass navigation</string>
|
||||
|
||||
<!-- Civil defense info -->
|
||||
<string name="action_civil_defense_info">Civil defense information</string>
|
||||
|
|
@ -83,6 +85,18 @@
|
|||
<string name="civil_defense_step5_body">One continuous tone lasting approximately 30 seconds. The danger or attack is over. Continue to follow instructions from authorities.</string>
|
||||
<string name="civil_defense_source">Source: DSB (Norwegian Directorate for Civil Protection)</string>
|
||||
|
||||
<!-- About -->
|
||||
<string name="about_title">About Tilfluktsrom</string>
|
||||
<string name="about_description">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.</string>
|
||||
<string name="about_privacy_title">Privacy</string>
|
||||
<string name="about_privacy_body">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.</string>
|
||||
<string name="about_data_title">Data sources</string>
|
||||
<string name="about_data_body">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.</string>
|
||||
<string name="about_stored_title">Stored on your device</string>
|
||||
<string name="about_stored_body">• 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.</string>
|
||||
<string name="about_open_source">Open source — kode.naiv.no/olemd/tilfluktsrom</string>
|
||||
<string name="action_about">About this app</string>
|
||||
|
||||
<!-- Copyright -->
|
||||
<string name="app_copyright">Copyright © Ole-Morten Duesund</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -252,10 +252,13 @@ class ShelterWidgetProvider : AppWidgetProvider() {
|
|||
return getSavedLocation(context)
|
||||
}
|
||||
|
||||
/** Read the last GPS fix persisted by MainActivity to SharedPreferences. */
|
||||
/** 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()
|
||||
|
|
|
|||
|
|
@ -84,10 +84,13 @@ class WidgetUpdateWorker(
|
|||
return Result.success()
|
||||
}
|
||||
|
||||
/** Read the last GPS fix persisted by MainActivity. */
|
||||
/** 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()
|
||||
|
|
|
|||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
|
@ -1 +1 @@
|
|||
Find the nearest public emergency shelter in Norway. Works offline.
|
||||
Find the nearest public emergency shelter in Norway — works offline
|
||||
|
|
|
|||
BIN
fastlane/metadata/android/nb-NO/images/icon.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
|
@ -1 +1 @@
|
|||
Finn nærmeste offentlige tilfluktsrom i Norge. Fungerer uten nett.
|
||||
Finn nærmeste offentlige tilfluktsrom i Norge — fungerer uten nett
|
||||
|
|
|
|||
BIN
fastlane/metadata/android/nn-NO/images/icon.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 417 KiB After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
|
@ -1 +1 @@
|
|||
Finn næraste offentlege tilfluktsrom i Noreg. Fungerer utan nett.
|
||||
Finn næraste offentlege tilfluktsrom i Noreg — fungerer utan nett
|
||||
|
|
|
|||
1
pwa/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
public/data/shelters.json
|
||||
|
|
|
|||
|
|
@ -1,68 +1,68 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1A1A2E" />
|
||||
<meta name="description" content="Find the nearest public shelter in Norway" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' https://*.tile.openstreetmap.org; font-src 'self'; worker-src 'self'" />
|
||||
<title>Tilfluktsrom</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="icons/icon-192.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Status bar -->
|
||||
<div id="status-bar">
|
||||
<span id="status-text"></span>
|
||||
<header id="status-bar" role="banner">
|
||||
<span id="status-text" aria-live="polite"></span>
|
||||
<button id="about-btn" aria-label="About">ℹ</button>
|
||||
<button id="refresh-btn" aria-label="Refresh data">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content: map or compass -->
|
||||
<div id="main-content">
|
||||
<div id="map-container"></div>
|
||||
<div id="compass-container">
|
||||
<main id="main-content">
|
||||
<div id="map-container" role="application" aria-label="Map"></div>
|
||||
<div id="compass-container" role="img" aria-label="Compass">
|
||||
<span id="compass-address"></span>
|
||||
<span id="compass-distance"></span>
|
||||
<span id="compass-distance" aria-live="polite"></span>
|
||||
</div>
|
||||
<!-- Toggle map/compass FAB -->
|
||||
<button id="toggle-fab" aria-label="Toggle map/compass view">🧭</button>
|
||||
<!-- Reset view button -->
|
||||
<button id="reset-view-btn" aria-label="Reset view">⌖</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- No-cache warning banner -->
|
||||
<div id="no-cache-banner">
|
||||
<div id="no-cache-banner" role="alert">
|
||||
<span id="no-cache-text"></span>
|
||||
<button id="cache-retry-btn"></button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet with shelter info -->
|
||||
<div id="bottom-sheet">
|
||||
<div id="selected-shelter">
|
||||
<aside id="bottom-sheet" aria-label="Shelter info">
|
||||
<div id="selected-shelter" aria-live="polite">
|
||||
<canvas id="mini-arrow" width="96" height="96" role="img" aria-label="Direction to shelter"></canvas>
|
||||
<div id="selected-shelter-info">
|
||||
<div id="selected-shelter-address"></div>
|
||||
<div id="selected-shelter-details"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="shelter-list"></div>
|
||||
</div>
|
||||
<div id="shelter-list" role="list" aria-label="Nearest shelters"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div id="loading-overlay">
|
||||
<div id="loading-spinner"></div>
|
||||
<div id="loading-text"></div>
|
||||
<div id="loading-overlay" role="dialog" aria-modal="true" aria-label="Loading">
|
||||
<div id="loading-spinner" aria-hidden="true"></div>
|
||||
<div id="loading-text" aria-live="assertive" tabindex="-1"></div>
|
||||
<div id="loading-button-row">
|
||||
<button id="loading-skip-btn"></button>
|
||||
<button id="loading-ok-btn"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
12
pwa/public/.well-known/assetlinks.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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 {
|
||||
|
|
@ -91,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 {
|
||||
|
|
@ -268,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);
|
||||
|
|
@ -290,6 +310,7 @@ function onHeadingUpdate(heading: number): void {
|
|||
const angle = selected.bearingDegrees - heading;
|
||||
|
||||
compassView.setDirection(angle);
|
||||
compassView.setNorthAngle(-heading);
|
||||
updateMiniArrow(angle);
|
||||
}
|
||||
|
||||
|
|
@ -378,3 +399,67 @@ async function forceRefresh(): Promise<void> {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
5
pwa/src/cache/map-cache-manager.ts
vendored
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
2
pwa/src/config.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** Deep link domain — single source of truth for the PWA. */
|
||||
export const DEEP_LINK_DOMAIN = 'tilfluktsrom.naiv.no';
|
||||
|
|
@ -46,7 +46,47 @@ export const en: Record<string, string> = {
|
|||
'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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,21 +11,22 @@ const locales: Record<string, Record<string, string>> = { 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. */
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
app_name: 'Tilfluktsrom',
|
||||
|
||||
|
|
@ -7,41 +7,81 @@ export const nb: Record<string, string> = {
|
|||
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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,20 +5,20 @@ export const nn: Record<string, string> = {
|
|||
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,21 +27,61 @@ export const nn: Record<string, string> = {
|
|||
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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -47,6 +62,7 @@ html, body {
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#about-btn,
|
||||
#refresh-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -58,6 +74,7 @@ html, body {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#about-btn:hover,
|
||||
#refresh-btn:hover {
|
||||
color: #ECEFF1;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -372,3 +401,113 @@ html, body {
|
|||
.leaflet-popup-close-button {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
122
pwa/src/ui/about-dialog.ts
Normal file
|
|
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
96
pwa/src/ui/civil-defense-dialog.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,11 +54,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
versionMajor=1
|
||||
versionMinor=6
|
||||
versionPatch=0
|
||||
versionCode=9
|
||||