Compare commits
No commits in common. "main" and "v1.9.0" have entirely different histories.
63 changed files with 1809 additions and 4198 deletions
73
.beads/.gitignore
vendored
73
.beads/.gitignore
vendored
|
|
@ -1,73 +0,0 @@
|
|||
# Dolt database (managed by Dolt, not git)
|
||||
dolt/
|
||||
embeddeddolt/
|
||||
|
||||
# Runtime files
|
||||
bd.sock
|
||||
bd.sock.startlock
|
||||
sync-state.json
|
||||
last-touched
|
||||
.exclusive-lock
|
||||
|
||||
# Daemon runtime (lock, log, pid)
|
||||
daemon.*
|
||||
|
||||
# Interactions log (runtime, not versioned)
|
||||
interactions.jsonl
|
||||
|
||||
# Push state (runtime, per-machine)
|
||||
push-state.json
|
||||
|
||||
# Lock files (various runtime locks)
|
||||
*.lock
|
||||
|
||||
# Credential key (encryption key for federation peer auth — never commit)
|
||||
.beads-credential-key
|
||||
|
||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||
.local_version
|
||||
|
||||
# Worktree redirect file (contains relative path to main repo's .beads/)
|
||||
# Must not be committed as paths would be wrong in other clones
|
||||
redirect
|
||||
|
||||
# Sync state (local-only, per-machine)
|
||||
# These files are machine-specific and should not be shared across clones
|
||||
.sync.lock
|
||||
export-state/
|
||||
export-state.json
|
||||
|
||||
# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned)
|
||||
ephemeral.sqlite3
|
||||
ephemeral.sqlite3-journal
|
||||
ephemeral.sqlite3-wal
|
||||
ephemeral.sqlite3-shm
|
||||
|
||||
# Dolt server management (auto-started by bd)
|
||||
dolt-server.pid
|
||||
dolt-server.log
|
||||
dolt-server.lock
|
||||
dolt-server.port
|
||||
dolt-server.activity
|
||||
|
||||
# Corrupt backup directories (created by bd doctor --fix recovery)
|
||||
*.corrupt.backup/
|
||||
|
||||
# Backup data (auto-exported JSONL, local-only)
|
||||
backup/
|
||||
|
||||
# Per-project environment file (Dolt connection config, GH#2520)
|
||||
.env
|
||||
|
||||
# Legacy files (from pre-Dolt versions)
|
||||
*.db
|
||||
*.db?*
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
db.sqlite
|
||||
bd.db
|
||||
# NOTE: Do NOT add negation patterns here.
|
||||
# They would override fork protection in .git/info/exclude.
|
||||
# Config files (metadata.json, config.yaml) are tracked by git by default
|
||||
# since no pattern above ignores them.
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
# Beads - AI-Native Issue Tracking
|
||||
|
||||
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
|
||||
|
||||
## What is Beads?
|
||||
|
||||
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
|
||||
|
||||
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Create new issues
|
||||
bd create "Add user authentication"
|
||||
|
||||
# View all issues
|
||||
bd list
|
||||
|
||||
# View issue details
|
||||
bd show <issue-id>
|
||||
|
||||
# Update issue status
|
||||
bd update <issue-id> --claim
|
||||
bd update <issue-id> --status done
|
||||
|
||||
# Sync with Dolt remote
|
||||
bd dolt push
|
||||
```
|
||||
|
||||
### Working with Issues
|
||||
|
||||
Issues in Beads are:
|
||||
- **Git-native**: Stored in Dolt database with version control and branching
|
||||
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
|
||||
- **Branch-aware**: Issues can follow your branch workflow
|
||||
- **Always in sync**: Auto-syncs with your commits
|
||||
|
||||
## Why Beads?
|
||||
|
||||
✨ **AI-Native Design**
|
||||
- Built specifically for AI-assisted development workflows
|
||||
- CLI-first interface works seamlessly with AI coding agents
|
||||
- No context switching to web UIs
|
||||
|
||||
🚀 **Developer Focused**
|
||||
- Issues live in your repo, right next to your code
|
||||
- Works offline, syncs when you push
|
||||
- Fast, lightweight, and stays out of your way
|
||||
|
||||
🔧 **Git Integration**
|
||||
- Automatic sync with git commits
|
||||
- Branch-aware issue tracking
|
||||
- Dolt-native three-way merge resolution
|
||||
|
||||
## Get Started with Beads
|
||||
|
||||
Try Beads in your own projects:
|
||||
|
||||
```bash
|
||||
# Install Beads
|
||||
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
|
||||
|
||||
# Initialize in your repo
|
||||
bd init
|
||||
|
||||
# Create your first issue
|
||||
bd create "Try out Beads"
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
|
||||
- **Quick Start Guide**: Run `bd quickstart`
|
||||
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
|
||||
|
||||
---
|
||||
|
||||
*Beads: Issue tracking that moves at the speed of thought* ⚡
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
# Beads Configuration File
|
||||
# This file configures default behavior for all bd commands in this repository
|
||||
# All settings can also be set via environment variables (BD_* prefix)
|
||||
# or overridden with command-line flags
|
||||
|
||||
# Issue prefix for this repository (used by bd init)
|
||||
# If not set, bd init will auto-detect from directory name
|
||||
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
|
||||
# issue-prefix: ""
|
||||
|
||||
# Use no-db mode: JSONL-only, no Dolt database
|
||||
# When true, bd will use .beads/issues.jsonl as the source of truth
|
||||
# no-db: false
|
||||
|
||||
# Enable JSON output by default
|
||||
# json: false
|
||||
|
||||
# Feedback title formatting for mutating commands (create/update/close/dep/edit)
|
||||
# 0 = hide titles, N > 0 = truncate to N characters
|
||||
# output:
|
||||
# title-length: 255
|
||||
|
||||
# Default actor for audit trails (overridden by BEADS_ACTOR or --actor)
|
||||
# actor: ""
|
||||
|
||||
# Export events (audit trail) to .beads/events.jsonl on each flush/sync
|
||||
# When enabled, new events are appended incrementally using a high-water mark.
|
||||
# Use 'bd export --events' to trigger manually regardless of this setting.
|
||||
# events-export: false
|
||||
|
||||
# Multi-repo configuration (experimental - bd-307)
|
||||
# Allows hydrating from multiple repositories and routing writes to the correct database
|
||||
# repos:
|
||||
# primary: "." # Primary repo (where this database lives)
|
||||
# additional: # Additional repos to hydrate from (read-only)
|
||||
# - ~/beads-planning # Personal planning repo
|
||||
# - ~/work-planning # Work planning repo
|
||||
|
||||
# JSONL backup (periodic export for off-machine recovery)
|
||||
# Auto-enabled when a git remote exists. Override explicitly:
|
||||
# backup:
|
||||
# enabled: false # Disable auto-backup entirely
|
||||
# interval: 15m # Minimum time between auto-exports
|
||||
# git-push: false # Disable git push (export locally only)
|
||||
# git-repo: "" # Separate git repo for backups (default: project repo)
|
||||
|
||||
# Integration settings (access with 'bd config get/set')
|
||||
# These are stored in the database, not in this file:
|
||||
# - jira.url
|
||||
# - jira.project
|
||||
# - linear.url
|
||||
# - linear.api-key
|
||||
# - github.org
|
||||
# - github.repo
|
||||
|
||||
sync.remote: "git+ssh://git@kode.naiv.no:2222/olemd/tilfluktsrom.git"
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$_bd_timeout" bd hooks run post-checkout "$@"
|
||||
_bd_exit=$?
|
||||
if [ $_bd_exit -eq 124 ]; then
|
||||
echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads"
|
||||
_bd_exit=0
|
||||
fi
|
||||
else
|
||||
bd hooks run post-checkout "$@"
|
||||
_bd_exit=$?
|
||||
fi
|
||||
if [ $_bd_exit -eq 3 ]; then
|
||||
echo >&2 "beads: database not initialized — skipping hook 'post-checkout'"
|
||||
_bd_exit=0
|
||||
fi
|
||||
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$_bd_timeout" bd hooks run post-merge "$@"
|
||||
_bd_exit=$?
|
||||
if [ $_bd_exit -eq 124 ]; then
|
||||
echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads"
|
||||
_bd_exit=0
|
||||
fi
|
||||
else
|
||||
bd hooks run post-merge "$@"
|
||||
_bd_exit=$?
|
||||
fi
|
||||
if [ $_bd_exit -eq 3 ]; then
|
||||
echo >&2 "beads: database not initialized — skipping hook 'post-merge'"
|
||||
_bd_exit=0
|
||||
fi
|
||||
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$_bd_timeout" bd hooks run pre-commit "$@"
|
||||
_bd_exit=$?
|
||||
if [ $_bd_exit -eq 124 ]; then
|
||||
echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads"
|
||||
_bd_exit=0
|
||||
fi
|
||||
else
|
||||
bd hooks run pre-commit "$@"
|
||||
_bd_exit=$?
|
||||
fi
|
||||
if [ $_bd_exit -eq 3 ]; then
|
||||
echo >&2 "beads: database not initialized — skipping hook 'pre-commit'"
|
||||
_bd_exit=0
|
||||
fi
|
||||
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$_bd_timeout" bd hooks run pre-push "$@"
|
||||
_bd_exit=$?
|
||||
if [ $_bd_exit -eq 124 ]; then
|
||||
echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads"
|
||||
_bd_exit=0
|
||||
fi
|
||||
else
|
||||
bd hooks run pre-push "$@"
|
||||
_bd_exit=$?
|
||||
fi
|
||||
if [ $_bd_exit -eq 3 ]; then
|
||||
echo >&2 "beads: database not initialized — skipping hook 'pre-push'"
|
||||
_bd_exit=0
|
||||
fi
|
||||
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
|
||||
_bd_exit=$?
|
||||
if [ $_bd_exit -eq 124 ]; then
|
||||
echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads"
|
||||
_bd_exit=0
|
||||
fi
|
||||
else
|
||||
bd hooks run prepare-commit-msg "$@"
|
||||
_bd_exit=$?
|
||||
fi
|
||||
if [ $_bd_exit -eq 3 ]; then
|
||||
echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'"
|
||||
_bd_exit=0
|
||||
fi
|
||||
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{"id":"tilfluktsrom-7zc","title":"Badge-kontrast under WCAG 2.2 AA — bytt shelter_primary til warning_bg","description":"Mirror av Forgejo-issue #18.\n\nBadge introdusert i 1fb9f14 (#13) bruker hvit fet 11sp tekst på shelter_primary (#FF6B35). Målt kontrast: ~2.84:1.\n\nWCAG 2.2 AA SC 1.4.3:\n- Normaltekst: ≥4.5:1\n- Large text (≥14pt fet eller ≥18pt regular): ≥3.0:1\n\n11sp fet teller som normaltekst → feiler AA-terskelen, og marginalt også 3:1 for large text.\n\nFiks: én linje i app/src/main/res/layout/item_shelter.xml — bytt @color/shelter_primary til @color/warning_bg (#BF360C, dokumentert ~5.5:1 vs hvit i colors.xml-kommentar).\n\nAlternativer som ikke fungerer:\n- shelter_primary_dark (#E55A2B) ~3.6:1 — fortsatt under AA normaltekst\n- Svart tekst på orange — ~7:1, men bryter visuell konsistens\n\nRelevant for offentlig-sektor-godkjenning (Uutilsynet/WAD/EN 301 549).\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/18","acceptance_criteria":"Badge-bakgrunnen på item_shelter.xml outsideNearestBadge er endret til warning_bg. Visuelt verifisert at hvit fet 11sp tekst på den nye bakgrunnen gir ≥4.5:1 målt kontrast (med en kontrast-sjekker eller WCAG-verktøy).","status":"closed","priority":2,"issue_type":"bug","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T14:54:00Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:55:09Z","started_at":"2026-04-29T14:54:56Z","closed_at":"2026-04-29T14:55:09Z","close_reason":"Bytta @color/shelter_primary til @color/warning_bg på outsideNearestBadge i item_shelter.xml. Kontrast: ~2.84:1 → ~5.6:1 (over WCAG 2.2 AA SC 1.4.3 4.5:1-terskel). Visuell verifisering på enhet/emulator gjenstår.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-efo","title":"PWA: dyplenket tilfluktsrom utenfor topp-N vises som om det er nærmest","description":"Mirror av Forgejo-issue #17.\n\nParallell til Android-fiksen i 1fb9f14 (#13).\n\nNåværende oppførsel: når dyplenken peker på et tilfluktsrom utenfor de N nærmeste, gjør pwa/src/app.ts:266-285 unshift() inn på indeks 0 og setter selectedShelterIndex=0. Resultat: det dyplenkede vises *som om* det er det nærmeste — uten badge, uten separator, uten forklaring. Lista bryter sin egen sort-på-avstand-invariant.\n\nHybrid-fiks (parallell til Android):\n1. Append (push) i stedet for unshift på app.ts:274\n2. Badge i shelter-list.ts på den appendede raden — bruk samme nøkkel shelter_outside_nearest_badge i pwa/src/i18n/{en,nb,nn}.ts\n3. updateList() får outsideNearestRomnr-parameter (eller wrap-objekt parallelt med Android sin ShelterListItem)\n4. Badge-teksten suffikses i aria-label (skjermleser-paritet med Android)\n5. selectedItem.scrollIntoView({block:'nearest', behavior:'smooth'}) etter updateList\n6. Tilgjengelighet: badge ≥4.5:1 kontrast (WCAG 2.2 AA SC 1.4.3). #FF6B35+hvit tekst er ~3.5:1 — *under terskel*. Bruk shelter_primary_dark #E55A2B som badge-bakgrunn, eller svart tekst på orange (~7:1)\n\nUt av skopet: endring av selve dyplenke-formatet (fortsatt /shelter/{romnr}), klyngevisning (#10).\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/17","acceptance_criteria":"Dyplenket tilfluktsrom utenfor topp-N appendes på siste rad i lista (ikke unshift). Den appendede raden har et synlig 'Valgt – utenfor nærområdet'-badge med ≥4.5:1 kontrast. Lista scroller automatisk til valgt rad. Skjermleser leser badge-teksten som del av aria-label. Verifisert manuelt med en deep-link til et tilfluktsrom langt fra brukerens posisjon.","status":"open","priority":2,"issue_type":"bug","owner":"olemd@glemt.net","created_at":"2026-04-29T14:52:08Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:52:08Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-5vc","title":"Brukerens retningspil i kartet er hvit og forsvinner på lyse underlag","description":"Mirror av Forgejo-issue #16.\n\nNår brukeren beveger seg raskt nok til at OSMDroid bytter fra person- til retningspil-ikonet i MyLocationNewOverlay, blir pilen tilnærmet usynlig: heltrukken hvit uten kontur eller skygge. På lyse tiles (snø, sand, brede veifyll, lyse OSM-temaer) forsvinner den helt.\n\nÅrsak: MainActivity.kt:190 instansierer MyLocationNewOverlay uten å overstyre setDirectionIcon()/setPersonIcon() — OSMDroid bruker da stock hvite bitmaps.\n\nForslag:\n- Egen vector i res/drawable/ic_user_direction.xml med kontrastfarget fyll + motsatt-farge kontur\n- Kall myLocationOverlay.setDirectionIcon(...) + setPersonIcon(...) eksplisitt\n- Vurder halo-ring (blå pil i hvit ring, Google Maps-stil) for variert underlag\n- Sjekk PWA-en (Leaflet) for samme problem\n\nTilgjengelighet:\n- ≥3:1 kontrast mot både lyse og mørke tiles (WCAG 2.2 AA, ikke-tekstlig innhold)\n- Ikke avhengig av farge alene; konturen sikrer at formen faktisk synes\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/16","acceptance_criteria":"Pilen og person-ikonet i MyLocationNewOverlay er tydelig synlig mot både lyse og mørke kartunderlag (≥3:1 kontrast). Verifisert manuelt på minst ett lyst og ett mørkt kartområde, både stillestående (person) og i bevegelse (pil).","status":"closed","priority":2,"issue_type":"bug","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T14:06:57Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:15:13Z","started_at":"2026-04-29T14:09:43Z","closed_at":"2026-04-29T14:15:13Z","close_reason":"Fikset i d2291a2 — egne ikoner i res/drawable/ic_user_dot.xml og ic_user_arrow.xml, satt på MyLocationNewOverlay med setPersonIcon/setDirectionIcon + (0.5, 0.5)-anker. Visuell verifisering på enhet/emulator gjenstår.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-5s7","title":"PWA: manuell testing mangler","description":"Mirror av Forgejo-issue #1.\n\nPWA-versjonen (pwa/) er skrevet, men ikke manuelt testet i nettleser. Enhetstestene passerer, men appen må verifiseres i praksis:\n\n- Start utviklingsserver og test i Chrome/Firefox\n- Test offline-modus (service worker)\n- Test kompass (iOS Safari + Android Chrome)\n- Test installasjon via «Legg til på startskjerm»\n- Test kartbufring og offline kartvisning\n- Test på fysisk iPhone (iOS-spesifikk kompasshåndtering)\n- Test i18n (norsk bokmål, nynorsk, engelsk)\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/1","status":"closed","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:57:04Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:02:57Z","closed_at":"2026-04-29T14:02:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-nt8","title":"Åpne i kartapp for gangvei til tilfluktsrom","description":"Mirror av Forgejo-issue #2.\n\nLegg til knapp (på bunnark og/eller kompassvisning) som åpner gangveibeskrivelse til valgt tilfluktsrom i ekstern kartapp.\n\nImplementasjon:\n- ACTION_VIEW intent med geo: URI: geo:lat,lon?q=lat,lon(Tilfluktsrom - adresse)\n- geo: håndteres av tilgjengelig kartapp (OsmAnd, Organic Maps, Google Maps, ...)\n- OsmAnd og Organic Maps støtter offline-navigasjon med geo: — ideelt for degradert nett\n- IKKE hardkode Google Maps-URL-er — bruk geo:\n- Faller pent tilbake hvis ingen kartapp er installert (Toast med koordinater å kopiere)\n- Knapp ved siden av tilfluktsrom-adresse i bunnarket\n\nI en akuttsituasjon er det å finne tilfluktsrommet på kartet bare halve problemet — du må vite gangveien dit. geo:-intent fungerer med offline-kapable kartapper, kritisk når nettet er nede.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/2","status":"open","priority":2,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:57:03Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:57:03Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-gmu","title":"Test og ferdigstill PWA-versjonen","description":"Mirror av Forgejo-issue #7.\n\nFå den eksisterende PWA-en i pwa/-katalogen til å fungere og testet som webfallback.\n\nStatus:\n- Vite + TypeScript + Leaflet + idb + vite-plugin-pwa\n- Shelter-data forhåndsprosesseres ved bygg (scripts/fetch-shelters.ts)\n- Markert som ikke-testet i README (issue #1)\n\nOppgaver:\n- bun install + bun run dev — fikse byggefeil\n- Verifiser at bun run fetch-shelters genererer public/data/shelters.json\n- Test offline (service worker)\n- Test på mobilnettleser (iOS Safari, Android Chrome)\n- Deploy til statisk hosting\n- Lenke til PWA fra Android-appens om-side eller README\n\nWebfallback for iOS-brukere og folk uten Android-app. Også raskest tilgang i en akuttsituasjon.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/7","status":"closed","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:46Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:02:57Z","closed_at":"2026-04-29T14:02:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"id":"tilfluktsrom-9sf","title":"Dyplenket tilfluktsrom utenfor lista vises ikke","description":"Mirror av Forgejo-issue #13.\n\nNår en dyplenke åpner et tilfluktsrom som ikke er blant de 3 nærmeste, blir det valgt i kartet, men vises ikke i lista i bunnpanelet. Brukeren ser ikke hva som er valgt.\n\nForslag:\n1. Legg til det dyplenkede tilfluktsrommet som ekstra element i lista (med markering om at det ikke er blant de 3 nærmeste), eller\n2. Rull lista slik at det valgte elementet er synlig.\n\nIdentifisert i bruksanalyse. Moderat prioritet.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/13","status":"closed","priority":2,"issue_type":"bug","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:49:15Z","started_at":"2026-04-29T14:46:18Z","closed_at":"2026-04-29T14:49:15Z","close_reason":"Hybrid implementert: ShelterListItem-wrapper med isOutsideNearest-flagg, badge i item_shelter.xml, smoothScrollToPosition på rebuildShelterList. Visuell verifisering på enhet/emulator gjenstår.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-jmv","title":"Geonorge: lokalId regenereres på hver eksport — bytt til romnr som ekstern nøkkel","description":"Mirror av Forgejo-issue #15.\n\nTilfluktsromdata fra Geonorge regenererer lokalId-feltet ved hver eksport (verifisert: alle 556 lokalId-er endres mellom snapshots, mens romnr/plasser/adresse/koordinater er stabile).\n\nKonsekvens: delingslenker basert på lokalId ble brutt mellom datasett-oppdateringer. Vi har allerede byttet ekstern delingsidentifikator til romnr og beholdt lokalId som intern Room-PK.\n\nGjenstår:\n- Spørre Geonorge/DSB hvorfor lokalId regenereres (tilsiktet gml:id-stil eller FME/SOSI-feil?)\n- Hvis feil: be om at lokalId persisteres mellom eksporter\n- Hvis tilsiktet: be om dokumentasjon\n- Sjekke om WFS-endepunktet returnerer stabile ID-er\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/15","status":"deferred","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:55:59Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-30T11:16:26Z","defer_until":"2026-10-30T00:00:00Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-jvn","title":"Filtrere tilfluktsrom etter minimumskapasitet","description":"Mirror av Forgejo-issue #5.\n\nLegg til filter for minimumskapasitet slik at brukere kan finne tilfluktsrom store nok for gruppen sin.\n\nImplementasjon:\n- Filterchip eller dropdown over tilfluktsromlista (f.eks. \"Min. plasser: 50 / 100 / 200 / Alle\")\n- Filteret gjelder både nærmeste-lista og kartmarkørene\n- Lagre valg i SharedPreferences\n- Default: vis alle (intet filter)\n\nSkoler, arbeidsplasser og familier trenger tilfluktsrom med nok kapasitet. Et lite tilfluktsrom med 20 plasser er ubrukelig for en gruppe på 50. Enkel UX-forbedring med reell praktisk verdi.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/5","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:51Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:51Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-nyz","title":"Støtte for internasjonale tilfluktsromdata","description":"Mirror av Forgejo-issue #9.\n\nI dag støtter Tilfluktsrom kun norske data fra Geonorge (GeoJSON, EPSG:25833). Mål: identifisere og integrere data fra andre land.\n\nMål:\n1. Identifisere internasjonale datakilder (NO, SE/MSB, FI/Pelastustoimi, CH/FOCP, SG/SCDF, US/FEMA)\n2. Støtte flere dataformater uten å bryte eksisterende funksjonalitet\n3. Auto-nedlasting basert på brukerens posisjon\n\nTekniske vurderinger:\n- ShelterDataSource-grensesnitt med per-land-implementasjoner\n- Parsefeil i én kilde må aldri ødelegge andre kilder (isolert per kilde, valider per record)\n- Generalisere Shelter-modellen (kjernefelt: koordinater WGS84, kapasitet, adresse, kildeland)\n- Bbox-basert dataset-registry, last bare ned relevante datasett\n- Offline-first beholdes — alle nedlastede datasett caches i Room\n\nOut of scope: brukerbidratte lokasjoner, sanntidsstatus, ruting.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/9 (3 kommentarer)","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:34Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:34Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-bnw","title":"Klyngevisning for kartmarkører","description":"Mirror av Forgejo-issue #10.\n\nNår mange tilfluktsrom ligger tett i kartet, overlapper markørene og det er vanskelig å trykke på riktig en.\n\nForslag: legg til klyngevisning (marker clustering) som grupperer nærliggende markører og viser et tall. Når brukeren zoomer inn, splittes klyngene.\n\nAlternativer:\n- Android: OSMBonusPack MarkerClusterer, eller egen logikk\n- PWA: Leaflet.markercluster-plugin\n\nLavere prioritet enn tilgjengelighetsforbedringer.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/10","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:23Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-k5i","title":"PWA: legg til sivilforsvarsinformasjons-dialog","description":"Mirror av Forgejo-issue #12.\n\nPWA-versjonen mangler sivilforsvarsinformasjonsdialogen som finnes i Android-appen (CivilDefenseInfoDialog).\n\nPort dialogen til PWA-en med samme innhold (5 steg + DSB-kilde). Vis som modal/overlay.\n\nAvhengighet: bør gjøres etter #7 (test og ferdigstill PWA).\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/12","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:19Z","dependencies":[{"issue_id":"tilfluktsrom-k5i","depends_on_id":"tilfluktsrom-gmu","type":"blocks","created_at":"2026-04-29T16:01:22Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-52s","title":"Migrere UI fra Views/ViewBinding til Jetpack Compose","description":"Mirror av Forgejo-issue #14.\n\nMigrere fra tradisjonelle Android Views med ViewBinding til Jetpack Compose.\n\nOmfang:\n- activity_main.xml → rot-Composable med tilstandsheving\n- RecyclerView + ShelterListAdapter → LazyColumn\n- DirectionArrowView (Canvas) → Compose Canvas\n- Bunnark → Card / BottomSheetScaffold\n- dialog_civil_defense.xml → AlertDialog composable\n- Lasteoverlegg → AnimatedVisibility\n- Innfør MainViewModel\n\nForblir Views: OSMDroid MapView (pakkes i AndroidView), Widget (RemoteViews, eventuell Glance-migrering er separat).\n\nVurderinger:\n- APK +2-3 MB (Compose runtime)\n- @Preview gir bedre dev-loop for ikke-kart-komponenter\n- Stort tiltak — ikke kritisk\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/14","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:10Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:10Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"tilfluktsrom-bok","title":"Forberede F-Droid-metadata for innsending","description":"Mirror av Forgejo-issue #8.\n\nNB: F-Droid-distribusjon er pauset (jf. memory feedback_fdroid_paused.md). Skal IKKE jobbes på før brukeren gjenopptar F-Droid-spor.\n\nForberede repoet for F-Droid-innsending med fastlane metadata-struktur:\n- fastlane/metadata/android/-katalog\n- en-US/ og nb-NO/ med full_description, short_description, title, changelogs/\n- images/ med skjermbilder og feature graphic\n- Dokumenter at appen bruker play-services-location men faller tilbake (anti-features)\n- Vurdere .fdroid.yml hvis spesielle byggesteg trengs\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/8 (1 kommentar)","status":"open","priority":4,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:40Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:40Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"database": "dolt",
|
||||
"backend": "dolt",
|
||||
"dolt_mode": "embedded",
|
||||
"dolt_database": "tilfluktsrom",
|
||||
"project_id": "49020f4c-0353-4536-a242-39a3dc116f11"
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"command": "bd prime",
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
"matcher": ""
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"command": "bd prime",
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
"matcher": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -10,8 +10,3 @@
|
|||
local.properties
|
||||
/app/build
|
||||
keystore.properties
|
||||
|
||||
# Beads / Dolt files (added by bd init)
|
||||
.dolt/
|
||||
*.db
|
||||
.beads-credential-key
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Følgende innhold er ikke omfattet av regelverket og vurderes heller ikke som en
|
|||
|
||||
- **Vurderingsmetode:** Egenkontroll. Ingen uavhengig tredjepartsrevisjon er gjennomført.
|
||||
- **Dato erklæringen ble utarbeidet:** 17. april 2026.
|
||||
- **Dato for siste gjennomgang:** 18. april 2026.
|
||||
- **Dato for siste gjennomgang:** 17. april 2026.
|
||||
|
||||
## 5. Tilbakemelding og kontaktinformasjon
|
||||
|
||||
|
|
|
|||
84
AGENTS.md
84
AGENTS.md
|
|
@ -1,84 +0,0 @@
|
|||
# Agent Instructions
|
||||
|
||||
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
bd ready # Find available work
|
||||
bd show <id> # View issue details
|
||||
bd update <id> --claim # Claim work atomically
|
||||
bd close <id> # Complete work
|
||||
bd dolt push # Push beads data to remote
|
||||
```
|
||||
|
||||
## Non-Interactive Shell Commands
|
||||
|
||||
**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts.
|
||||
|
||||
Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input.
|
||||
|
||||
**Use these forms instead:**
|
||||
```bash
|
||||
# Force overwrite without prompting
|
||||
cp -f source dest # NOT: cp source dest
|
||||
mv -f source dest # NOT: mv source dest
|
||||
rm -f file # NOT: rm file
|
||||
|
||||
# For recursive operations
|
||||
rm -rf directory # NOT: rm -r directory
|
||||
cp -rf source dest # NOT: cp -r source dest
|
||||
```
|
||||
|
||||
**Other commands that may prompt:**
|
||||
- `scp` - use `-o BatchMode=yes` for non-interactive
|
||||
- `ssh` - use `-o BatchMode=yes` to fail instead of prompting
|
||||
- `apt-get` - use `-y` flag
|
||||
- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var
|
||||
|
||||
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
|
||||
## Beads Issue Tracker
|
||||
|
||||
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
bd ready # Find available work
|
||||
bd show <id> # View issue details
|
||||
bd update <id> --claim # Claim work
|
||||
bd close <id> # Complete work
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
|
||||
- Run `bd prime` for detailed command reference and session close protocol
|
||||
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
|
||||
|
||||
## Session Completion
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git pull --rebase
|
||||
bd dolt push
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
<!-- END BEADS INTEGRATION -->
|
||||
|
|
@ -13,6 +13,7 @@ This document describes the architecture of Tilfluktsrom, a Norwegian emergency
|
|||
- [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)
|
||||
|
|
@ -40,7 +41,7 @@ This is an emergency app. Core functionality — finding the nearest shelter, co
|
|||
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, and OkHttp.
|
||||
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.
|
||||
|
|
@ -106,12 +107,15 @@ no.naiv.tilfluktsrom/
|
|||
│ ├── 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
|
||||
├── 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/` have separate implementations per build variant:
|
||||
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
|
||||
|
||||
|
|
@ -194,9 +198,24 @@ The `standard` flavor adds `com.google.android.gms:play-services-location`. Runt
|
|||
|
||||
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/{romnr}`
|
||||
**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}`).
|
||||
|
||||
|
|
@ -207,20 +226,6 @@ The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts`
|
|||
|
||||
Share messages include the HTTPS URL, which SMS apps auto-link as a tappable URL.
|
||||
|
||||
#### Deep link identifier — why `romnr`, not `lokalId`
|
||||
|
||||
The path component is the shelter's `romnr` (DSB room number — an integer like `776`), not the GeoJSON `lokalId` UUID, even though `lokalId` is what Room and Leaflet use as the in-memory primary key.
|
||||
|
||||
**Empirical reason:** the upstream GeoJSON ZIP at `nedlasting.geonorge.no/.../TilfluktsromOffentlige_GeoJSON.zip` re-rolls every `lokalId` on every export. Three snapshots of the same dataset (taken Dec 2025, Apr 20 2026, Apr 27 2026) had **556/556 different lokalIds** while every other field (`romnr`, `adresse`, `plasser`, `latitude`, `longitude`) was byte-identical and the shelter count was unchanged. The most recent two snapshots are only seven days apart, so this is regular drift, not a one-off re-issue.
|
||||
|
||||
That makes `lokalId` unsuitable for any cross-device or cross-build identifier:
|
||||
- Sender and receiver who fetched the dataset on different days have different lokalIds for the same physical shelter, so a lokalId-keyed share link fails with "shelter not found" on the receiving side.
|
||||
- Even on a single device, a user who hits "Refresh data" while a shelter is selected would lose the selection if it was tracked by lokalId.
|
||||
|
||||
`romnr` is the actual DSB business key — the room number physically assigned to the shelter by the civil-defence authority — and is stable across exports. Verified unique (556/556) and present (no zeros) on the current dataset.
|
||||
|
||||
The internal Room primary key remains `lokalId` because (a) it's already the upstream-supplied UUID and changing it would force a destructive Room schema migration, and (b) within a single fetch it's a perfectly fine in-memory key. Only the *external* deep-link identifier was switched.
|
||||
|
||||
---
|
||||
|
||||
## Progressive Web App
|
||||
|
|
|
|||
51
CLAUDE.md
51
CLAUDE.md
|
|
@ -12,6 +12,7 @@ The app must work on devices without Google Play Services (e.g. LineageOS, Graph
|
|||
- **Location**: Prefer FusedLocationProviderClient (Play Services) → fall back to LocationManager (AOSP)
|
||||
- **Maps**: OSMDroid (no Google dependency)
|
||||
- **Database**: Room/SQLite (no Google dependency)
|
||||
- **Background work**: WorkManager (works without Play Services via built-in scheduler)
|
||||
|
||||
### Offline-First
|
||||
This is an emergency app. Assume internet and infrastructure may be degraded or unavailable. All core functionality (finding nearest shelter, compass navigation, sharing location) must work offline after initial data cache. Avoid solutions that depend on external servers being reachable.
|
||||
|
|
@ -23,6 +24,7 @@ This is an emergency app. Assume internet and infrastructure may be degraded or
|
|||
- **Database**: Room (SQLite) for shelter data cache
|
||||
- **HTTP**: OkHttp for data downloads
|
||||
- **Location**: FusedLocationProviderClient (Play Services) with LocationManager fallback
|
||||
- **Background**: WorkManager for periodic widget updates
|
||||
- **UI**: Traditional Views with ViewBinding
|
||||
|
||||
## Key Data Flow
|
||||
|
|
@ -42,6 +44,7 @@ no.naiv.tilfluktsrom/
|
|||
├── data/ # Room entities, DAO, repository, GeoJSON parser, map cache
|
||||
├── location/ # GPS location provider, nearest shelter finder
|
||||
├── ui/ # Custom views (DirectionArrowView), adapters
|
||||
├── widget/ # Home screen widget, WorkManager periodic updater
|
||||
└── util/ # Coordinate conversion (UTM→WGS84), distance calculations
|
||||
```
|
||||
|
||||
|
|
@ -110,51 +113,3 @@ Current screenshots:
|
|||
- Default (English): `res/values/strings.xml`
|
||||
- Norwegian Bokmål: `res/values-nb/strings.xml`
|
||||
- Norwegian Nynorsk: `res/values-nn/strings.xml`
|
||||
|
||||
|
||||
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
|
||||
## Beads Issue Tracker
|
||||
|
||||
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
bd ready # Find available work
|
||||
bd show <id> # View issue details
|
||||
bd update <id> --claim # Claim work
|
||||
bd close <id> # Complete work
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
|
||||
- Run `bd prime` for detailed command reference and session close protocol
|
||||
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
|
||||
|
||||
## Session Completion
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git pull --rebase
|
||||
bd dolt push
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
<!-- END BEADS INTEGRATION -->
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ Finn nærmeste offentlige tilfluktsrom i Norge. Appen er bygd for nødsituasjone
|
|||
- **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
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ tilfluktsrom/
|
|||
│ │ ├── data/ # Room-database, nedlasting, GeoJSON-parser
|
||||
│ │ ├── location/ # GPS, nærmeste tilfluktsrom
|
||||
│ │ ├── ui/ # Retningspil, liste-adapter, om-dialog
|
||||
│ │ ├── widget/ # Hjemmeskjerm-widget
|
||||
│ │ └── util/ # UTM→WGS84-konvertering, avstandsberegning
|
||||
│ └── res/ # Layout, strenger (en/nb/nn), ikoner
|
||||
├── pwa/ # Nettapp (TypeScript)
|
||||
|
|
@ -97,7 +99,7 @@ Appen er designet etter «offline-first»-prinsippet:
|
|||
- Content Security Policy (CSP) i PWA-versjonen
|
||||
- Tilfluktsromdata valideres ved parsing (koordinater innenfor Norge, gyldige felt)
|
||||
- Databaseoppdateringer er atomiske (transaksjon) for å unngå datatap
|
||||
- GPS-posisjon lagres ikke — den brukes bare i minnet mens appen er i bruk
|
||||
- Lagret GPS-posisjon utløper automatisk etter 24 timer
|
||||
- Egendefinert User-Agent forhindrer enhetsfingeravtrykk
|
||||
|
||||
## Personvern
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ engineers must be in orbit, in your pocket, and on the circuit board.
|
|||
|
||||
| Library | What it does | Contributors | Source |
|
||||
|---|---|---|---|
|
||||
| AndroidX (Core, AppCompat, Room, etc.) | UI, architecture, database | ~1,000 | [GitHub: androidx/androidx](https://github.com/androidx/androidx) monorepo |
|
||||
| AndroidX (Core, AppCompat, Room, WorkManager, etc.) | UI, architecture, database, scheduling | ~1,000 | [GitHub: androidx/androidx](https://github.com/androidx/androidx) monorepo |
|
||||
| Material Design Components | Visual design language and components | ~199 | [GitHub: material-components-android](https://github.com/material-components/material-components-android) |
|
||||
| Kotlinx Coroutines | Async data loading without blocking the UI | ~308 | [GitHub: Kotlin/kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) |
|
||||
| OkHttp | Downloads the GeoJSON ZIP from Geonorge | ~287 | [GitHub: square/okhttp](https://github.com/square/okhttp) |
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ android {
|
|||
applicationId = "no.naiv.tilfluktsrom"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 17
|
||||
versionName = "1.10.2"
|
||||
versionCode = 13
|
||||
versionName = "1.9.0"
|
||||
|
||||
// Deep link domain — single source of truth for manifest + Kotlin code
|
||||
val deepLinkDomain = "tilfluktsrom.naiv.no"
|
||||
|
|
@ -104,6 +104,9 @@ dependencies {
|
|||
// Google Play Services Location (precise GPS) — standard flavor only
|
||||
"standardImplementation"("com.google.android.gms:play-services-location:21.3.0")
|
||||
|
||||
// WorkManager (periodic widget updates)
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
package no.naiv.tilfluktsrom.widget
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.text.format.DateFormat
|
||||
import android.util.Log
|
||||
import android.widget.RemoteViews
|
||||
import androidx.core.content.ContextCompat
|
||||
import no.naiv.tilfluktsrom.MainActivity
|
||||
import no.naiv.tilfluktsrom.R
|
||||
import no.naiv.tilfluktsrom.data.ShelterDatabase
|
||||
import no.naiv.tilfluktsrom.location.ShelterFinder
|
||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Home screen widget showing the nearest shelter with distance.
|
||||
*
|
||||
* F-Droid flavor: uses LocationManager only (no Google Play Services).
|
||||
*/
|
||||
class ShelterWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ShelterWidget"
|
||||
const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH"
|
||||
private const val EXTRA_LATITUDE = "lat"
|
||||
private const val EXTRA_LONGITUDE = "lon"
|
||||
|
||||
fun requestUpdate(context: Context) {
|
||||
val intent = Intent(context, ShelterWidgetProvider::class.java).apply {
|
||||
action = ACTION_REFRESH
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
fun requestUpdateWithLocation(context: Context, latitude: Double, longitude: Double) {
|
||||
val intent = Intent(context, ShelterWidgetProvider::class.java).apply {
|
||||
action = ACTION_REFRESH
|
||||
putExtra(EXTRA_LATITUDE, latitude)
|
||||
putExtra(EXTRA_LONGITUDE, longitude)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetUpdateWorker.schedule(context)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetUpdateWorker.cancel(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
WidgetUpdateWorker.schedule(context)
|
||||
updateAllWidgetsAsync(context, null)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
|
||||
if (intent.action == ACTION_REFRESH) {
|
||||
val providedLocation = if (intent.hasExtra(EXTRA_LATITUDE)) {
|
||||
Location("widget").apply {
|
||||
latitude = intent.getDoubleExtra(EXTRA_LATITUDE, 0.0)
|
||||
longitude = intent.getDoubleExtra(EXTRA_LONGITUDE, 0.0)
|
||||
}
|
||||
} else null
|
||||
|
||||
updateAllWidgetsAsync(context, providedLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAllWidgetsAsync(context: Context, providedLocation: Location?) {
|
||||
val pendingResult = goAsync()
|
||||
Thread {
|
||||
try {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val widgetIds = appWidgetManager.getAppWidgetIds(
|
||||
ComponentName(context, ShelterWidgetProvider::class.java)
|
||||
)
|
||||
val location = providedLocation ?: getBestLocation(context)
|
||||
for (appWidgetId in widgetIds) {
|
||||
updateWidget(context, appWidgetManager, appWidgetId, location)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to update widgets", e)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun updateWidget(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetId: Int,
|
||||
location: Location?
|
||||
) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter)
|
||||
|
||||
val openAppIntent = Intent(context, MainActivity::class.java)
|
||||
val openAppPending = PendingIntent.getActivity(
|
||||
context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.widgetRoot, openAppPending)
|
||||
|
||||
val refreshIntent = Intent(context, ShelterWidgetProvider::class.java).apply {
|
||||
action = ACTION_REFRESH
|
||||
}
|
||||
val refreshPending = PendingIntent.getBroadcast(
|
||||
context, 0, refreshIntent, PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
showFallback(context, views, context.getString(R.string.widget_open_app))
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
return
|
||||
}
|
||||
|
||||
if (location == null) {
|
||||
showFallback(context, views, context.getString(R.string.widget_no_location))
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
return
|
||||
}
|
||||
|
||||
val shelters = try {
|
||||
val dao = ShelterDatabase.getInstance(context).shelterDao()
|
||||
kotlinx.coroutines.runBlocking { dao.getAllSheltersList() }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to query shelters", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
if (shelters.isEmpty()) {
|
||||
showFallback(context, views, context.getString(R.string.widget_no_data))
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
return
|
||||
}
|
||||
|
||||
val nearest = ShelterFinder.findNearest(
|
||||
shelters, location.latitude, location.longitude, 1
|
||||
).firstOrNull()
|
||||
|
||||
if (nearest == null) {
|
||||
showFallback(context, views, context.getString(R.string.widget_no_data))
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
return
|
||||
}
|
||||
|
||||
views.setTextViewText(R.id.widgetAddress, nearest.shelter.adresse)
|
||||
views.setTextViewText(
|
||||
R.id.widgetDetails,
|
||||
context.getString(R.string.shelter_capacity, nearest.shelter.plasser)
|
||||
)
|
||||
views.setTextViewText(
|
||||
R.id.widgetDistance,
|
||||
DistanceUtils.formatDistance(nearest.distanceMeters)
|
||||
)
|
||||
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
|
||||
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
|
||||
private fun showFallback(context: Context, views: RemoteViews, message: String) {
|
||||
views.setTextViewText(R.id.widgetAddress, message)
|
||||
views.setTextViewText(R.id.widgetDetails, "")
|
||||
views.setTextViewText(R.id.widgetDistance, "")
|
||||
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
|
||||
}
|
||||
|
||||
private fun formatTimestamp(context: Context): String {
|
||||
val format = DateFormat.getTimeFormat(context)
|
||||
val timeStr = format.format(System.currentTimeMillis())
|
||||
return context.getString(R.string.widget_updated_at, timeStr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available location via LocationManager or SharedPreferences.
|
||||
* Safe to call from a background thread.
|
||||
*/
|
||||
private fun getBestLocation(context: Context): Location? {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) return null
|
||||
|
||||
val lmLocation = getLocationManagerLocation(context)
|
||||
if (lmLocation != null) return lmLocation
|
||||
|
||||
return getSavedLocation(context)
|
||||
}
|
||||
|
||||
/** Returns null if older than 24 hours to avoid retaining stale location data. */
|
||||
private fun getSavedLocation(context: Context): Location? {
|
||||
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
|
||||
if (!prefs.contains("last_lat")) return null
|
||||
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
|
||||
if (age > 24 * 60 * 60 * 1000L) return null
|
||||
return Location("saved").apply {
|
||||
latitude = prefs.getFloat("last_lat", 0f).toDouble()
|
||||
longitude = prefs.getFloat("last_lon", 0f).toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLocationManagerLocation(context: Context): Location? {
|
||||
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
|
||||
as? LocationManager ?: return null
|
||||
|
||||
try {
|
||||
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||
val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
|
||||
val cached = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time }
|
||||
if (cached != null) return cached
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException getting last known location", e)
|
||||
return null
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val provider = when {
|
||||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
|
||||
LocationManager.NETWORK_PROVIDER
|
||||
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
|
||||
LocationManager.GPS_PROVIDER
|
||||
else -> return null
|
||||
}
|
||||
try {
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
var result: Location? = null
|
||||
val signal = CancellationSignal()
|
||||
locationManager.getCurrentLocation(
|
||||
provider, signal, context.mainExecutor
|
||||
) { location ->
|
||||
result = location
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
signal.cancel()
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Active location request failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package no.naiv.tilfluktsrom.widget
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Periodic background worker that refreshes the home screen widget.
|
||||
*
|
||||
* F-Droid flavor: uses LocationManager only (no Google Play Services).
|
||||
*/
|
||||
class WidgetUpdateWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WidgetUpdateWorker"
|
||||
private const val WORK_NAME = "widget_update"
|
||||
private const val LOCATION_TIMEOUT_MS = 10_000L
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<WidgetUpdateWorker>(
|
||||
15, TimeUnit.MINUTES
|
||||
).build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
fun runOnce(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<WidgetUpdateWorker>().build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val location = requestFreshLocation() ?: getSavedLocation()
|
||||
if (location != null) {
|
||||
ShelterWidgetProvider.requestUpdateWithLocation(
|
||||
applicationContext, location.latitude, location.longitude
|
||||
)
|
||||
} else {
|
||||
ShelterWidgetProvider.requestUpdate(applicationContext)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
/** Returns null if older than 24 hours. */
|
||||
private fun getSavedLocation(): Location? {
|
||||
val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
|
||||
if (!prefs.contains("last_lat")) return null
|
||||
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
|
||||
if (age > 24 * 60 * 60 * 1000L) return null
|
||||
return Location("saved").apply {
|
||||
latitude = prefs.getFloat("last_lat", 0f).toDouble()
|
||||
longitude = prefs.getFloat("last_lon", 0f).toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestFreshLocation(): Location? {
|
||||
val context = applicationContext
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) return null
|
||||
|
||||
return requestViaLocationManager()
|
||||
}
|
||||
|
||||
private suspend fun requestViaLocationManager(): Location? {
|
||||
val locationManager = applicationContext.getSystemService(Context.LOCATION_SERVICE)
|
||||
as? LocationManager ?: return null
|
||||
|
||||
val provider = when {
|
||||
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
|
||||
LocationManager.GPS_PROVIDER
|
||||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
|
||||
LocationManager.NETWORK_PROVIDER
|
||||
else -> return null
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return requestCurrentLocation(locationManager, provider)
|
||||
}
|
||||
// API 26-29: fall back to passive cache
|
||||
return try {
|
||||
locationManager.getLastKnownLocation(provider)
|
||||
} catch (e: SecurityException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestCurrentLocation(locationManager: LocationManager, provider: String): Location? {
|
||||
return try {
|
||||
withTimeoutOrNull(LOCATION_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine<Location?> { cont ->
|
||||
val signal = CancellationSignal()
|
||||
locationManager.getCurrentLocation(
|
||||
provider,
|
||||
signal,
|
||||
applicationContext.mainExecutor
|
||||
) { location ->
|
||||
if (cont.isActive) cont.resume(location)
|
||||
}
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException requesting location via LocationManager", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,5 +39,18 @@
|
|||
android:pathPrefix="/shelter/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.ShelterWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/nearest_shelter">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
<action android:name="no.naiv.tilfluktsrom.widget.REFRESH" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,18 +5,14 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
|
|
@ -44,8 +40,8 @@ import no.naiv.tilfluktsrom.location.ShelterFinder
|
|||
import no.naiv.tilfluktsrom.location.ShelterWithDistance
|
||||
import no.naiv.tilfluktsrom.ui.CivilDefenseInfoDialog
|
||||
import no.naiv.tilfluktsrom.ui.ShelterListAdapter
|
||||
import no.naiv.tilfluktsrom.ui.ShelterListItem
|
||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||
import no.naiv.tilfluktsrom.widget.ShelterWidgetProvider
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
|
@ -74,14 +70,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
private var deviceHeading = 0f
|
||||
private var isCompassMode = false
|
||||
private var cachingJob: Job? = null
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
// Whether to consider showing the map-cache prompt on the next location
|
||||
// update. Mirrors the PWA's firstLocationFix flag: we only prompt once per
|
||||
// session, regardless of whether the user accepts or skips. Without this
|
||||
// guard, every location update re-checks hasCacheForLocation and re-shows
|
||||
// the prompt if the user previously chose "Skip".
|
||||
private var mapCachePromptPending = true
|
||||
// Map from shelter lokalId to its map marker, for icon swapping on selection
|
||||
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf()
|
||||
private var highlightedMarkerId: String? = null
|
||||
|
|
@ -89,14 +77,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
// Whether a compass sensor is available on this device
|
||||
private var hasCompassSensor = false
|
||||
|
||||
// Deep link: shelter to select once data is loaded.
|
||||
// We key on `romnr` (DSB's room number) rather than `lokalId` because
|
||||
// upstream Geonorge re-rolls the lokalId UUID on every export. Two
|
||||
// devices that fetched at different times have different lokalIds for
|
||||
// the same physical shelter, breaking cross-device share links.
|
||||
// Romnr is the actual DSB business key and is stable across exports.
|
||||
// See ARCHITECTURE.md → "Deep link identifier".
|
||||
private var pendingDeepLinkRomnr: Int? = null
|
||||
// Deep link: shelter ID to select once data is loaded
|
||||
private var pendingDeepLinkShelterId: String? = null
|
||||
|
||||
// The currently selected shelter — can be any shelter, not just one from nearestShelters
|
||||
private var selectedShelter: ShelterWithDistance? = null
|
||||
|
|
@ -114,9 +96,27 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
if (fineGranted || coarseGranted) {
|
||||
startLocationUpdates()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
|
||||
// Check if user permanently denied (don't show rationale = permanently denied)
|
||||
val shouldShowRationale = ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
this, Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
if (!shouldShowRationale) {
|
||||
// Permission permanently denied — guide user to settings
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.permission_location_title)
|
||||
.setMessage(R.string.permission_denied)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
updateLocationStatusBanner()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
@ -148,9 +148,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle https://{domain}/shelter/{romnr} deep link.
|
||||
* `romnr` is DSB's stable shelter room number — see field comment on
|
||||
* pendingDeepLinkRomnr for why we don't use lokalId.
|
||||
* Handle https://{domain}/shelter/{lokalId} deep link.
|
||||
* If shelters are already loaded, select immediately; otherwise store as pending.
|
||||
*/
|
||||
private fun handleDeepLinkIntent(intent: Intent?) {
|
||||
|
|
@ -159,15 +157,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
uri.host != BuildConfig.DEEP_LINK_DOMAIN ||
|
||||
uri.path?.startsWith("/shelter/") != true) return
|
||||
|
||||
val romnr = uri.lastPathSegment?.toIntOrNull() ?: return
|
||||
val lokalId = uri.lastPathSegment ?: return
|
||||
// Clear intent data so config changes don't re-trigger
|
||||
intent.data = null
|
||||
|
||||
val shelter = allShelters.find { it.romnr == romnr }
|
||||
val shelter = allShelters.find { it.lokalId == lokalId }
|
||||
if (shelter != null) {
|
||||
selectShelterByData(shelter)
|
||||
} else {
|
||||
pendingDeepLinkRomnr = romnr
|
||||
pendingDeepLinkShelterId = lokalId
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,37 +187,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
false // Don't consume the event
|
||||
}
|
||||
|
||||
// Add user location overlay. OSMDroid's stock person/arrow bitmaps
|
||||
// are pure white and disappear on light tiles - replace with
|
||||
// app-themed icons that have a white halo + drop shadow so the
|
||||
// silhouette stays visible on any tile theme (Forgejo #16).
|
||||
// Add user location overlay
|
||||
myLocationOverlay = MyLocationNewOverlay(
|
||||
GpsMyLocationProvider(this@MainActivity), this
|
||||
).apply {
|
||||
drawableToBitmap(R.drawable.ic_user_dot, sizeDp = 24)?.let {
|
||||
setPersonIcon(it)
|
||||
setPersonAnchor(0.5f, 0.5f)
|
||||
}
|
||||
drawableToBitmap(R.drawable.ic_user_arrow, sizeDp = 32)?.let {
|
||||
setDirectionIcon(it)
|
||||
setDirectionAnchor(0.5f, 0.5f)
|
||||
}
|
||||
}
|
||||
)
|
||||
overlays.add(myLocationOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawableToBitmap(@androidx.annotation.DrawableRes resId: Int, sizeDp: Int): Bitmap? {
|
||||
val drawable = ContextCompat.getDrawable(this, resId) ?: return null
|
||||
val sizePx = (sizeDp * resources.displayMetrics.density).toInt().coerceAtLeast(1)
|
||||
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
Canvas(bitmap).also { canvas ->
|
||||
drawable.setBounds(0, 0, sizePx, sizePx)
|
||||
drawable.draw(canvas)
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun setupShelterList() {
|
||||
shelterAdapter = ShelterListAdapter { swd ->
|
||||
userSelectedShelter = true
|
||||
|
|
@ -285,8 +260,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
private fun loadData() {
|
||||
updateLocationStatusBanner()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
var hasData = repository.hasCachedData()
|
||||
|
|
@ -323,9 +296,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
updateShelterMarkers()
|
||||
|
||||
// Process pending deep links now that shelter data is available
|
||||
pendingDeepLinkRomnr?.let { romnr ->
|
||||
pendingDeepLinkRomnr = null
|
||||
val shelter = shelters.find { it.romnr == romnr }
|
||||
pendingDeepLinkShelterId?.let { id ->
|
||||
pendingDeepLinkShelterId = null
|
||||
val shelter = shelters.find { it.lokalId == id }
|
||||
if (shelter != null) {
|
||||
selectShelterByData(shelter)
|
||||
} else {
|
||||
|
|
@ -406,44 +379,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
)
|
||||
}
|
||||
|
||||
private fun updateLocationStatusBanner() {
|
||||
val banner = binding.noLocationBanner
|
||||
val text = binding.locationBannerText
|
||||
val action = binding.locationBannerAction
|
||||
|
||||
when {
|
||||
!locationProvider.hasLocationPermission() -> {
|
||||
text.setText(R.string.status_location_permission_needed)
|
||||
action.setText(R.string.action_grant_permission)
|
||||
action.setOnClickListener {
|
||||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
})
|
||||
}
|
||||
banner.visibility = View.VISIBLE
|
||||
}
|
||||
!isLocationServicesEnabled() -> {
|
||||
text.setText(R.string.status_location_services_off)
|
||||
action.setText(R.string.action_location_settings)
|
||||
action.setOnClickListener {
|
||||
startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
|
||||
}
|
||||
banner.visibility = View.VISIBLE
|
||||
}
|
||||
else -> banner.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLocationServicesEnabled(): Boolean {
|
||||
val lm = getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return false
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
lm.isLocationEnabled
|
||||
} else {
|
||||
lm.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
|
||||
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
// Use repeatOnLifecycle(STARTED) so GPS stops when Activity is paused
|
||||
lifecycleScope.launch {
|
||||
|
|
@ -451,6 +386,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
try {
|
||||
locationProvider.locationUpdates().collectLatest { location ->
|
||||
currentLocation = location
|
||||
saveLastLocation(location)
|
||||
updateNearestShelters(location)
|
||||
|
||||
// Center map on first location fix
|
||||
|
|
@ -460,14 +396,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
)
|
||||
}
|
||||
|
||||
// Cache map tiles on first launch — at most one prompt
|
||||
// per session so a "Skip" decision sticks.
|
||||
if (mapCachePromptPending &&
|
||||
!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude) &&
|
||||
isNetworkAvailable()
|
||||
) {
|
||||
mapCachePromptPending = false
|
||||
cacheMapTiles(location.latitude, location.longitude)
|
||||
// Cache map tiles on first launch
|
||||
if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) {
|
||||
if (isNetworkAvailable()) {
|
||||
cacheMapTiles(location.latitude, location.longitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
|
|
@ -487,60 +420,26 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
allShelters, location.latitude, location.longitude, NEAREST_COUNT
|
||||
)
|
||||
|
||||
// Highlight which nearest-list item matches the current selection
|
||||
val selectedIdx = if (selectedShelter != null) {
|
||||
nearestShelters.indexOfFirst { it.shelter.lokalId == selectedShelter!!.shelter.lokalId }
|
||||
} else -1
|
||||
|
||||
shelterAdapter.submitList(nearestShelters)
|
||||
shelterAdapter.selectPosition(selectedIdx)
|
||||
|
||||
if (userSelectedShelter && selectedShelter != null) {
|
||||
// Recalculate distance/bearing for the user's picked shelter
|
||||
refreshSelectedShelterDistance(location)
|
||||
rebuildShelterList()
|
||||
updateSelectedShelterUI()
|
||||
} else if (nearestShelters.isNotEmpty()) {
|
||||
// Auto-select nearest; selectShelter handles list rebuild + UI
|
||||
selectShelter(nearestShelters[0])
|
||||
} else {
|
||||
rebuildShelterList()
|
||||
updateSelectedShelterUI()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the bottom-sheet list from the current nearest set + selection.
|
||||
*
|
||||
* Hybrid behaviour for Forgejo #13: when a shelter has been explicitly
|
||||
* selected (deep link, marker tap, ...) and is *not* among the N nearest,
|
||||
* append it to the list with an "outside nearest" badge so the user can
|
||||
* see what they selected. The list also auto-scrolls to the selected
|
||||
* row, so a manually-picked nearby entry comes into view too.
|
||||
*/
|
||||
private fun rebuildShelterList() {
|
||||
val items = nearestShelters
|
||||
.map { ShelterListItem(it, isOutsideNearest = false) }
|
||||
.toMutableList()
|
||||
|
||||
val selected = selectedShelter
|
||||
val isSelectedAmongNearest = selected != null &&
|
||||
nearestShelters.any { it.shelter.lokalId == selected.shelter.lokalId }
|
||||
if (selected != null && !isSelectedAmongNearest) {
|
||||
// Only flag as "outside nearest" when there *is* a nearest list to
|
||||
// contrast with - otherwise the selection is just the only entry.
|
||||
items.add(
|
||||
ShelterListItem(
|
||||
selected,
|
||||
isOutsideNearest = nearestShelters.isNotEmpty()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
shelterAdapter.submitList(items)
|
||||
|
||||
val selectedIdx = if (selected != null) {
|
||||
items.indexOfFirst { it.swd.shelter.lokalId == selected.shelter.lokalId }
|
||||
} else -1
|
||||
shelterAdapter.selectPosition(selectedIdx)
|
||||
|
||||
if (selectedIdx >= 0) {
|
||||
binding.shelterList.post {
|
||||
binding.shelterList.smoothScrollToPosition(selectedIdx)
|
||||
// Auto-select nearest
|
||||
if (nearestShelters.isNotEmpty()) {
|
||||
selectShelter(nearestShelters[0])
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedShelterUI()
|
||||
ShelterWidgetProvider.requestUpdate(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -551,7 +450,10 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
selectedShelter = swd
|
||||
currentLocation?.let { refreshSelectedShelterDistance(it) }
|
||||
|
||||
rebuildShelterList()
|
||||
// Update list highlight
|
||||
val idx = nearestShelters.indexOfFirst { it.shelter.lokalId == swd.shelter.lokalId }
|
||||
shelterAdapter.selectPosition(idx)
|
||||
|
||||
updateSelectedShelterUI()
|
||||
}
|
||||
|
||||
|
|
@ -758,26 +660,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
return
|
||||
}
|
||||
|
||||
// Guard against double-tap / overlapping refreshes. Without this, the
|
||||
// user can fire several refreshData() calls that serialize on the
|
||||
// single Room write-lock and stack 30–90 s of OkHttp timeouts on top
|
||||
// of each other — perceived as "hang" with no feedback.
|
||||
if (refreshJob?.isActive == true) return
|
||||
|
||||
binding.statusText.text = getString(R.string.status_updating)
|
||||
showLoading(getString(R.string.loading_shelters))
|
||||
|
||||
refreshJob = lifecycleScope.launch {
|
||||
try {
|
||||
val success = repository.refreshData()
|
||||
if (success) {
|
||||
updateFreshnessIndicator()
|
||||
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} finally {
|
||||
hideLoading()
|
||||
lifecycleScope.launch {
|
||||
binding.statusText.text = getString(R.string.status_updating)
|
||||
val success = repository.refreshData()
|
||||
if (success) {
|
||||
updateFreshnessIndicator()
|
||||
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -795,10 +685,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
val shelter = selected.shelter
|
||||
// Path component is romnr (stable DSB business key), not lokalId —
|
||||
// upstream re-rolls lokalId on every Geonorge export, which would
|
||||
// break cross-device links. See pendingDeepLinkRomnr comment.
|
||||
val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.romnr}"
|
||||
val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.lokalId}"
|
||||
val body = getString(
|
||||
R.string.share_body,
|
||||
shelter.adresse,
|
||||
|
|
@ -858,6 +745,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
binding.selectedShelterDetails.text = getString(R.string.status_shelters_loaded, allShelters.size)
|
||||
}
|
||||
|
||||
/** Persist last GPS fix so the widget can use it even when the app isn't running. */
|
||||
private fun saveLastLocation(location: Location) {
|
||||
getSharedPreferences("widget_prefs", Context.MODE_PRIVATE).edit()
|
||||
.putFloat("last_lat", location.latitude.toFloat())
|
||||
.putFloat("last_lon", location.longitude.toFloat())
|
||||
.putLong("last_time", System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun isNetworkAvailable(): Boolean {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return false
|
||||
|
|
@ -873,10 +769,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
binding.mapView.onResume()
|
||||
myLocationOverlay?.enableMyLocation()
|
||||
|
||||
// Re-check permission + location-services state so the banner updates
|
||||
// when the user returns from Settings.
|
||||
updateLocationStatusBanner()
|
||||
|
||||
val sm = sensorManager ?: return
|
||||
|
||||
// Try rotation vector first (best compass source)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class ShelterRepository(private val context: Context) {
|
|||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.addInterceptor(Interceptor { chain ->
|
||||
chain.proceed(chain.request().newBuilder()
|
||||
.header("User-Agent", "Tilfluktsrom/1.10.2")
|
||||
.header("User-Agent", "Tilfluktsrom/1.9.0")
|
||||
.build())
|
||||
})
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
package no.naiv.tilfluktsrom.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import no.naiv.tilfluktsrom.R
|
||||
|
||||
|
|
@ -31,12 +28,7 @@ class AboutDialog : DialogFragment() {
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = inflater.inflate(R.layout.dialog_about, container, false)
|
||||
view.findViewById<TextView>(R.id.about_data_links).apply {
|
||||
text = Html.fromHtml(getString(R.string.about_data_links), Html.FROM_HTML_MODE_COMPACT)
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
return view
|
||||
return inflater.inflate(R.layout.dialog_about, container, false)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import android.provider.Settings
|
|||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import no.naiv.tilfluktsrom.R
|
||||
|
||||
/**
|
||||
|
|
@ -164,35 +162,20 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Draw a small north indicator: a tiny triangle and "N" label pointing
|
||||
* outward from the view centre in the direction of north. The radius
|
||||
* is clamped so the label stays inside the viewport even when north
|
||||
* points toward the shorter viewport axis (previously the indicator
|
||||
* would be drawn off-screen when the user was facing roughly east or
|
||||
* west on a portrait-oriented compass view).
|
||||
* 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
|
||||
val textSize = arrowSize * 0.18f
|
||||
northTextPaint.textSize = textSize
|
||||
|
||||
// Outward reach of the rendered indicator beyond `radius`: the
|
||||
// triangle's apex sits at tickSize*1.8, the text baseline at
|
||||
// tickSize*2.2, and the "N" glyph extends roughly textSize above
|
||||
// its baseline. The larger of these is what must fit inside the
|
||||
// viewport.
|
||||
val labelReach = tickSize * 2.2f + textSize
|
||||
|
||||
val radius = clampIndicatorRadius(
|
||||
cx, cy, width, height, northAngle,
|
||||
preferredRadius = arrowSize * 1.35f,
|
||||
labelReach = labelReach,
|
||||
minRadius = tickSize * 3f
|
||||
)
|
||||
// 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)
|
||||
|
|
@ -200,6 +183,7 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
northPath.close()
|
||||
canvas.drawPath(northPath, northPaint)
|
||||
|
||||
// "N" label just outside the triangle
|
||||
canvas.drawText("N", cx, cy - radius - tickSize * 2.2f, northTextPaint)
|
||||
|
||||
canvas.restore()
|
||||
|
|
@ -270,46 +254,5 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
* Returns a value in {0, 45, 90, 135, 180, 225, 270, 315}. */
|
||||
internal fun snapToSector(angleDegrees: Float): Float =
|
||||
sectorIndex(angleDegrees) * 45f
|
||||
|
||||
/**
|
||||
* Return the largest radius from the view centre that still lets an
|
||||
* indicator — positioned on a circle around the centre and reaching
|
||||
* [labelReach] further in the outward direction — stay inside the
|
||||
* axis-aligned viewport `[0, viewWidth] × [0, viewHeight]`, while
|
||||
* respecting [preferredRadius] as an upper bound.
|
||||
*
|
||||
* [angleDegrees] is in screen space: 0° points up, positive is
|
||||
* clockwise. [minRadius] is a floor used only when the label's
|
||||
* reach is larger than the available room (a degenerate case we
|
||||
* shouldn't hit for real viewport sizes). Pure function; exposed
|
||||
* as `internal` so it can be unit-tested on the JVM.
|
||||
*/
|
||||
internal fun clampIndicatorRadius(
|
||||
cx: Float,
|
||||
cy: Float,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
angleDegrees: Float,
|
||||
preferredRadius: Float,
|
||||
labelReach: Float,
|
||||
minRadius: Float
|
||||
): Float {
|
||||
val thetaRad = Math.toRadians(angleDegrees.toDouble())
|
||||
val dx = sin(thetaRad).toFloat()
|
||||
val dy = -cos(thetaRad).toFloat()
|
||||
val tHoriz = when {
|
||||
dx > 0f -> (viewWidth - cx) / dx
|
||||
dx < 0f -> -cx / dx
|
||||
else -> Float.POSITIVE_INFINITY
|
||||
}
|
||||
val tVert = when {
|
||||
dy > 0f -> (viewHeight - cy) / dy
|
||||
dy < 0f -> -cy / dy
|
||||
else -> Float.POSITIVE_INFINITY
|
||||
}
|
||||
val distanceToEdge = minOf(tHoriz, tVert)
|
||||
return minOf(preferredRadius, distanceToEdge - labelReach)
|
||||
.coerceAtLeast(minRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package no.naiv.tilfluktsrom.ui
|
|||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
|
|
@ -12,23 +11,12 @@ import no.naiv.tilfluktsrom.databinding.ItemShelterBinding
|
|||
import no.naiv.tilfluktsrom.location.ShelterWithDistance
|
||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||
|
||||
/**
|
||||
* One row in the bottom-sheet list. The list normally holds the N nearest
|
||||
* shelters to the user, but a deep-linked / explicitly-selected shelter that
|
||||
* is *not* among them is appended with isOutsideNearest=true so the user can
|
||||
* see what they picked. See Forgejo #13 / beads tilfluktsrom-9sf.
|
||||
*/
|
||||
data class ShelterListItem(
|
||||
val swd: ShelterWithDistance,
|
||||
val isOutsideNearest: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Adapter for the list of nearest shelters shown in the bottom sheet.
|
||||
*/
|
||||
class ShelterListAdapter(
|
||||
private val onShelterSelected: (ShelterWithDistance) -> Unit
|
||||
) : ListAdapter<ShelterListItem, ShelterListAdapter.ViewHolder>(DIFF_CALLBACK) {
|
||||
) : ListAdapter<ShelterWithDistance, ShelterListAdapter.ViewHolder>(DIFF_CALLBACK) {
|
||||
|
||||
private var selectedPosition = 0
|
||||
|
||||
|
|
@ -54,34 +42,23 @@ class ShelterListAdapter(
|
|||
private val binding: ItemShelterBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: ShelterListItem, isSelected: Boolean) {
|
||||
fun bind(item: ShelterWithDistance, isSelected: Boolean) {
|
||||
val ctx = binding.root.context
|
||||
val swd = item.swd
|
||||
binding.shelterAddress.text = swd.shelter.adresse
|
||||
binding.shelterDistance.text = DistanceUtils.formatDistance(swd.distanceMeters)
|
||||
binding.shelterAddress.text = item.shelter.adresse
|
||||
binding.shelterDistance.text = DistanceUtils.formatDistance(item.distanceMeters)
|
||||
binding.shelterCapacity.text = ctx.getString(
|
||||
R.string.shelter_capacity, swd.shelter.plasser
|
||||
R.string.shelter_capacity, item.shelter.plasser
|
||||
)
|
||||
binding.shelterRoomNr.text = ctx.getString(
|
||||
R.string.shelter_room_nr, swd.shelter.romnr
|
||||
R.string.shelter_room_nr, item.shelter.romnr
|
||||
)
|
||||
|
||||
binding.outsideNearestBadge.visibility =
|
||||
if (item.isOutsideNearest) View.VISIBLE else View.GONE
|
||||
|
||||
// Build accessible description; suffix the badge text so screen-
|
||||
// reader users learn the same context that sighted users see.
|
||||
val baseDesc = ctx.getString(
|
||||
binding.root.contentDescription = ctx.getString(
|
||||
R.string.content_desc_shelter_item,
|
||||
swd.shelter.adresse,
|
||||
DistanceUtils.formatDistance(swd.distanceMeters),
|
||||
swd.shelter.plasser
|
||||
item.shelter.adresse,
|
||||
DistanceUtils.formatDistance(item.distanceMeters),
|
||||
item.shelter.plasser
|
||||
)
|
||||
binding.root.contentDescription = if (item.isOutsideNearest) {
|
||||
ctx.getString(R.string.shelter_outside_nearest_badge) + ". " + baseDesc
|
||||
} else {
|
||||
baseDesc
|
||||
}
|
||||
|
||||
binding.root.isSelected = isSelected
|
||||
binding.root.alpha = if (isSelected) 1.0f else 0.7f
|
||||
|
|
@ -91,18 +68,18 @@ class ShelterListAdapter(
|
|||
val pos = adapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) {
|
||||
selectPosition(pos)
|
||||
onShelterSelected(getItem(pos).swd)
|
||||
onShelterSelected(getItem(pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ShelterListItem>() {
|
||||
override fun areItemsTheSame(a: ShelterListItem, b: ShelterListItem) =
|
||||
a.swd.shelter.lokalId == b.swd.shelter.lokalId
|
||||
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ShelterWithDistance>() {
|
||||
override fun areItemsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) =
|
||||
a.shelter.lokalId == b.shelter.lokalId
|
||||
|
||||
override fun areContentsTheSame(a: ShelterListItem, b: ShelterListItem) =
|
||||
override fun areContentsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) =
|
||||
a == b
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- User direction marker (moving): orange chevron with white halo + shadow.
|
||||
Points up at 0deg; OSMDroid rotates by bearing. The triple stack (shadow,
|
||||
white outline, orange fill) ensures the silhouette is visible against
|
||||
any tile theme - addresses Forgejo #16. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:fillColor="#55000000"
|
||||
android:pathData="M16,2.5 L29.5,29 L16,22.5 L2.5,29 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M16,3 L29,28.5 L16,22 L3,28.5 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFF6B35"
|
||||
android:pathData="M16,7 L25.5,26 L16,21.5 L6.5,26 Z" />
|
||||
</vector>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- User location marker (stationary): orange dot, white halo, soft shadow.
|
||||
White ring guarantees >=3:1 contrast against dark tiles; orange core
|
||||
stays visible against light tiles; shadow keeps the silhouette readable
|
||||
when both layers happen to land on near-white snow/sand. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#33000000"
|
||||
android:pathData="M12,12 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0 z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,12 m-9.5,0 a9.5,9.5 0 1,0 19,0 a9.5,9.5 0 1,0 -19,0 z" />
|
||||
<path
|
||||
android:fillColor="#FFFF6B35"
|
||||
android:pathData="M12,12 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0 z" />
|
||||
</vector>
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:contentDescription="@string/a11y_map"
|
||||
app:layout_constraintTop_toBottomOf="@id/noLocationBanner"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomSheet" />
|
||||
|
||||
<!-- Direction arrow overlay (shown when toggled) -->
|
||||
|
|
@ -87,7 +87,7 @@
|
|||
android:background="@color/compass_bg"
|
||||
android:contentDescription="@string/a11y_compass"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/noLocationBanner"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomSheet">
|
||||
|
||||
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
||||
|
|
@ -149,41 +149,6 @@
|
|||
app:backgroundTint="@color/shelter_primary"
|
||||
app:tint="@color/white" />
|
||||
|
||||
<!-- Warning banner: location unavailable (permission denied or services off).
|
||||
Placed at the top of the content area (below the status bar) so it never
|
||||
collides with the floating action buttons anchored above the bottom sheet. -->
|
||||
<LinearLayout
|
||||
android:id="@+id/noLocationBanner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/warning_bg"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusBar">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/locationBannerText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="@color/warning_text"
|
||||
android:textSize="12sp"
|
||||
tools:text="@string/status_location_permission_needed" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/locationBannerAction"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:textColor="@color/warning_text"
|
||||
android:textSize="12sp"
|
||||
android:minHeight="0dp"
|
||||
tools:text="@string/action_grant_permission" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Warning banner: no offline map cache -->
|
||||
<LinearLayout
|
||||
android:id="@+id/noCacheBanner"
|
||||
|
|
|
|||
|
|
@ -60,20 +60,11 @@
|
|||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/about_data_body"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/about_data_links"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textColorLink="@color/shelter_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<!-- Stored on device section -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
|||
|
|
@ -9,21 +9,6 @@
|
|||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/outsideNearestBadge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:background="@color/warning_bg"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="2dp"
|
||||
android:text="@string/shelter_outside_nearest_badge"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/shelterAddress"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
|||
74
app/src/main/res/layout/widget_nearest_shelter.xml
Normal file
74
app/src/main/res/layout/widget_nearest_shelter.xml
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/widgetRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetIcon"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/nearest_shelter"
|
||||
android:src="@drawable/ic_shelter" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widgetAddress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Storgata 1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widgetDetails"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
tools:text="400 places" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widgetTimestamp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="9sp"
|
||||
tools:text="14:32" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widgetDistance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:textColor="@color/shelter_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="1.2 km" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetRefreshButton"
|
||||
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>
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
<!-- Tilfluktsrominfo -->
|
||||
<string name="shelter_capacity">%d plasser</string>
|
||||
<string name="shelter_room_nr">Rom %d</string>
|
||||
<string name="nearest_shelter">Nærmeste tilfluktsrom</string>
|
||||
<string name="no_shelters">Ingen tilfluktsromdata tilgjengelig</string>
|
||||
|
||||
<!-- Handlinger -->
|
||||
|
|
@ -30,11 +31,7 @@
|
|||
<string name="action_cache_now">Lagre nå</string>
|
||||
<string name="action_reset_navigation">Tilbakestill navigasjonsvisning</string>
|
||||
<string name="action_share">Del tilfluktsrom</string>
|
||||
<string name="action_grant_permission">Gi tilgang</string>
|
||||
<string name="action_location_settings">Aktiver</string>
|
||||
<string name="warning_no_map_cache">Ingen frakoblet kart lagret. Kartet krever internett.</string>
|
||||
<string name="status_location_permission_needed">Posisjonstilgang nødvendig for å finne nærmeste tilfluktsrom. Du kan også trykke på et merke i kartet.</string>
|
||||
<string name="status_location_services_off">Stedstjenester er slått av. Aktiver dem eller velg et tilfluktsrom fra kartet.</string>
|
||||
|
||||
<!-- Tillatelser -->
|
||||
<string name="permission_location_title">Posisjonstillatelse kreves</string>
|
||||
|
|
@ -49,6 +46,13 @@
|
|||
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
||||
<string name="update_failed">Oppdatering mislyktes — bruker lagrede data</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">Viser n\u00e6rmeste tilfluktsrom med avstand</string>
|
||||
<string name="widget_open_app">\u00c5pne appen for posisjon</string>
|
||||
<string name="widget_no_data">Ingen tilfluktsromdata</string>
|
||||
<string name="widget_no_location">Trykk for \u00e5 oppdatere</string>
|
||||
<string name="widget_updated_at">Oppdatert %s</string>
|
||||
|
||||
<!-- Dataferskhet -->
|
||||
<string name="freshness_fresh">Data er oppdatert</string>
|
||||
<string name="freshness_week">Data er %d dager gammel</string>
|
||||
|
|
@ -62,7 +66,6 @@
|
|||
<!-- 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="shelter_outside_nearest_badge">Valgt – utenfor nærområdet</string>
|
||||
<string name="compass_accuracy_warning">Upresist kompass - %s</string>
|
||||
<string name="a11y_map">Tilfluktsromkart</string>
|
||||
<string name="a11y_compass">Kompassnavigasjon</string>
|
||||
|
|
@ -98,9 +101,8 @@
|
|||
<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_data_links"><![CDATA[Mer om datasettet:<br/>• <a href="https://kartkatalog.geonorge.no/metadata/tilfluktsrom-offentlige/dbae9aae-10e7-4b75-8d67-7f0e8828f3d8">Geonorge kartkatalog</a><br/>• <a href="https://register.geonorge.no/register/versjoner/produktark/direktoratet-for-samfunnssikkerhet-og-beredskap/tilfluktsrom-offentlige">Produktark (DSB)</a>]]></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\nIngen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser.</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
<!-- Tilfluktsrominfo -->
|
||||
<string name="shelter_capacity">%d plassar</string>
|
||||
<string name="shelter_room_nr">Rom %d</string>
|
||||
<string name="nearest_shelter">Næraste tilfluktsrom</string>
|
||||
<string name="no_shelters">Ingen tilfluktsromdata tilgjengeleg</string>
|
||||
|
||||
<!-- Handlingar -->
|
||||
|
|
@ -30,11 +31,7 @@
|
|||
<string name="action_cache_now">Lagre no</string>
|
||||
<string name="action_reset_navigation">Tilbakestill navigasjonsvising</string>
|
||||
<string name="action_share">Del tilfluktsrom</string>
|
||||
<string name="action_grant_permission">Gje tilgang</string>
|
||||
<string name="action_location_settings">Aktiver</string>
|
||||
<string name="warning_no_map_cache">Ingen fråkopla kart lagra. Kartet krev internett.</string>
|
||||
<string name="status_location_permission_needed">Posisjonstilgang trengst for å finne næraste tilfluktsrom. Du kan òg trykke på eit merke i kartet.</string>
|
||||
<string name="status_location_services_off">Stedstenester er slått av. Aktiver dei eller vel eit tilfluktsrom frå kartet.</string>
|
||||
|
||||
<!-- Løyve -->
|
||||
<string name="permission_location_title">Posisjonsløyve krevst</string>
|
||||
|
|
@ -49,6 +46,13 @@
|
|||
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
||||
<string name="update_failed">Oppdatering mislukkast — brukar lagra data</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">Viser n\u00e6raste tilfluktsrom med avstand</string>
|
||||
<string name="widget_open_app">Opne appen for posisjon</string>
|
||||
<string name="widget_no_data">Ingen tilfluktsromdata</string>
|
||||
<string name="widget_no_location">Trykk for \u00e5 oppdatere</string>
|
||||
<string name="widget_updated_at">Oppdatert %s</string>
|
||||
|
||||
<!-- Dataferskheit -->
|
||||
<string name="freshness_fresh">Data er oppdatert</string>
|
||||
<string name="freshness_week">Data er %d dagar gammal</string>
|
||||
|
|
@ -62,7 +66,6 @@
|
|||
<!-- 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="shelter_outside_nearest_badge">Vald – utanfor nærområdet</string>
|
||||
<string name="compass_accuracy_warning">Upresis kompass - %s</string>
|
||||
<string name="a11y_map">Tilfluktsromkart</string>
|
||||
<string name="a11y_compass">Kompassnavigasjon</string>
|
||||
|
|
@ -98,9 +101,8 @@
|
|||
<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_data_links"><![CDATA[Meir om datasettet:<br/>• <a href="https://kartkatalog.geonorge.no/metadata/tilfluktsrom-offentlige/dbae9aae-10e7-4b75-8d67-7f0e8828f3d8">Geonorge kartkatalog</a><br/>• <a href="https://register.geonorge.no/register/versjoner/produktark/direktoratet-for-samfunnssikkerhet-og-beredskap/tilfluktsrom-offentlige">Produktark (DSB)</a>]]></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\nIngen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser.</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
<!-- Shelter info -->
|
||||
<string name="shelter_capacity">%d places</string>
|
||||
<string name="shelter_room_nr">Room %d</string>
|
||||
<string name="nearest_shelter">Nearest shelter</string>
|
||||
<string name="no_shelters">No shelter data available</string>
|
||||
|
||||
<!-- Actions -->
|
||||
|
|
@ -30,11 +31,7 @@
|
|||
<string name="action_cache_now">Cache now</string>
|
||||
<string name="action_reset_navigation">Reset navigation view</string>
|
||||
<string name="action_share">Share shelter</string>
|
||||
<string name="action_grant_permission">Grant access</string>
|
||||
<string name="action_location_settings">Enable</string>
|
||||
<string name="warning_no_map_cache">No offline map cached. Map requires internet.</string>
|
||||
<string name="status_location_permission_needed">Location access needed to find the nearest shelter. You can also tap a marker on the map.</string>
|
||||
<string name="status_location_services_off">Location services are off. Enable them or pick a shelter from the map.</string>
|
||||
|
||||
<!-- Permissions -->
|
||||
<string name="permission_location_title">Location permission required</string>
|
||||
|
|
@ -49,6 +46,13 @@
|
|||
<string name="update_success">Shelter data updated</string>
|
||||
<string name="update_failed">Update failed — using cached data</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">Shows nearest shelter with distance</string>
|
||||
<string name="widget_open_app">Open app for location</string>
|
||||
<string name="widget_no_data">No shelter data</string>
|
||||
<string name="widget_no_location">Tap to refresh</string>
|
||||
<string name="widget_updated_at">Updated %s</string>
|
||||
|
||||
<!-- Data freshness -->
|
||||
<string name="freshness_fresh">Data is up to date</string>
|
||||
<string name="freshness_week">Data is %d days old</string>
|
||||
|
|
@ -62,9 +66,6 @@
|
|||
<!-- 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>
|
||||
<!-- Badge shown on a list row that is not among the nearest shelters but
|
||||
was explicitly selected (e.g. via a deep link). Forgejo #13. -->
|
||||
<string name="shelter_outside_nearest_badge">Selected (outside nearest)</string>
|
||||
<string name="compass_accuracy_warning">Low accuracy - %s</string>
|
||||
<string name="a11y_map">Shelter map</string>
|
||||
<string name="a11y_compass">Compass navigation</string>
|
||||
|
|
@ -101,9 +102,8 @@
|
|||
<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_data_links"><![CDATA[Learn more about the dataset:<br/>• <a href="https://kartkatalog.geonorge.no/metadata/tilfluktsrom-offentlige/dbae9aae-10e7-4b75-8d67-7f0e8828f3d8">Geonorge metadata catalogue</a><br/>• <a href="https://register.geonorge.no/register/versjoner/produktark/direktoratet-for-samfunnssikkerhet-og-beredskap/tilfluktsrom-offentlige">DSB product sheet</a>]]></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\nNo data leaves your device except requests to download shelter data and map tiles.</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>
|
||||
|
||||
|
|
|
|||
9
app/src/main/res/xml/widget_info.xml
Normal file
9
app/src/main/res/xml/widget_info.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="40dp"
|
||||
android:updatePeriodMillis="0"
|
||||
android:initialLayout="@layout/widget_nearest_shelter"
|
||||
android:resizeMode="horizontal"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_description" />
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
package no.naiv.tilfluktsrom.widget
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.text.format.DateFormat
|
||||
import android.util.Log
|
||||
import android.widget.RemoteViews
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import no.naiv.tilfluktsrom.MainActivity
|
||||
import no.naiv.tilfluktsrom.R
|
||||
import no.naiv.tilfluktsrom.data.ShelterDatabase
|
||||
import no.naiv.tilfluktsrom.location.ShelterFinder
|
||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Home screen widget showing the nearest shelter with distance.
|
||||
*
|
||||
* Update strategy:
|
||||
* - Background: WorkManager runs every 15 min while widget exists
|
||||
* - Live: MainActivity sends ACTION_REFRESH on each GPS location update
|
||||
* - Manual: user taps the refresh button on the widget
|
||||
*
|
||||
* Location resolution (in priority order):
|
||||
* 1. Location provided via intent extras (from WorkManager or MainActivity)
|
||||
* 2. FusedLocationProviderClient cache/active request (Play Services)
|
||||
* 3. LocationManager cache/active request (AOSP fallback)
|
||||
* 4. Last GPS fix saved to SharedPreferences by MainActivity
|
||||
*
|
||||
* Note: Background processes cannot reliably trigger GPS hardware on
|
||||
* Android 8+. The SharedPreferences fallback ensures the widget works
|
||||
* after app updates and reboots without opening the app first.
|
||||
*/
|
||||
class ShelterWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ShelterWidget"
|
||||
const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH"
|
||||
private const val EXTRA_LATITUDE = "lat"
|
||||
private const val EXTRA_LONGITUDE = "lon"
|
||||
|
||||
/** Trigger a widget refresh from anywhere (e.g. MainActivity on location update). */
|
||||
fun requestUpdate(context: Context) {
|
||||
val intent = Intent(context, ShelterWidgetProvider::class.java).apply {
|
||||
action = ACTION_REFRESH
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/** Trigger a widget refresh with a known location (from WidgetUpdateWorker). */
|
||||
fun requestUpdateWithLocation(context: Context, latitude: Double, longitude: Double) {
|
||||
val intent = Intent(context, ShelterWidgetProvider::class.java).apply {
|
||||
action = ACTION_REFRESH
|
||||
putExtra(EXTRA_LATITUDE, latitude)
|
||||
putExtra(EXTRA_LONGITUDE, longitude)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetUpdateWorker.schedule(context)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetUpdateWorker.cancel(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
WidgetUpdateWorker.schedule(context)
|
||||
updateAllWidgetsAsync(context, null)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
|
||||
if (intent.action == ACTION_REFRESH) {
|
||||
val providedLocation = if (intent.hasExtra(EXTRA_LATITUDE)) {
|
||||
Location("widget").apply {
|
||||
latitude = intent.getDoubleExtra(EXTRA_LATITUDE, 0.0)
|
||||
longitude = intent.getDoubleExtra(EXTRA_LONGITUDE, 0.0)
|
||||
}
|
||||
} else null
|
||||
|
||||
updateAllWidgetsAsync(context, providedLocation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run widget update on a background thread so we can call
|
||||
* FusedLocationProviderClient.getLastLocation() synchronously.
|
||||
* Uses goAsync() to keep the BroadcastReceiver alive until done.
|
||||
*/
|
||||
private fun updateAllWidgetsAsync(context: Context, providedLocation: Location?) {
|
||||
val pendingResult = goAsync()
|
||||
Thread {
|
||||
try {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val widgetIds = appWidgetManager.getAppWidgetIds(
|
||||
ComponentName(context, ShelterWidgetProvider::class.java)
|
||||
)
|
||||
val location = providedLocation ?: getBestLocation(context)
|
||||
for (appWidgetId in widgetIds) {
|
||||
updateWidget(context, appWidgetManager, appWidgetId, location)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to update widgets", e)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun updateWidget(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetId: Int,
|
||||
location: Location?
|
||||
) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter)
|
||||
|
||||
// Tapping widget body opens the app
|
||||
val openAppIntent = Intent(context, MainActivity::class.java)
|
||||
val openAppPending = PendingIntent.getActivity(
|
||||
context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.widgetRoot, openAppPending)
|
||||
|
||||
// Refresh button sends our custom broadcast
|
||||
val refreshIntent = Intent(context, ShelterWidgetProvider::class.java).apply {
|
||||
action = ACTION_REFRESH
|
||||
}
|
||||
val refreshPending = PendingIntent.getBroadcast(
|
||||
context, 0, refreshIntent, PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending)
|
||||
|
||||
// Check permission
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
showFallback(context, views, context.getString(R.string.widget_open_app))
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
return
|
||||
}
|
||||
|
||||
if (location == null) {
|
||||
showFallback(context, views, context.getString(R.string.widget_no_location))
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
return
|
||||
}
|
||||
|
||||
// Query shelters from Room (fast: ~556 rows, <10ms)
|
||||
val shelters = try {
|
||||
val dao = ShelterDatabase.getInstance(context).shelterDao()
|
||||
kotlinx.coroutines.runBlocking { dao.getAllSheltersList() }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to query shelters", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
if (shelters.isEmpty()) {
|
||||
showFallback(context, views, context.getString(R.string.widget_no_data))
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
return
|
||||
}
|
||||
|
||||
// Find nearest shelter
|
||||
val nearest = ShelterFinder.findNearest(
|
||||
shelters, location.latitude, location.longitude, 1
|
||||
).firstOrNull()
|
||||
|
||||
if (nearest == null) {
|
||||
showFallback(context, views, context.getString(R.string.widget_no_data))
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
return
|
||||
}
|
||||
|
||||
// Show shelter info
|
||||
views.setTextViewText(R.id.widgetAddress, nearest.shelter.adresse)
|
||||
views.setTextViewText(
|
||||
R.id.widgetDetails,
|
||||
context.getString(R.string.shelter_capacity, nearest.shelter.plasser)
|
||||
)
|
||||
views.setTextViewText(
|
||||
R.id.widgetDistance,
|
||||
DistanceUtils.formatDistance(nearest.distanceMeters)
|
||||
)
|
||||
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
|
||||
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
|
||||
/** Show a fallback message when location or data is unavailable. */
|
||||
private fun showFallback(context: Context, views: RemoteViews, message: String) {
|
||||
views.setTextViewText(R.id.widgetAddress, message)
|
||||
views.setTextViewText(R.id.widgetDetails, "")
|
||||
views.setTextViewText(R.id.widgetDistance, "")
|
||||
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
|
||||
}
|
||||
|
||||
/** Format current time as "Updated HH:mm", respecting the user's 12/24h preference. */
|
||||
private fun formatTimestamp(context: Context): String {
|
||||
val format = DateFormat.getTimeFormat(context)
|
||||
val timeStr = format.format(System.currentTimeMillis())
|
||||
return context.getString(R.string.widget_updated_at, timeStr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available location.
|
||||
* Tries FusedLocationProviderClient first (Play Services, better cache),
|
||||
* then LocationManager (AOSP), then last saved GPS fix from SharedPreferences.
|
||||
* Safe to call from a background thread.
|
||||
*/
|
||||
private fun getBestLocation(context: Context): Location? {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) return null
|
||||
|
||||
// Try Play Services first — maintains a better location cache
|
||||
val fusedLocation = getFusedLastLocation(context)
|
||||
if (fusedLocation != null) return fusedLocation
|
||||
|
||||
// Fall back to LocationManager
|
||||
val lmLocation = getLocationManagerLocation(context)
|
||||
if (lmLocation != null) return lmLocation
|
||||
|
||||
// Fall back to last location saved by MainActivity
|
||||
return getSavedLocation(context)
|
||||
}
|
||||
|
||||
/** Read the last GPS fix persisted by MainActivity to SharedPreferences.
|
||||
* Returns null if older than 24 hours to avoid retaining stale location data. */
|
||||
private fun getSavedLocation(context: Context): Location? {
|
||||
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
|
||||
if (!prefs.contains("last_lat")) return null
|
||||
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
|
||||
if (age > 24 * 60 * 60 * 1000L) return null
|
||||
return Location("saved").apply {
|
||||
latitude = prefs.getFloat("last_lat", 0f).toDouble()
|
||||
longitude = prefs.getFloat("last_lon", 0f).toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location via Play Services — blocks, call from background thread.
|
||||
* Tries cached location first, then actively requests a fix if cache is empty.
|
||||
*/
|
||||
private fun getFusedLastLocation(context: Context): Location? {
|
||||
if (!isPlayServicesAvailable(context)) return null
|
||||
return try {
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
// Try cache first (instant)
|
||||
val cached = Tasks.await(client.lastLocation, 3, TimeUnit.SECONDS)
|
||||
if (cached != null) return cached
|
||||
// Cache empty — actively request a fix (turns on GPS/network)
|
||||
val task = client.getCurrentLocation(
|
||||
Priority.PRIORITY_BALANCED_POWER_ACCURACY, null
|
||||
)
|
||||
Tasks.await(task, 10, TimeUnit.SECONDS)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "FusedLocationProvider failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location via LocationManager (AOSP).
|
||||
* Tries cache first, then actively requests a fix on API 30+.
|
||||
* Blocks — call from background thread.
|
||||
*/
|
||||
private fun getLocationManagerLocation(context: Context): Location? {
|
||||
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
|
||||
as? LocationManager ?: return null
|
||||
|
||||
// Try cache first
|
||||
try {
|
||||
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||
val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
|
||||
val cached = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time }
|
||||
if (cached != null) return cached
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException getting last known location", e)
|
||||
return null
|
||||
}
|
||||
|
||||
// Cache empty — actively request on API 30+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val provider = when {
|
||||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
|
||||
LocationManager.NETWORK_PROVIDER
|
||||
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
|
||||
LocationManager.GPS_PROVIDER
|
||||
else -> return null
|
||||
}
|
||||
try {
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
var result: Location? = null
|
||||
val signal = CancellationSignal()
|
||||
locationManager.getCurrentLocation(
|
||||
provider, signal, context.mainExecutor
|
||||
) { location ->
|
||||
result = location
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
signal.cancel()
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Active location request failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isPlayServicesAvailable(context: Context): Boolean {
|
||||
return try {
|
||||
val result = GoogleApiAvailability.getInstance()
|
||||
.isGooglePlayServicesAvailable(context)
|
||||
result == ConnectionResult.SUCCESS
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
package no.naiv.tilfluktsrom.widget
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Periodic background worker that refreshes the home screen widget.
|
||||
*
|
||||
* Scheduled every 15 minutes (WorkManager's minimum interval).
|
||||
* Actively requests a fresh location fix to populate the system cache,
|
||||
* then triggers the widget's existing update logic via broadcast.
|
||||
*
|
||||
* Location strategy (mirrors LocationProvider):
|
||||
* - Play Services: FusedLocationProviderClient.getCurrentLocation()
|
||||
* - AOSP API 30+: LocationManager.getCurrentLocation()
|
||||
* - AOSP API 26-29: LocationManager.getLastKnownLocation()
|
||||
*/
|
||||
class WidgetUpdateWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WidgetUpdateWorker"
|
||||
private const val WORK_NAME = "widget_update"
|
||||
private const val LOCATION_TIMEOUT_MS = 10_000L
|
||||
|
||||
/** Schedule periodic widget updates. Safe to call multiple times. */
|
||||
fun schedule(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<WidgetUpdateWorker>(
|
||||
15, TimeUnit.MINUTES
|
||||
).build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
/** Run once immediately (e.g. when widget is first placed or location was unavailable). */
|
||||
fun runOnce(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<WidgetUpdateWorker>().build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
|
||||
/** Cancel periodic updates (e.g. when all widgets are removed). */
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val location = requestFreshLocation() ?: getSavedLocation()
|
||||
if (location != null) {
|
||||
ShelterWidgetProvider.requestUpdateWithLocation(
|
||||
applicationContext, location.latitude, location.longitude
|
||||
)
|
||||
} else {
|
||||
ShelterWidgetProvider.requestUpdate(applicationContext)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
/** Read the last GPS fix persisted by MainActivity.
|
||||
* Returns null if older than 24 hours. */
|
||||
private fun getSavedLocation(): Location? {
|
||||
val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
|
||||
if (!prefs.contains("last_lat")) return null
|
||||
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
|
||||
if (age > 24 * 60 * 60 * 1000L) return null
|
||||
return Location("saved").apply {
|
||||
latitude = prefs.getFloat("last_lat", 0f).toDouble()
|
||||
longitude = prefs.getFloat("last_lon", 0f).toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actively request a location fix and return it.
|
||||
* Returns null if permission is missing or location is unavailable.
|
||||
*/
|
||||
private suspend fun requestFreshLocation(): Location? {
|
||||
val context = applicationContext
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) return null
|
||||
|
||||
return if (isPlayServicesAvailable()) {
|
||||
requestViaPlayServices()
|
||||
} else {
|
||||
requestViaLocationManager()
|
||||
}
|
||||
}
|
||||
|
||||
/** Use FusedLocationProviderClient.getCurrentLocation() — best accuracy, best cache. */
|
||||
private suspend fun requestViaPlayServices(): Location? {
|
||||
return try {
|
||||
val client = LocationServices.getFusedLocationProviderClient(applicationContext)
|
||||
val task = client.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
|
||||
Tasks.await(task, LOCATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException requesting location via Play Services", e)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Play Services location request failed, falling back", e)
|
||||
requestViaLocationManager()
|
||||
}
|
||||
}
|
||||
|
||||
/** Use LocationManager.getCurrentLocation() (API 30+) or getLastKnownLocation() fallback. */
|
||||
private suspend fun requestViaLocationManager(): Location? {
|
||||
val locationManager = applicationContext.getSystemService(Context.LOCATION_SERVICE)
|
||||
as? LocationManager ?: return null
|
||||
|
||||
val provider = when {
|
||||
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
|
||||
LocationManager.GPS_PROVIDER
|
||||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
|
||||
LocationManager.NETWORK_PROVIDER
|
||||
else -> return null
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return requestCurrentLocation(locationManager, provider)
|
||||
}
|
||||
// API 26-29: fall back to passive cache
|
||||
return try {
|
||||
locationManager.getLastKnownLocation(provider)
|
||||
} catch (e: SecurityException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** API 30+: actively request a single location fix. */
|
||||
private suspend fun requestCurrentLocation(locationManager: LocationManager, provider: String): Location? {
|
||||
return try {
|
||||
withTimeoutOrNull(LOCATION_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine<Location?> { cont ->
|
||||
val signal = CancellationSignal()
|
||||
locationManager.getCurrentLocation(
|
||||
provider,
|
||||
signal,
|
||||
applicationContext.mainExecutor
|
||||
) { location ->
|
||||
if (cont.isActive) cont.resume(location)
|
||||
}
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException requesting location via LocationManager", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPlayServicesAvailable(): Boolean {
|
||||
return try {
|
||||
val result = GoogleApiAvailability.getInstance()
|
||||
.isGooglePlayServicesAvailable(applicationContext)
|
||||
result == ConnectionResult.SUCCESS
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -95,105 +95,4 @@ class DirectionArrowViewTest {
|
|||
// Just past full rotation wraps to forward.
|
||||
assertEquals(0f, DirectionArrowView.snapToSector(359.9f), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampIndicatorRadius_returnsPreferredWhenViewportIsBig() {
|
||||
// Viewport generous enough to fit the preferred radius + label
|
||||
// at any angle.
|
||||
val r = DirectionArrowView.clampIndicatorRadius(
|
||||
cx = 500f, cy = 500f, viewWidth = 1000, viewHeight = 1000,
|
||||
angleDegrees = 45f,
|
||||
preferredRadius = 100f, labelReach = 20f, minRadius = 30f
|
||||
)
|
||||
assertEquals(100f, r, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampIndicatorRadius_clampsWhenNorthPointsToShortAxis() {
|
||||
// Portrait-ish compass viewport (1080 × 2400), facing east.
|
||||
// Horizontal half-width = 540, label reach = 100 → max radius 440.
|
||||
val r = DirectionArrowView.clampIndicatorRadius(
|
||||
cx = 540f, cy = 1200f, viewWidth = 1080, viewHeight = 2400,
|
||||
angleDegrees = 90f,
|
||||
preferredRadius = 600f, labelReach = 100f, minRadius = 30f
|
||||
)
|
||||
assertEquals(440f, r, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampIndicatorRadius_usesPreferredWhenVerticalAxisIsLonger() {
|
||||
// Same portrait viewport, facing north. Vertical half-height is
|
||||
// 1200, so preferred radius 600 fits comfortably.
|
||||
val r = DirectionArrowView.clampIndicatorRadius(
|
||||
cx = 540f, cy = 1200f, viewWidth = 1080, viewHeight = 2400,
|
||||
angleDegrees = 0f,
|
||||
preferredRadius = 600f, labelReach = 100f, minRadius = 30f
|
||||
)
|
||||
assertEquals(600f, r, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampIndicatorRadius_diagonalGetsMoreRoomThanAxial() {
|
||||
// On a square viewport, the distance to the corner (√2 × half)
|
||||
// exceeds the axial distance. Used as a regression check that we
|
||||
// don't accidentally clamp by min(width, height) regardless of
|
||||
// angle.
|
||||
val axial = DirectionArrowView.clampIndicatorRadius(
|
||||
cx = 500f, cy = 500f, viewWidth = 1000, viewHeight = 1000,
|
||||
angleDegrees = 90f,
|
||||
preferredRadius = 10_000f, labelReach = 0f, minRadius = 0f
|
||||
)
|
||||
val diagonal = DirectionArrowView.clampIndicatorRadius(
|
||||
cx = 500f, cy = 500f, viewWidth = 1000, viewHeight = 1000,
|
||||
angleDegrees = 45f,
|
||||
preferredRadius = 10_000f, labelReach = 0f, minRadius = 0f
|
||||
)
|
||||
assertEquals(500f, axial, 0.01f)
|
||||
assertEquals(707.11f, diagonal, 0.1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampIndicatorRadius_enforcesMinRadiusWhenLabelCantFit() {
|
||||
// Viewport far too small to fit the requested label. Clamp to the
|
||||
// minimum instead of returning a negative radius.
|
||||
val r = DirectionArrowView.clampIndicatorRadius(
|
||||
cx = 50f, cy = 50f, viewWidth = 100, viewHeight = 100,
|
||||
angleDegrees = 0f,
|
||||
preferredRadius = 40f, labelReach = 200f, minRadius = 10f
|
||||
)
|
||||
assertEquals(10f, r, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampIndicatorRadius_stayInsideViewport_sweep() {
|
||||
// For every 5° step, the rendered indicator (at `radius + labelReach`
|
||||
// from the centre) must remain inside the viewport rectangle. This
|
||||
// is the invariant the user reported being violated.
|
||||
val cx = 540f
|
||||
val cy = 711f
|
||||
val w = 1080
|
||||
val h = 1422
|
||||
val labelReach = 170f
|
||||
var angle = 0f
|
||||
while (angle < 360f) {
|
||||
val radius = DirectionArrowView.clampIndicatorRadius(
|
||||
cx, cy, w, h, angle,
|
||||
preferredRadius = 583f, labelReach = labelReach, minRadius = 30f
|
||||
)
|
||||
val outermost = radius + labelReach
|
||||
val thetaRad = Math.toRadians(angle.toDouble())
|
||||
val px = cx + (outermost * Math.sin(thetaRad)).toFloat()
|
||||
val py = cy - (outermost * Math.cos(thetaRad)).toFloat()
|
||||
// Allow ~1 px of slack so float precision at the exact edge
|
||||
// (e.g. 540 * sin(240°) rounding to -5.4e-5 instead of 0) is
|
||||
// not treated as a clipping regression.
|
||||
val slack = 1f
|
||||
val inBounds = px in -slack..(w + slack) && py in -slack..(h + slack)
|
||||
assertEquals(
|
||||
"indicator tip at angle $angle° landed at ($px, $py), outside [0,$w]×[0,$h]",
|
||||
true, inBounds
|
||||
)
|
||||
angle += 5f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
- Actionable banner when location is unavailable (permissions, disabled services, or no GPS fix)
|
||||
- Home screen widget removed
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
- Deep links now use the shelter room number, which is stable across data refreshes — links shared between devices work reliably
|
||||
- Refresh button shows a loading indicator and no longer appears to hang
|
||||
- "Cache map" prompt no longer reappears after you tap Skip
|
||||
- Refreshed bundled shelter data
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
- PWA-only release: share button is now visually consistent with the other status-bar icons, and updates apply automatically the next time you bring the app to the foreground (no manual reload needed)
|
||||
- No Android changes
|
||||
|
|
@ -5,6 +5,7 @@ Features:
|
|||
• Compass navigation — direction arrow points to the selected shelter
|
||||
• Offline map — map tiles are cached automatically for use without internet
|
||||
• Select any shelter — tap any marker on the map to navigate there
|
||||
• Home screen widget — shows nearest shelter at a glance
|
||||
• Share shelters — send shelter location to others via any app
|
||||
• Civil defense info — what to do if the alarm sounds
|
||||
• Multilingual — English, Bokmål, and Nynorsk
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
- Handlingsbanner når posisjon ikke er tilgjengelig (tillatelser, avslåtte stedstjenester eller manglende GPS-fix)
|
||||
- Hjemmeskjerm-widget fjernet
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
- Delingslenker bruker nå romnummeret, som er stabilt på tvers av dataoppdateringer — lenker som deles mellom enheter virker pålitelig
|
||||
- Oppdater-knappen viser en lasteindikator og ser ikke lenger ut til å henge
|
||||
- "Lagre kart"-spørsmålet dukker ikke lenger opp igjen etter at du har trykket Hopp over
|
||||
- Oppdatert pakket tilfluktsromdata
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
- PWA-utgivelse: del-knappen er visuelt konsistent med de andre ikonene i statuslinjen, og oppdateringer trer i kraft automatisk neste gang du henter appen i forgrunnen (ingen manuell omlasting)
|
||||
- Ingen Android-endringer
|
||||
|
|
@ -5,6 +5,7 @@ Funksjoner:
|
|||
• Kompassnavigasjon — retningspil som peker mot valgt tilfluktsrom
|
||||
• Frakoblet kart — kartfliser lagres automatisk for bruk uten nett
|
||||
• Velg fritt — trykk på en markør i kartet for å navigere dit
|
||||
• Hjemskjerm-widget — viser nærmeste tilfluktsrom med ett blikk
|
||||
• Del tilfluktsrom — send posisjon til andre via en hvilken som helst app
|
||||
• Sivilforsvarsinformasjon — hva du skal gjøre hvis alarmen går
|
||||
• Flerspråklig — engelsk, bokmål og nynorsk
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
- Handlingsbanner når posisjonen ikkje er tilgjengeleg (løyve, avslegne stadtenester eller manglande GPS-fix)
|
||||
- Heimeskjerm-widgeten er fjerna
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
- Delingslenker brukar no romnummeret, som er stabilt på tvers av dataoppdateringar — lenker som blir delte mellom einingar verkar pålitelig
|
||||
- Oppdater-knappen viser ein lasteindikator og ser ikkje lenger ut til å henge
|
||||
- "Lagre kart"-spørsmålet dukkar ikkje opp att etter at du har trykt Hopp over
|
||||
- Oppdatert pakka tilfluktsromdata
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
- PWA-utgjeving: del-knappen er visuelt jamn med dei andre ikona i statuslinja, og oppdateringar tek effekt automatisk neste gong du hentar appen i framgrunnen (inga manuell omlasting)
|
||||
- Ingen Android-endringar
|
||||
|
|
@ -5,6 +5,7 @@ Funksjonar:
|
|||
• Kompassnavigasjon — retningspil som peikar mot valt tilfluktsrom
|
||||
• Fråkopla kart — kartfliser lagrast automatisk for bruk utan nett
|
||||
• Vel fritt — trykk på ein markør i kartet for å navigere dit
|
||||
• Heimeskjerm-widget — viser næraste tilfluktsrom med eitt blikk
|
||||
• Del tilfluktsrom — send posisjon til andre via ei kva som helst app
|
||||
• Sivilforsvarsinformasjon — kva du skal gjere om alarmen går
|
||||
• Fleirspråkleg — engelsk, bokmål og nynorsk
|
||||
|
|
|
|||
|
|
@ -2,16 +2,9 @@
|
|||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- WCAG 1.4.4 Resize Text: do not pin maximum-scale or disable user-scalable.
|
||||
Leaflet handles its own touch gestures on the map; the rest of the page
|
||||
(status bar, bottom sheet, dialogs) must remain zoomable for low-vision users. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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" />
|
||||
<!-- iOS home-screen/PWA integration: run in standalone mode, dark status bar, correct label. -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Tilfluktsrom" />
|
||||
<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>
|
||||
|
|
@ -25,11 +18,6 @@
|
|||
<header id="status-bar" role="banner">
|
||||
<span id="status-text" aria-live="polite"></span>
|
||||
<button id="about-btn" aria-label="About">ℹ</button>
|
||||
<button id="share-btn" aria-label="Share shelter">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true" focusable="false">
|
||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="refresh-btn" aria-label="Refresh data">↻</button>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
173
pwa/src/app.ts
173
pwa/src/app.ts
|
|
@ -11,7 +11,6 @@ import type { Shelter, ShelterWithDistance, LatLon } from './types';
|
|||
import { t } from './i18n/i18n';
|
||||
import { formatDistance, distanceMeters, bearingDegrees } from './util/distance-utils';
|
||||
import { findNearest } from './location/shelter-finder';
|
||||
import { DEEP_LINK_DOMAIN } from './config';
|
||||
import * as repo from './data/shelter-repository';
|
||||
import * as locationProvider from './location/location-provider';
|
||||
import * as compassProvider from './location/compass-provider';
|
||||
|
|
@ -36,12 +35,6 @@ let firstLocationFix = true;
|
|||
// Track whether user manually selected a shelter (prevents auto-reselection
|
||||
// on location updates)
|
||||
let userSelectedShelter = false;
|
||||
// Romnr (DSB room number) of the user-selected shelter — survives location
|
||||
// updates that recompute nearestShelters (deep links, marker taps to a
|
||||
// far-away shelter), and *also* survives a forceRefresh() that replaces
|
||||
// every lokalId in the dataset. Romnr is the stable upstream business key;
|
||||
// see ARCHITECTURE.md → "Deep link identifier" for rationale.
|
||||
let selectedRomnr: number | null = null;
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
applyA11yLabels();
|
||||
|
|
@ -65,16 +58,21 @@ function applyA11yLabels(): void {
|
|||
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('share-btn')?.setAttribute('aria-label', t('action_share'));
|
||||
document.getElementById('toggle-fab')?.setAttribute('aria-label', t('action_toggle_view'));
|
||||
}
|
||||
|
||||
function setupMap(): void {
|
||||
const container = document.getElementById('map-container')!;
|
||||
mapView.initMap(container, (shelter: Shelter) => {
|
||||
// Marker click — select this shelter (route through selectShelterByData
|
||||
// so a tap on a far-away marker also survives location updates).
|
||||
selectShelterByData(shelter);
|
||||
// Marker click — select this shelter
|
||||
const idx = nearestShelters.findIndex(
|
||||
(s) => s.shelter.lokalId === shelter.lokalId,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
userSelectedShelter = true;
|
||||
selectedShelterIndex = idx;
|
||||
updateSelectedShelter(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +86,6 @@ function setupShelterList(): void {
|
|||
shelterList.initShelterList(container, (index: number) => {
|
||||
userSelectedShelter = true;
|
||||
selectedShelterIndex = index;
|
||||
selectedRomnr = nearestShelters[index]?.shelter.romnr ?? null;
|
||||
updateSelectedShelter(true);
|
||||
});
|
||||
}
|
||||
|
|
@ -104,14 +101,10 @@ function setupButtons(): void {
|
|||
const compassContainer = document.getElementById('compass-container')!;
|
||||
|
||||
if (isCompassMode) {
|
||||
// Request compass permission on first toggle (iOS requirement).
|
||||
// On denial we stay in map mode and surface a status message so the
|
||||
// user understands why nothing happened — silent reverting is the
|
||||
// failure mode the Android GPS-denied banner was designed to avoid.
|
||||
// Request compass permission on first toggle (iOS requirement)
|
||||
const granted = await compassProvider.requestPermission();
|
||||
if (!granted) {
|
||||
isCompassMode = false;
|
||||
statusBar.setStatus(t('compass_permission_denied'));
|
||||
return;
|
||||
}
|
||||
mapContainer.style.display = 'none';
|
||||
|
|
@ -135,14 +128,6 @@ function setupButtons(): void {
|
|||
// Refresh button
|
||||
statusBar.onRefreshClick(forceRefresh);
|
||||
|
||||
// Share button — emits the same HTTPS deep link the Android app uses,
|
||||
// so a recipient with the app installed (and verified App Links) opens
|
||||
// the shelter natively, otherwise it opens in the PWA.
|
||||
document.getElementById('share-btn')?.addEventListener('click', () => {
|
||||
navigator.vibrate?.(10);
|
||||
shareSelectedShelter();
|
||||
});
|
||||
|
||||
// Cache retry button
|
||||
const cacheRetryBtn = document.getElementById('cache-retry-btn')!;
|
||||
cacheRetryBtn.textContent = t('action_cache_now');
|
||||
|
|
@ -258,40 +243,8 @@ function updateNearestShelters(location: LatLon): void {
|
|||
NEAREST_COUNT,
|
||||
);
|
||||
|
||||
if (userSelectedShelter && selectedRomnr !== null) {
|
||||
// Preserve the user's chosen shelter (deep link, marker click, list tap)
|
||||
// even when it isn't in the geographic top-N. We re-add it with a
|
||||
// freshly computed distance/bearing so the arrow stays correct.
|
||||
// Match by romnr — survives a forceRefresh() that replaces lokalIds.
|
||||
const inList = nearestShelters.findIndex(
|
||||
(s) => s.shelter.romnr === selectedRomnr,
|
||||
);
|
||||
if (inList >= 0) {
|
||||
selectedShelterIndex = inList;
|
||||
} else {
|
||||
const shelter = allShelters.find((s) => s.romnr === selectedRomnr);
|
||||
if (shelter) {
|
||||
nearestShelters.unshift({
|
||||
shelter,
|
||||
distanceMeters: distanceMeters(
|
||||
location.latitude, location.longitude,
|
||||
shelter.latitude, shelter.longitude,
|
||||
),
|
||||
bearingDegrees: bearingDegrees(
|
||||
location.latitude, location.longitude,
|
||||
shelter.latitude, shelter.longitude,
|
||||
),
|
||||
});
|
||||
selectedShelterIndex = 0;
|
||||
} else {
|
||||
// Selected shelter no longer exists in the dataset (e.g. DSB
|
||||
// decommissioned it). Fall back to nearest.
|
||||
selectedRomnr = null;
|
||||
userSelectedShelter = false;
|
||||
selectedShelterIndex = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Only auto-select the nearest shelter if the user hasn't manually selected one
|
||||
if (!userSelectedShelter) {
|
||||
selectedShelterIndex = 0;
|
||||
}
|
||||
|
||||
|
|
@ -448,22 +401,19 @@ async function forceRefresh(): Promise<void> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle /shelter/{romnr} deep links.
|
||||
* Handle /shelter/{lokalId} deep links.
|
||||
* Called after loadData() so allShelters is populated.
|
||||
*
|
||||
* Path component is romnr (DSB room number), not lokalId — see
|
||||
* selectedRomnr comment above for the upstream-stability rationale.
|
||||
*/
|
||||
function handleDeepLink(): void {
|
||||
const match = window.location.pathname.match(/^\/shelter\/(\d+)$/);
|
||||
const match = window.location.pathname.match(/^\/shelter\/(.+)$/);
|
||||
if (!match) return;
|
||||
|
||||
const romnr = parseInt(match[1], 10);
|
||||
const lokalId = decodeURIComponent(match[1]);
|
||||
|
||||
// Clean the URL so refresh doesn't re-trigger
|
||||
window.history.replaceState({}, '', '/');
|
||||
|
||||
const shelter = allShelters.find((s) => s.romnr === romnr);
|
||||
const shelter = allShelters.find((s) => s.lokalId === lokalId);
|
||||
if (!shelter) {
|
||||
statusBar.setStatus(t('error_shelter_not_found'));
|
||||
return;
|
||||
|
|
@ -474,95 +424,42 @@ function handleDeepLink(): void {
|
|||
|
||||
/**
|
||||
* Select a specific shelter, even if it's not in the current nearest-3 list.
|
||||
* Used for deep link targets, marker taps, and list taps. The selection is
|
||||
* remembered via selectedLokalId so subsequent location updates preserve it.
|
||||
* Used for deep link targets.
|
||||
*/
|
||||
function selectShelterByData(shelter: Shelter): void {
|
||||
userSelectedShelter = true;
|
||||
selectedRomnr = shelter.romnr;
|
||||
|
||||
// Check if it's already in nearestShelters
|
||||
const existingIdx = nearestShelters.findIndex(
|
||||
(s) => s.shelter.romnr === shelter.romnr,
|
||||
(s) => s.shelter.lokalId === shelter.lokalId,
|
||||
);
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
userSelectedShelter = true;
|
||||
selectedShelterIndex = existingIdx;
|
||||
} else {
|
||||
// Compute distance/bearing if we have a location, otherwise NaN signals
|
||||
// "unknown distance" — same convention as MainActivity.kt.
|
||||
const dist = currentLocation
|
||||
? distanceMeters(
|
||||
currentLocation.latitude, currentLocation.longitude,
|
||||
shelter.latitude, shelter.longitude,
|
||||
)
|
||||
: NaN;
|
||||
const bearing = currentLocation
|
||||
? bearingDegrees(
|
||||
currentLocation.latitude, currentLocation.longitude,
|
||||
shelter.latitude, shelter.longitude,
|
||||
)
|
||||
: NaN;
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share the currently selected shelter. Uses the Web Share API when
|
||||
* available (mobile browsers, installed PWAs) and falls back to copying
|
||||
* the same body to the clipboard. Body format mirrors share_body in the
|
||||
* Android strings.xml so the two clients produce equivalent messages.
|
||||
*/
|
||||
async function shareSelectedShelter(): Promise<void> {
|
||||
const selected = nearestShelters[selectedShelterIndex];
|
||||
if (!selected || !selected.shelter) {
|
||||
statusBar.setStatus(t('share_no_shelter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const shelter = selected.shelter;
|
||||
const lat = shelter.latitude.toFixed(6);
|
||||
const lon = shelter.longitude.toFixed(6);
|
||||
// Path component is romnr (stable DSB business key), not lokalId. The
|
||||
// upstream Geonorge GeoJSON re-rolls lokalId on every export, so a
|
||||
// lokalId-keyed link breaks the moment either party refreshes their
|
||||
// dataset. Romnr is unique (verified 556/556) and stable across exports.
|
||||
const deepLink = `https://${DEEP_LINK_DOMAIN}/shelter/${shelter.romnr}`;
|
||||
|
||||
const subject = t('share_subject');
|
||||
const body = [
|
||||
`${subject}: ${shelter.adresse}`,
|
||||
t('shelter_capacity', shelter.plasser),
|
||||
`${lat}, ${lon}`,
|
||||
`geo:${lat},${lon}`,
|
||||
deepLink,
|
||||
].join('\n');
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: subject, text: body, url: deepLink });
|
||||
return;
|
||||
} catch (err) {
|
||||
// AbortError means the user cancelled — silent. Anything else falls
|
||||
// through to the clipboard fallback below.
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(body);
|
||||
statusBar.setStatus(t('share_copied'));
|
||||
} catch {
|
||||
statusBar.setStatus(t('share_no_shelter'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@ export const en: Record<string, string> = {
|
|||
action_skip: 'Skip',
|
||||
action_cache_ok: 'Cache map',
|
||||
action_cache_now: 'Cache now',
|
||||
action_share: 'Share shelter',
|
||||
share_subject: 'Emergency shelter',
|
||||
share_no_shelter: 'No shelter selected',
|
||||
share_copied: 'Shelter details copied to clipboard',
|
||||
warning_no_map_cache: 'No offline map cached. Map requires internet.',
|
||||
|
||||
// Permissions
|
||||
|
|
@ -51,10 +47,6 @@ export const en: Record<string, string> = {
|
|||
update_success: 'Shelter data updated',
|
||||
update_failed: 'Update failed \u2014 using cached data',
|
||||
error_shelter_not_found: 'Shelter not found',
|
||||
compass_permission_denied:
|
||||
'Compass access denied. You can still use the map to find shelters.',
|
||||
ios_install_hint:
|
||||
'Add Tilfluktsrom to your home screen for offline access: tap Share, then Add to Home Screen.',
|
||||
|
||||
// Accessibility
|
||||
direction_arrow_description: 'Direction to shelter, %s away',
|
||||
|
|
|
|||
|
|
@ -26,11 +26,7 @@ export function initLocale(): void {
|
|||
break;
|
||||
}
|
||||
}
|
||||
// Guard the DOM write so this module is usable from Node (vitest runs
|
||||
// without jsdom). In a browser, document is always defined.
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = currentLocale;
|
||||
}
|
||||
document.documentElement.lang = currentLocale;
|
||||
}
|
||||
|
||||
/** Get current locale code. */
|
||||
|
|
|
|||
|
|
@ -26,10 +26,6 @@ export const nb: Record<string, string> = {
|
|||
action_skip: 'Hopp over',
|
||||
action_cache_ok: 'Lagre kart',
|
||||
action_cache_now: 'Lagre nå',
|
||||
action_share: 'Del tilfluktsrom',
|
||||
share_subject: 'Tilfluktsrom',
|
||||
share_no_shelter: 'Ingen tilfluktsrom valgt',
|
||||
share_copied: 'Tilfluktsrominfo kopiert til utklippstavlen',
|
||||
warning_no_map_cache:
|
||||
'Ingen frakoblet kart lagret. Kartet krever internett.',
|
||||
|
||||
|
|
@ -46,10 +42,6 @@ export const nb: Record<string, string> = {
|
|||
update_success: 'Tilfluktsromdata oppdatert',
|
||||
update_failed: 'Oppdatering mislyktes — bruker lagrede data',
|
||||
error_shelter_not_found: 'Fant ikke tilfluktsrommet',
|
||||
compass_permission_denied:
|
||||
'Kompasstilgang avslått. Du kan fortsatt bruke kartet til å finne tilfluktsrom.',
|
||||
ios_install_hint:
|
||||
'Legg Tilfluktsrom til hjemskjermen for frakoblet bruk: trykk Del, deretter Legg til på hjem-skjerm.',
|
||||
|
||||
// Tilgjengelighet
|
||||
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
|
||||
|
|
|
|||
|
|
@ -26,10 +26,6 @@ export const nn: Record<string, string> = {
|
|||
action_skip: 'Hopp over',
|
||||
action_cache_ok: 'Lagre kart',
|
||||
action_cache_now: 'Lagre no',
|
||||
action_share: 'Del tilfluktsrom',
|
||||
share_subject: 'Tilfluktsrom',
|
||||
share_no_shelter: 'Ingen tilfluktsrom valt',
|
||||
share_copied: 'Tilfluktsrominfo kopiert til utklippstavla',
|
||||
warning_no_map_cache:
|
||||
'Ingen fråkopla kart lagra. Kartet krev internett.',
|
||||
|
||||
|
|
@ -46,10 +42,6 @@ export const nn: Record<string, string> = {
|
|||
update_success: 'Tilfluktsromdata oppdatert',
|
||||
update_failed: 'Oppdatering mislukkast — brukar lagra data',
|
||||
error_shelter_not_found: 'Fann ikkje tilfluktsrommet',
|
||||
compass_permission_denied:
|
||||
'Kompasstilgang avslått. Du kan framleis bruke kartet til å finne tilfluktsrom.',
|
||||
ios_install_hint:
|
||||
'Legg Tilfluktsrom til heimeskjermen for fråkopla bruk: trykk Del, deretter Legg til på heimeskjerm.',
|
||||
|
||||
// Tilgjenge
|
||||
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
|
||||
|
|
|
|||
|
|
@ -11,44 +11,11 @@ import './styles/main.css';
|
|||
import 'leaflet/dist/leaflet.css';
|
||||
import { initLocale } from './i18n/i18n';
|
||||
import { init } from './app';
|
||||
import { maybeShow as maybeShowIosInstallHint } from './ui/install-hint';
|
||||
import { setStatus } from './ui/status-bar';
|
||||
import { t } from './i18n/i18n';
|
||||
|
||||
console.info(`[tilfluktsrom] build ${__BUILD_REVISION__}`);
|
||||
|
||||
// Make `registerType: 'autoUpdate'` actually auto-update the running tab.
|
||||
// vite-plugin-pwa's autoUpdate strategy makes the new service worker
|
||||
// skipWaiting + clientsClaim, but the JS already loaded in the open tab is
|
||||
// the *old* build until something triggers a navigation. Without this
|
||||
// listener, a deploy is invisible until the user manually refreshes.
|
||||
//
|
||||
// We *defer* the reload until the user next backgrounds the app
|
||||
// (visibilityState === 'hidden') instead of reloading immediately. This is
|
||||
// an emergency app: a mid-task reload would lose the selected shelter,
|
||||
// compass mode, and any in-flight UI state right when the user can least
|
||||
// afford to be surprised. Deferring keeps the "auto" promise (they're on
|
||||
// the new version next time they look at the screen) without interrupting
|
||||
// active use.
|
||||
//
|
||||
// The `wasAlreadyControlled` guard avoids reloading on the very first SW
|
||||
// install (when there was no previous controller — that's a fresh visit,
|
||||
// not an update).
|
||||
if ('serviceWorker' in navigator) {
|
||||
const wasAlreadyControlled = !!navigator.serviceWorker.controller;
|
||||
let pendingReload = false;
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (!wasAlreadyControlled) return;
|
||||
pendingReload = true;
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (pendingReload && document.visibilityState === 'hidden') {
|
||||
pendingReload = false;
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initLocale();
|
||||
|
||||
|
|
@ -57,9 +24,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
await navigator.storage.persist();
|
||||
}
|
||||
|
||||
await init();
|
||||
// Listen for service worker updates — flash a status message when a new
|
||||
// version activates so the user knows they have fresh code/data.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
setStatus(t('update_success'));
|
||||
});
|
||||
}
|
||||
|
||||
// Shown only on first iOS Safari visit, once per device. Placed after init()
|
||||
// so the banner doesn't compete with the loading overlay.
|
||||
maybeShowIosInstallHint();
|
||||
await init();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ html, body {
|
|||
}
|
||||
|
||||
#about-btn,
|
||||
#share-btn,
|
||||
#refresh-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -76,23 +75,10 @@ html, body {
|
|||
}
|
||||
|
||||
#about-btn:hover,
|
||||
#share-btn:hover,
|
||||
#refresh-btn:hover {
|
||||
color: #ECEFF1;
|
||||
}
|
||||
|
||||
#share-btn[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#share-btn svg {
|
||||
display: block;
|
||||
/* `↻` and `ℹ` glyphs sit on a baseline; an SVG inside an inline-flex
|
||||
container can otherwise pick up extra descender space and look
|
||||
misaligned next to them. `display:block` removes that. */
|
||||
}
|
||||
|
||||
/* --- Main content area (map or compass) --- */
|
||||
#main-content {
|
||||
flex: 1;
|
||||
|
|
@ -213,41 +199,6 @@ html, body {
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* --- iOS "Add to Home Screen" hint (shown once, dismissable) --- */
|
||||
#ios-install-hint {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #16213E;
|
||||
color: #ECEFF1;
|
||||
border: 1px solid #FF6B35;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#ios-install-hint span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#ios-install-hint button {
|
||||
background: none;
|
||||
border: 1px solid #ECEFF1;
|
||||
color: #ECEFF1;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#cache-retry-btn {
|
||||
background: none;
|
||||
border: 1px solid #FFFFFF;
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
/**
|
||||
* iOS-only "Add to Home Screen" hint.
|
||||
*
|
||||
* Chrome-based browsers fire `beforeinstallprompt` and we could show a native
|
||||
* install UI there; iOS Safari does not. For iOS users the only way to install
|
||||
* is Share → Add to Home Screen, so we show a dismissable textual hint the
|
||||
* first time they visit in a non-standalone context.
|
||||
*
|
||||
* Shown once per device (localStorage). Harmless if the heuristic mis-fires
|
||||
* on a new iOS version — the hint is dismissable.
|
||||
*/
|
||||
|
||||
import { t } from '../i18n/i18n';
|
||||
|
||||
const DISMISSED_KEY = 'tilfluktsrom:ios-install-hint:dismissed';
|
||||
|
||||
function isIOS(): boolean {
|
||||
// Safari on iPadOS 13+ reports as MacIntel, so also check for touch + Safari.
|
||||
const ua = navigator.userAgent;
|
||||
if (/iPad|iPhone|iPod/.test(ua)) return true;
|
||||
return (
|
||||
navigator.maxTouchPoints > 1 &&
|
||||
/Macintosh/.test(ua) &&
|
||||
/Safari/.test(ua) &&
|
||||
!/Chrome|CriOS|FxiOS/.test(ua)
|
||||
);
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
// iOS-specific property
|
||||
const nav = navigator as Navigator & { standalone?: boolean };
|
||||
if (nav.standalone) return true;
|
||||
// Standards-based check used by Chrome/Edge
|
||||
return window.matchMedia?.('(display-mode: standalone)').matches ?? false;
|
||||
}
|
||||
|
||||
export function maybeShow(): void {
|
||||
if (!isIOS() || isStandalone()) return;
|
||||
if (localStorage.getItem(DISMISSED_KEY) === '1') return;
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'ios-install-hint';
|
||||
banner.setAttribute('role', 'status');
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = t('ios_install_hint');
|
||||
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.type = 'button';
|
||||
dismiss.textContent = t('action_close');
|
||||
dismiss.setAttribute('aria-label', t('action_close'));
|
||||
dismiss.addEventListener('click', () => {
|
||||
localStorage.setItem(DISMISSED_KEY, '1');
|
||||
banner.remove();
|
||||
});
|
||||
|
||||
banner.append(text, dismiss);
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue