Compare commits

..

20 commits

Author SHA1 Message Date
5365c99bf5 Initial baseline for scheduled romnr-stabilitet-sjekk
Generert lokalt fra Geonorge-eksporten 2026-04-30: 556 tilfluktsrom,
hver med {lokalId, sha256(easting, northing, plasser, adresse)}.

Brukes av den ukentlige remote-routinen
(trig_01CjSBymeghmUfXFZ9QR7oGE, mandag 08:00 Oslo) som diff-grunnlag.
Når DSB legger til/fjerner/flytter tilfluktsrom, vil routinen sende et
Gmail-utkast med diff og oppdatert snapshot — etter triage commiter
brukeren ny baseline manuelt.

Bakgrunn: Forgejo-issue #15 / beads tilfluktsrom-jmv.
2026-04-30 13:37:02 +02:00
890ea96ed8 Beads: defer jmv (Geonorge lokalId-utredning) til 2026-10-30
Praktisk konsekvens er løst — delingslenker bruker romnr. Resterende
arbeid er kommunikasjon mot Geonorge/DSB, ikke kritisk.
2026-04-30 13:16:44 +02:00
337e9b96c2 Bytt badge-bakgrunn til warning_bg for WCAG 2.2 AA-kontrast
Hvit fet 11sp tekst på shelter_primary (#FF6B35) gav ~2.84:1 kontrast,
under SC 1.4.3-terskelen på 4.5:1 for normaltekst (og marginalt under
3:1 for large text). warning_bg (#BF360C) er allerede dokumentert i
colors.xml med ~5.5:1 mot hvit.

Forgejo: #18
2026-04-29 16:55:15 +02:00
3f0c958b98 Beads: speil Android badge-kontrast-saken (#18 / 7zc) 2026-04-29 16:54:06 +02:00
40da1dfd6c Beads: speil PWA-saken (#17 / efo) 2026-04-29 16:52:24 +02:00
1fb9f14ad4 Vis dyplenket tilfluktsrom i lista, selv utenfor topp-3 (hybrid)
Når en dyplenke (eller markørtap) velger et tilfluktsrom som ikke er
blant de N nærmeste, blir det nå appendet til bunnpanelets liste med
et tydelig "Valgt – utenfor nærområdet"-badge, og lista scroller til
den valgte raden. Hvis valget er innenfor topp-N, scrollelementet
fortsatt synes — løser begge symptomene som rapporten pekte på.

Endringer:
- Ny ShelterListItem(swd, isOutsideNearest)-wrapper i adapteren
- ShelterListAdapter: viser badge + a11y-suffiks når isOutsideNearest=true
- item_shelter.xml: badge-TextView (orange bakgrunn, hvit tekst, gone som default)
- MainActivity: rebuildShelterList()-helper bygger top-N + maybe-appended,
  smoothScrollToPosition(selectedIdx) sikrer synlig markering
- Strings i en/nb/nn

Forgejo: #13
2026-04-29 16:49:28 +02:00
b31537df2c Beads: oppdater issues.jsonl etter at #16 (5vc) er lukket
Speiler statusendringen fra Dolt-databasen til snapshot-filen i git.
2026-04-29 16:15:36 +02:00
d2291a2d35 Fiks usynlig retningspil for brukerlokasjon på lyse kart
OSMDroid sin MyLocationNewOverlay brukte stock hvit person- og pil-
bitmap, som forsvant på lyse tiles (snø, sand, lyse OSM-temaer). Bytter
inn appens orange tema med hvit halo og myk skygge — silhuetten holder
≥3:1 kontrast (WCAG 2.2 AA, ikke-tekstlig innhold) mot både lyse og
mørke underlag.

- ic_user_dot.xml: stillestående (orange dot, hvit ring)
- ic_user_arrow.xml: i bevegelse (orange chevron, hvit kontur)
- drawableToBitmap-helper konverterer vector → Bitmap for OSMDroid
- setPersonAnchor/setDirectionAnchor (0.5, 0.5) sentrerer rotasjon

Forgejo: #16
2026-04-29 16:14:53 +02:00
91dd89c03d Verktøyoppsett: beads-issuetracker, AGENTS.md og Claude-innstillinger
Initialiserer beads (bd) som lokalt issue-spor med Dolt-historikk, og
legger til AGENTS.md + .claude/settings.json for konsistent agent-oppsett
på tvers av økter. Forgejo forblir kanonisk for offentlig samarbeid;
beads speiler issuene lokalt for AI-agent-bruk.

Endringer:
- .beads/: konfig, hooks, og initial issues.jsonl (11 saker speilet fra Forgejo, #1 og #7 lukket)
- .gitignore: ekskluder .dolt/, *.db og .beads-credential-key
- AGENTS.md: agentvennlig oppsummering av prosjektkonvensjoner
- CLAUDE.md: beads-integrasjonsblokk + sesjonsavslutningsprotokoll
- .claude/settings.json: Claude Code-innstillinger for prosjektet

Holder fdroid/-katalogen utenfor — F-Droid-distribusjon er pauset.
2026-04-29 16:03:50 +02:00
fbe8e53141 Om-dialog: lenker til Geonorge kartkatalog og DSB produktark
Legg til klikkbare lenker under "Datakilder" i Om-dialogen så brukarar
kan finne metadata, oppdateringsfrekvens og kvalitetsinfo om
tilfluktsrom-datasettet direkte hjå kjeldene.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:00:23 +02:00
83d406a6aa Bump versjon til v1.10.2 (versionCode 17)
PWA-utgivelse: synleg del-ikon og deferred autoupdate. Ingen
funksjonelle endringar i Android-koden — versjonsmetadata følgjer
PWA-en for samkøyrde utgivingsmerke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:28:38 +02:00
c603e81b2a PWA: synleg del-ikon og ekte autoupdate utan å avbryte aktiv bruk
- Bytt del-knappen frå tekstpila ↪ til ein innebygd Material-stil
  SVG (20×20). Den førre Unicode-pila vart teikna mykje mindre enn
  ℹ og ↻ av dei fleste fontar — no har alle tre ikona same visuelle
  vekt på tvers av nettlesarar og operativsystem.
- Lagt til ein controllerchange-lyttar som utløyser ein omlasting
  *neste* gong appen vert lagt i bakgrunnen (visibilityState ===
  'hidden'), ikkje umiddelbart. Slik tek nye distribusjonar effekt
  utan brukarinngrep, men utan å rive vekk skjermen midt i ein
  tilfluktsromnavigering. registerType: 'autoUpdate' åleine sørgjer
  for at den nye SW-en skipWaiting + clientsClaim, men det reload-ar
  ikkje den allereie køyrande JavaScripten — denne lyttaren stettar
  det utan å bryte noregisseringsflyt.

Påverkar ikkje fråkopla bruk: omlasting skjer berre etter at ein ny
SW har vorte aktivert, og ein ny SW kan berre installerast medan
nettverket er tilgjengeleg. Sjølve omlastinga løyser alle ressursar
frå presjekken og fungerer fråkopla.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:28:31 +02:00
948625b777 Bump versjon til v1.10.1 (versionCode 16)
Punktutgivelse med stabile djuplenker (romnr i staden for lokalId),
PWA-deling, og fiks for hengande oppdater-knapp og gjentatt
kart-bufferspørsmål.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:11:34 +02:00
3d6f8f362e Stabile djuplenker og fleire bruksforbetringar
- Bytt djuplenkjenøkkel frå lokalId til romnr fordi Geonorge
  regenererer lokalId-UUID-en på kvar eksport (556/556 endra på sju
  dagar), medan romnr er DSB sin stabile rom-nummer-nøkkel. Dokumentert
  i ARCHITECTURE.md.
- PWA: ny del-knapp som genererer same HTTPS-djuplenke som Android-appen
  (Web Share API med utklippstavle-fallback).
- PWA: vald tilfluktsrom overlever no posisjonsoppdateringar og
  manuell dataoppdatering — sporast på romnr i staden for lista.
- Android: kart-bufferspørsmålet dukkar berre opp éin gong per økt
  ("Hopp over" sit), og forceRefresh viser lasteoverlegg + hindrar
  samtidige refresh-kall.
- i18n.ts: vakta DOM-skriving slik at vitest køyrer utan jsdom.
- Oppdatert pakka tilfluktsromdata frå Geonorge.

Refs #15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:11:28 +02:00
87ac698d55 Legg til fastlane-endringslogg for v1.10.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:47:17 +02:00
262fafe9e0 Bump versjon til v1.10.0 (versionCode 15)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:27:35 +02:00
42c28df102 PWA: iOS-hjemskjermhint, kompass-feilmelding og zoom-fiks
- Fjern maximum-scale/user-scalable på viewport slik at siden kan
  zoomes (WCAG 1.4.4). Leaflet håndterer gestikk på kartet selv.
- Legg til Apple-meta-tagger for standalone-modus og statuslinje.
- Vis engangsbanner på iOS Safari om å legge til på hjemskjerm,
  siden iOS ikke støtter beforeinstallprompt.
- Gi tydelig statusmelding når bruker avslår kompasstilgang i
  stedet for å stille reversere til kartmodus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:22:21 +02:00
f0c4a1f5b4 Actionable banner når posisjon ikke er tilgjengelig
Tidligere sto statusteksten igjen på «Venter på GPS…» uansett om
årsaken var manglende tillatelse, avslåtte stedstjenester eller
bare at GPS-en ikke hadde fått fix ennå. For en nødsituasjonsapp
er det en reell feilmodus: brukeren får ingen hint om hva som kan
gjøres for å finne nærmeste tilfluktsrom.

Ny noLocationBanner plasseres øverst i innholdsområdet (rett under
statuslinjen) slik at den ikke kolliderer med de flytende
handlingsknappene over bunnarket, og viser én av tre tilstander:

1. Tillatelse avslått eller ikke gitt — «Posisjonstilgang
   nødvendig for å finne nærmeste tilfluktsrom. Du kan også
   trykke på et merke i kartet.» + «Gi tilgang» som åpner
   ACTION_APPLICATION_DETAILS_SETTINGS.
2. Tillatelse gitt, men stedstjenester slått av — «Stedstjenester
   er slått av. Aktiver dem eller velg et tilfluktsrom fra
   kartet.» + «Aktiver» som åpner ACTION_LOCATION_SOURCE_SETTINGS.
3. Begge OK — banner er skjult og den eksisterende «Venter på
   GPS…»-teksten gjelder.

Helperen updateLocationStatusBanner() kalles fra loadData(),
permission-result-kallbacket og onResume(), slik at banneret
oppdaterer seg både ved appstart, umiddelbart etter avslag, og
når brukeren kommer tilbake fra systeminnstillingene.
AlertDialog-en ved permanent avslag er fjernet til fordel for det
ikke-modale banneret, som lar brukeren fortsatt pan-ne kartet og
velge tilfluktsrom manuelt. Toasten på mykt avslag er beholdt som
en kort bekreftelse. API-nivå-fallbacket bruker
LocationManager.isLocationEnabled på API 28+, isProviderEnabled
for GPS/Network på API 26–27.

Verifisert på emulator i alle fire tilstander (avslag → App
Settings, tjeneste-av → Posisjonsinnstillinger, gjenopprettet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:32:02 +02:00
7ce0827e9f Fjern utdatert notis om 24-timers GPS-utløp i README
Linjen under «Sikkerhet» beskrev 24-timers-grensen på
widget_prefs-cachen som ble fjernet sammen med widgeten i
forrige commit. Appen persisterer nå ikke GPS-posisjon
overhodet, så linjen er erstattet med «GPS-posisjon lagres
ikke — den brukes bare i minnet mens appen er i bruk», som
samsvarer med den allerede oppdaterte teksten i
about_stored_body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:02:14 +02:00
0743eac9dd Fjern hjemmeskjerm-widget
Widgeten har vært en vedlikeholdskostnad uten et klart produktformål:
den duplikerte lokasjonslogikken fra hovedappen, kunne vise inntil
24 timer gammel GPS-posisjon uten alderindikator, og krevde en egen
WorkManager-periodisk oppdatering. Den strategiske vurderingen
(2026-04-17) konkluderte med at den samme nytten kan leveres via
app-åpning eller en lettere mekanisme senere, og at flaten bør
krympes før pitch mot offentlig sektor.

Denne endringen fjerner widget/-pakken for begge flavors
(standard + fdroid), AppWidgetProvider-mottakeren i manifestet,
WidgetUpdateWorker, androidx.work:work-runtime-ktx-avhengigheten,
widget_prefs SharedPreferences-lagringen i MainActivity, samt
widget_*-strenger og linjen om «for hjemmeskjerm-widgeten» i
about-dialogen. Dokumentasjonen i CLAUDE.md, ARCHITECTURE.md,
README.md, STANDING_ON_SHOULDERS.md og fastlane-beskrivelsene
er justert tilsvarende. Historiske changelogs (v3, v5, v6, v7)
er bevisst urørt — de beskriver korrekt hva som ble levert i
de versjonene.

Eksisterende widget-plasseringer på brukernes hjemmeskjerm
forsvinner automatisk neste gang appen oppdateres; Android
fjerner foreldreløse provider-komponenter uten migreringskode.
Begge debug-flavors bygger rent etter endringen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:57:45 +02:00
60 changed files with 4031 additions and 1800 deletions

73
.beads/.gitignore vendored Normal file
View file

@ -0,0 +1,73 @@
# 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.

81
.beads/README.md Normal file
View file

@ -0,0 +1,81 @@
# 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* ⚡

56
.beads/config.yaml Normal file
View file

@ -0,0 +1,56 @@
# 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"

24
.beads/hooks/post-checkout Executable file
View file

@ -0,0 +1,24 @@
#!/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 ---

24
.beads/hooks/post-merge Executable file
View file

@ -0,0 +1,24 @@
#!/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 ---

24
.beads/hooks/pre-commit Executable file
View file

@ -0,0 +1,24 @@
#!/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 ---

24
.beads/hooks/pre-push Executable file
View file

@ -0,0 +1,24 @@
#!/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 ---

24
.beads/hooks/prepare-commit-msg Executable file
View file

@ -0,0 +1,24 @@
#!/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 ---

14
.beads/issues.jsonl Normal file
View file

@ -0,0 +1,14 @@
{"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}

7
.beads/metadata.json Normal file
View file

@ -0,0 +1,7 @@
{
"database": "dolt",
"backend": "dolt",
"dolt_mode": "embedded",
"dolt_database": "tilfluktsrom",
"project_id": "49020f4c-0353-4536-a242-39a3dc116f11"
}

26
.claude/settings.json Normal file
View file

@ -0,0 +1,26 @@
{
"hooks": {
"PreCompact": [
{
"hooks": [
{
"command": "bd prime",
"type": "command"
}
],
"matcher": ""
}
],
"SessionStart": [
{
"hooks": [
{
"command": "bd prime",
"type": "command"
}
],
"matcher": ""
}
]
}
}

5
.gitignore vendored
View file

@ -10,3 +10,8 @@
local.properties
/app/build
keystore.properties
# Beads / Dolt files (added by bd init)
.dolt/
*.db
.beads-credential-key

84
AGENTS.md Normal file
View file

@ -0,0 +1,84 @@
# 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 -->

View file

@ -13,7 +13,6 @@ 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)
@ -41,7 +40,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, OkHttp, and WorkManager.
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.
### Data Sovereignty
Shelter data comes directly from Geonorge (the Norwegian mapping authority). No intermediate servers. The app fetches, converts, and caches the data locally.
@ -107,15 +106,12 @@ 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
└── widget/
├── ShelterWidgetProvider.kt # Home screen widget (flavor-specific)
└── WidgetUpdateWorker.kt # WorkManager periodic update
└── util/
├── CoordinateConverter.kt # UTM33N → WGS84 (Karney method)
└── DistanceUtils.kt # Haversine distance and bearing
```
Files under `location/` and `widget/` have separate implementations per build variant:
Files under `location/` have separate implementations per build variant:
- `app/src/standard/java/` — Google Play Services variant
- `app/src/fdroid/java/` — AOSP-only variant
@ -198,24 +194,9 @@ 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/{lokalId}`
**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{romnr}`
The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts` (exposed as `BuildConfig.DEEP_LINK_DOMAIN` and manifest placeholder `${deepLinkHost}`).
@ -226,6 +207,20 @@ 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

View file

@ -12,7 +12,6 @@ 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.
@ -24,7 +23,6 @@ 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
@ -44,7 +42,6 @@ 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
```
@ -113,3 +110,51 @@ 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 -->

View file

@ -28,7 +28,6 @@ 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
@ -70,7 +69,6 @@ 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)
@ -99,7 +97,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
- Lagret GPS-posisjon utløper automatisk etter 24 timer
- GPS-posisjon lagres ikke — den brukes bare i minnet mens appen er i bruk
- Egendefinert User-Agent forhindrer enhetsfingeravtrykk
## Personvern

View file

@ -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, WorkManager, etc.) | UI, architecture, database, scheduling | ~1,000 | [GitHub: androidx/androidx](https://github.com/androidx/androidx) monorepo |
| AndroidX (Core, AppCompat, Room, etc.) | UI, architecture, database | ~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) |

View file

@ -14,8 +14,8 @@ android {
applicationId = "no.naiv.tilfluktsrom"
minSdk = 26
targetSdk = 35
versionCode = 14
versionName = "1.9.1"
versionCode = 17
versionName = "1.10.2"
// Deep link domain — single source of truth for manifest + Kotlin code
val deepLinkDomain = "tilfluktsrom.naiv.no"
@ -104,9 +104,6 @@ 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")

View file

@ -1,268 +0,0 @@
package no.naiv.tilfluktsrom.widget
import android.Manifest
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.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
}
}

View file

@ -1,136 +0,0 @@
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
}
}
}

View file

@ -39,18 +39,5 @@
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

View file

@ -5,14 +5,18 @@ 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
@ -40,8 +44,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
@ -70,6 +74,14 @@ 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
@ -77,8 +89,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// Whether a compass sensor is available on this device
private var hasCompassSensor = false
// Deep link: shelter ID to select once data is loaded
private var pendingDeepLinkShelterId: String? = null
// 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
// The currently selected shelter — can be any shelter, not just one from nearestShelters
private var selectedShelter: ShelterWithDistance? = null
@ -95,28 +113,10 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
if (fineGranted || coarseGranted) {
startLocationUpdates()
} else {
// 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,7 +148,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
/**
* Handle https://{domain}/shelter/{lokalId} deep link.
* 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.
* If shelters are already loaded, select immediately; otherwise store as pending.
*/
private fun handleDeepLinkIntent(intent: Intent?) {
@ -157,15 +159,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
uri.host != BuildConfig.DEEP_LINK_DOMAIN ||
uri.path?.startsWith("/shelter/") != true) return
val lokalId = uri.lastPathSegment ?: return
val romnr = uri.lastPathSegment?.toIntOrNull() ?: return
// Clear intent data so config changes don't re-trigger
intent.data = null
val shelter = allShelters.find { it.lokalId == lokalId }
val shelter = allShelters.find { it.romnr == romnr }
if (shelter != null) {
selectShelterByData(shelter)
} else {
pendingDeepLinkShelterId = lokalId
pendingDeepLinkRomnr = romnr
}
}
@ -187,14 +189,37 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
false // Don't consume the event
}
// Add user location overlay
// 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).
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
@ -260,6 +285,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
private fun loadData() {
updateLocationStatusBanner()
lifecycleScope.launch {
try {
var hasData = repository.hasCachedData()
@ -296,9 +323,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
updateShelterMarkers()
// Process pending deep links now that shelter data is available
pendingDeepLinkShelterId?.let { id ->
pendingDeepLinkShelterId = null
val shelter = shelters.find { it.lokalId == id }
pendingDeepLinkRomnr?.let { romnr ->
pendingDeepLinkRomnr = null
val shelter = shelters.find { it.romnr == romnr }
if (shelter != null) {
selectShelterByData(shelter)
} else {
@ -379,6 +406,44 @@ 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 {
@ -386,7 +451,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
try {
locationProvider.locationUpdates().collectLatest { location ->
currentLocation = location
saveLastLocation(location)
updateNearestShelters(location)
// Center map on first location fix
@ -396,13 +460,16 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
)
}
// Cache map tiles on first launch
if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) {
if (isNetworkAvailable()) {
// 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)
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@ -420,26 +487,60 @@ 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)
} else {
// Auto-select nearest
if (nearestShelters.isNotEmpty()) {
rebuildShelterList()
updateSelectedShelterUI()
} else if (nearestShelters.isNotEmpty()) {
// Auto-select nearest; selectShelter handles list rebuild + UI
selectShelter(nearestShelters[0])
} else {
rebuildShelterList()
updateSelectedShelterUI()
}
}
updateSelectedShelterUI()
ShelterWidgetProvider.requestUpdate(this)
/**
* 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)
}
}
}
/**
@ -450,10 +551,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
selectedShelter = swd
currentLocation?.let { refreshSelectedShelterDistance(it) }
// Update list highlight
val idx = nearestShelters.indexOfFirst { it.shelter.lokalId == swd.shelter.lokalId }
shelterAdapter.selectPosition(idx)
rebuildShelterList()
updateSelectedShelterUI()
}
@ -660,8 +758,17 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
return
}
lifecycleScope.launch {
// 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 3090 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()
@ -669,6 +776,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} else {
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
}
} finally {
hideLoading()
}
}
}
@ -685,7 +795,10 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
val shelter = selected.shelter
val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.lokalId}"
// 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 body = getString(
R.string.share_body,
shelter.adresse,
@ -745,15 +858,6 @@ 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
@ -769,6 +873,10 @@ 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)

View file

@ -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.9.1")
.header("User-Agent", "Tilfluktsrom/1.10.2")
.build())
})
.build()

View file

@ -1,10 +1,13 @@
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
@ -28,7 +31,12 @@ class AboutDialog : DialogFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.dialog_about, container, false)
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
}
override fun onStart() {

View file

@ -2,6 +2,7 @@ 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
@ -11,12 +12,23 @@ 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<ShelterWithDistance, ShelterListAdapter.ViewHolder>(DIFF_CALLBACK) {
) : ListAdapter<ShelterListItem, ShelterListAdapter.ViewHolder>(DIFF_CALLBACK) {
private var selectedPosition = 0
@ -42,23 +54,34 @@ class ShelterListAdapter(
private val binding: ItemShelterBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: ShelterWithDistance, isSelected: Boolean) {
fun bind(item: ShelterListItem, isSelected: Boolean) {
val ctx = binding.root.context
binding.shelterAddress.text = item.shelter.adresse
binding.shelterDistance.text = DistanceUtils.formatDistance(item.distanceMeters)
val swd = item.swd
binding.shelterAddress.text = swd.shelter.adresse
binding.shelterDistance.text = DistanceUtils.formatDistance(swd.distanceMeters)
binding.shelterCapacity.text = ctx.getString(
R.string.shelter_capacity, item.shelter.plasser
R.string.shelter_capacity, swd.shelter.plasser
)
binding.shelterRoomNr.text = ctx.getString(
R.string.shelter_room_nr, item.shelter.romnr
R.string.shelter_room_nr, swd.shelter.romnr
)
binding.root.contentDescription = ctx.getString(
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(
R.string.content_desc_shelter_item,
item.shelter.adresse,
DistanceUtils.formatDistance(item.distanceMeters),
item.shelter.plasser
swd.shelter.adresse,
DistanceUtils.formatDistance(swd.distanceMeters),
swd.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
@ -68,18 +91,18 @@ class ShelterListAdapter(
val pos = adapterPosition
if (pos != RecyclerView.NO_POSITION) {
selectPosition(pos)
onShelterSelected(getItem(pos))
onShelterSelected(getItem(pos).swd)
}
}
}
}
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ShelterWithDistance>() {
override fun areItemsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) =
a.shelter.lokalId == b.shelter.lokalId
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ShelterListItem>() {
override fun areItemsTheSame(a: ShelterListItem, b: ShelterListItem) =
a.swd.shelter.lokalId == b.swd.shelter.lokalId
override fun areContentsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) =
override fun areContentsTheSame(a: ShelterListItem, b: ShelterListItem) =
a == b
}
}

View file

@ -0,0 +1,20 @@
<?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>

View file

@ -0,0 +1,20 @@
<?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>

View file

@ -76,7 +76,7 @@
android:layout_width="match_parent"
android:layout_height="0dp"
android:contentDescription="@string/a11y_map"
app:layout_constraintTop_toBottomOf="@id/statusBar"
app:layout_constraintTop_toBottomOf="@id/noLocationBanner"
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/statusBar"
app:layout_constraintTop_toBottomOf="@id/noLocationBanner"
app:layout_constraintBottom_toTopOf="@id/bottomSheet">
<no.naiv.tilfluktsrom.ui.DirectionArrowView
@ -149,6 +149,41 @@
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"

View file

@ -60,11 +60,20 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginBottom="8dp"
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"

View file

@ -9,6 +9,21 @@
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"

View file

@ -1,74 +0,0 @@
<?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>

View file

@ -20,7 +20,6 @@
<!-- 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 -->
@ -31,7 +30,11 @@
<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>
@ -46,13 +49,6 @@
<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>
@ -66,6 +62,7 @@
<!-- 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>
@ -101,8 +98,9 @@
<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• 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_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_open_source">Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">Om denne appen</string>

View file

@ -20,7 +20,6 @@
<!-- 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 -->
@ -31,7 +30,11 @@
<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>
@ -46,13 +49,6 @@
<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>
@ -66,6 +62,7 @@
<!-- 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>
@ -101,8 +98,9 @@
<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• 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_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_open_source">Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">Om denne appen</string>

View file

@ -20,7 +20,6 @@
<!-- 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 -->
@ -31,7 +30,11 @@
<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>
@ -46,13 +49,6 @@
<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>
@ -66,6 +62,9 @@
<!-- 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>
@ -102,8 +101,9 @@
<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• 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_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_open_source">Open source — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">About this app</string>

View file

@ -1,9 +0,0 @@
<?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" />

View file

@ -1,349 +0,0 @@
package no.naiv.tilfluktsrom.widget
import android.Manifest
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.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
}
}
}

View file

@ -1,187 +0,0 @@
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
}
}
}

View file

@ -0,0 +1,2 @@
- Actionable banner when location is unavailable (permissions, disabled services, or no GPS fix)
- Home screen widget removed

View file

@ -0,0 +1,4 @@
- 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

View file

@ -0,0 +1,2 @@
- 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

View file

@ -5,7 +5,6 @@ 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

View file

@ -0,0 +1,2 @@
- Handlingsbanner når posisjon ikke er tilgjengelig (tillatelser, avslåtte stedstjenester eller manglende GPS-fix)
- Hjemmeskjerm-widget fjernet

View file

@ -0,0 +1,4 @@
- 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

View file

@ -0,0 +1,2 @@
- 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

View file

@ -5,7 +5,6 @@ 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

View file

@ -0,0 +1,2 @@
- Handlingsbanner når posisjonen ikkje er tilgjengeleg (løyve, avslegne stadtenester eller manglande GPS-fix)
- Heimeskjerm-widgeten er fjerna

View file

@ -0,0 +1,4 @@
- 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

View file

@ -0,0 +1,2 @@
- 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

View file

@ -5,7 +5,6 @@ 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

View file

@ -2,9 +2,16 @@
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<!-- 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="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>
@ -18,6 +25,11 @@
<header id="status-bar" role="banner">
<span id="status-text" aria-live="polite"></span>
<button id="about-btn" aria-label="About">&#x2139;</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">&#x21bb;</button>
</header>

View file

@ -11,6 +11,7 @@ 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';
@ -35,6 +36,12 @@ 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();
@ -58,21 +65,16 @@ 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
const idx = nearestShelters.findIndex(
(s) => s.shelter.lokalId === shelter.lokalId,
);
if (idx >= 0) {
userSelectedShelter = true;
selectedShelterIndex = idx;
updateSelectedShelter(true);
}
// Marker click — select this shelter (route through selectShelterByData
// so a tap on a far-away marker also survives location updates).
selectShelterByData(shelter);
});
}
@ -86,6 +88,7 @@ function setupShelterList(): void {
shelterList.initShelterList(container, (index: number) => {
userSelectedShelter = true;
selectedShelterIndex = index;
selectedRomnr = nearestShelters[index]?.shelter.romnr ?? null;
updateSelectedShelter(true);
});
}
@ -101,10 +104,14 @@ function setupButtons(): void {
const compassContainer = document.getElementById('compass-container')!;
if (isCompassMode) {
// Request compass permission on first toggle (iOS requirement)
// 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.
const granted = await compassProvider.requestPermission();
if (!granted) {
isCompassMode = false;
statusBar.setStatus(t('compass_permission_denied'));
return;
}
mapContainer.style.display = 'none';
@ -128,6 +135,14 @@ 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');
@ -243,8 +258,40 @@ function updateNearestShelters(location: LatLon): void {
NEAREST_COUNT,
);
// Only auto-select the nearest shelter if the user hasn't manually selected one
if (!userSelectedShelter) {
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 {
selectedShelterIndex = 0;
}
@ -401,19 +448,22 @@ async function forceRefresh(): Promise<void> {
}
/**
* Handle /shelter/{lokalId} deep links.
* Handle /shelter/{romnr} 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\/(.+)$/);
const match = window.location.pathname.match(/^\/shelter\/(\d+)$/);
if (!match) return;
const lokalId = decodeURIComponent(match[1]);
const romnr = parseInt(match[1], 10);
// Clean the URL so refresh doesn't re-trigger
window.history.replaceState({}, '', '/');
const shelter = allShelters.find((s) => s.lokalId === lokalId);
const shelter = allShelters.find((s) => s.romnr === romnr);
if (!shelter) {
statusBar.setStatus(t('error_shelter_not_found'));
return;
@ -424,42 +474,95 @@ function handleDeepLink(): void {
/**
* Select a specific shelter, even if it's not in the current nearest-3 list.
* Used for deep link targets.
* Used for deep link targets, marker taps, and list taps. The selection is
* remembered via selectedLokalId so subsequent location updates preserve it.
*/
function selectShelterByData(shelter: Shelter): void {
// Check if it's already in nearestShelters
userSelectedShelter = true;
selectedRomnr = shelter.romnr;
const existingIdx = nearestShelters.findIndex(
(s) => s.shelter.lokalId === shelter.lokalId,
(s) => s.shelter.romnr === shelter.romnr,
);
if (existingIdx >= 0) {
userSelectedShelter = true;
selectedShelterIndex = existingIdx;
} else {
// Compute distance/bearing if we have a location, otherwise use placeholder
let dist = NaN;
let bearing = 0;
if (currentLocation) {
dist = distanceMeters(
// 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,
);
bearing = bearingDegrees(
)
: NaN;
const bearing = currentLocation
? bearingDegrees(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
);
}
)
: NaN;
// 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'));
}
}
}

View file

@ -30,6 +30,10 @@ 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
@ -47,6 +51,10 @@ 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',

View file

@ -26,8 +26,12 @@ 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;
}
}
/** Get current locale code. */
export function getLocale(): string {

View file

@ -26,6 +26,10 @@ 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.',
@ -42,6 +46,10 @@ 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',

View file

@ -26,6 +26,10 @@ 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.',
@ -42,6 +46,10 @@ 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',

View file

@ -11,11 +11,44 @@ import './styles/main.css';
import 'leaflet/dist/leaflet.css';
import { initLocale } from './i18n/i18n';
import { init } from './app';
import { setStatus } from './ui/status-bar';
import { t } from './i18n/i18n';
import { maybeShow as maybeShowIosInstallHint } from './ui/install-hint';
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();
@ -24,13 +57,9 @@ document.addEventListener('DOMContentLoaded', async () => {
await navigator.storage.persist();
}
// 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'));
});
}
await init();
// Shown only on first iOS Safari visit, once per device. Placed after init()
// so the banner doesn't compete with the loading overlay.
maybeShowIosInstallHint();
});

View file

@ -63,6 +63,7 @@ html, body {
}
#about-btn,
#share-btn,
#refresh-btn {
background: none;
border: none;
@ -75,10 +76,23 @@ 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;
@ -199,6 +213,41 @@ 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;

View file

@ -0,0 +1,59 @@
/**
* 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);
}

2231
scripts/romnr-baseline.json Normal file

File diff suppressed because it is too large Load diff