Compare commits

...

15 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
46 changed files with 3800 additions and 664 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

@ -196,7 +196,7 @@ Both flavors produce identical user experiences — `standard` achieves faster G
### 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}`).
@ -207,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

@ -110,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

@ -14,8 +14,8 @@ android {
applicationId = "no.naiv.tilfluktsrom"
minSdk = 26
targetSdk = 35
versionCode = 15
versionName = "1.10.0"
versionCode = 17
versionName = "1.10.2"
// Deep link domain — single source of truth for manifest + Kotlin code
val deepLinkDomain = "tilfluktsrom.naiv.no"

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,8 @@ 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
@ -42,6 +44,7 @@ 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 org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
@ -71,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
@ -78,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
@ -131,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?) {
@ -140,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
}
}
@ -170,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
@ -281,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 {
@ -418,11 +460,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
)
}
// Cache map tiles on first launch
if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) {
if (isNetworkAvailable()) {
cacheMapTiles(location.latitude, location.longitude)
}
// 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) {
@ -442,25 +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)
rebuildShelterList()
updateSelectedShelterUI()
} else if (nearestShelters.isNotEmpty()) {
// Auto-select nearest; selectShelter handles list rebuild + UI
selectShelter(nearestShelters[0])
} else {
// Auto-select nearest
if (nearestShelters.isNotEmpty()) {
selectShelter(nearestShelters[0])
}
rebuildShelterList()
updateSelectedShelterUI()
}
}
/**
* Rebuild the bottom-sheet list from the current nearest set + selection.
*
* Hybrid behaviour for Forgejo #13: when a shelter has been explicitly
* selected (deep link, marker tap, ...) and is *not* among the N nearest,
* append it to the list with an "outside nearest" badge so the user can
* see what they selected. The list also auto-scrolls to the selected
* row, so a manually-picked nearby entry comes into view too.
*/
private fun rebuildShelterList() {
val items = nearestShelters
.map { ShelterListItem(it, isOutsideNearest = false) }
.toMutableList()
val selected = selectedShelter
val isSelectedAmongNearest = selected != null &&
nearestShelters.any { it.shelter.lokalId == selected.shelter.lokalId }
if (selected != null && !isSelectedAmongNearest) {
// Only flag as "outside nearest" when there *is* a nearest list to
// contrast with - otherwise the selection is just the only entry.
items.add(
ShelterListItem(
selected,
isOutsideNearest = nearestShelters.isNotEmpty()
)
)
}
updateSelectedShelterUI()
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)
}
}
}
/**
@ -471,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()
}
@ -681,14 +758,26 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
return
}
lifecycleScope.launch {
binding.statusText.text = getString(R.string.status_updating)
val success = repository.refreshData()
if (success) {
updateFreshnessIndicator()
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
// 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()
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
}
} finally {
hideLoading()
}
}
}
@ -706,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,

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.10.0")
.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

@ -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

@ -62,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>
@ -97,6 +98,7 @@
<string name="about_privacy_body">Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester.\n\nGPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server.</string>
<string name="about_data_title">Datakilder</string>
<string name="about_data_body">Tilfluktsromdata er offentlig informasjon fra DSB (Direktoratet for samfunnssikkerhet og beredskap), distribuert via Geonorge. Kartfliser lastes fra OpenStreetMap. Begge lagres lokalt for frakoblet bruk.</string>
<string name="about_data_links"><![CDATA[Mer om datasettet:<br/>• <a href="https://kartkatalog.geonorge.no/metadata/tilfluktsrom-offentlige/dbae9aae-10e7-4b75-8d67-7f0e8828f3d8">Geonorge kartkatalog</a><br/>• <a href="https://register.geonorge.no/register/versjoner/produktark/direktoratet-for-samfunnssikkerhet-og-beredskap/tilfluktsrom-offentlige">Produktark (DSB)</a>]]></string>
<string name="about_stored_title">Lagret på enheten din</string>
<string name="about_stored_body">• Tilfluktsromdatabase (offentlige data fra DSB)\n• Kartfliser for frakoblet bruk\n\nIngen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser.</string>
<string name="about_open_source">Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom</string>

View file

@ -62,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>
@ -97,6 +98,7 @@
<string name="about_privacy_body">Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester.\n\nGPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar.</string>
<string name="about_data_title">Datakjelder</string>
<string name="about_data_body">Tilfluktsromdata er offentleg informasjon frå DSB (Direktoratet for samfunnstryggleik og beredskap), distribuert via Geonorge. Kartfliser vert lasta frå OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk.</string>
<string name="about_data_links"><![CDATA[Meir om datasettet:<br/>• <a href="https://kartkatalog.geonorge.no/metadata/tilfluktsrom-offentlige/dbae9aae-10e7-4b75-8d67-7f0e8828f3d8">Geonorge kartkatalog</a><br/>• <a href="https://register.geonorge.no/register/versjoner/produktark/direktoratet-for-samfunnssikkerhet-og-beredskap/tilfluktsrom-offentlige">Produktark (DSB)</a>]]></string>
<string name="about_stored_title">Lagra på eininga di</string>
<string name="about_stored_body">• Tilfluktsromdatabase (offentlege data frå DSB)\n• Kartfliser for fråkopla bruk\n\nIngen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser.</string>
<string name="about_open_source">Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom</string>

View file

@ -62,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>
@ -98,6 +101,7 @@
<string name="about_privacy_body">This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services.\n\nYour GPS location is used only on your device to find nearby shelters and is never sent to any server.</string>
<string name="about_data_title">Data sources</string>
<string name="about_data_body">Shelter data is public information from DSB (Norwegian Directorate for Civil Protection), distributed via Geonorge. Map tiles are loaded from OpenStreetMap. Both are cached locally for offline use.</string>
<string name="about_data_links"><![CDATA[Learn more about the dataset:<br/>• <a href="https://kartkatalog.geonorge.no/metadata/tilfluktsrom-offentlige/dbae9aae-10e7-4b75-8d67-7f0e8828f3d8">Geonorge metadata catalogue</a><br/>• <a href="https://register.geonorge.no/register/versjoner/produktark/direktoratet-for-samfunnssikkerhet-og-beredskap/tilfluktsrom-offentlige">DSB product sheet</a>]]></string>
<string name="about_stored_title">Stored on your device</string>
<string name="about_stored_body">• Shelter database (public data from DSB)\n• Map tiles for offline use\n\nNo data leaves your device except requests to download shelter data and map tiles.</string>
<string name="about_open_source">Open source — kode.naiv.no/olemd/tilfluktsrom</string>

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

@ -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

@ -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

@ -25,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);
});
}
@ -132,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');
@ -247,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;
}
@ -405,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;
@ -428,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(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
);
bearing = bearingDegrees(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
);
}
// Compute distance/bearing if we have a location, otherwise NaN signals
// "unknown distance" — same convention as MainActivity.kt.
const dist = currentLocation
? distanceMeters(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
)
: NaN;
const bearing = currentLocation
? bearingDegrees(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
)
: NaN;
// 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

View file

@ -26,7 +26,11 @@ export function initLocale(): void {
break;
}
}
document.documentElement.lang = currentLocale;
// 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. */

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.',

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.',

View file

@ -11,12 +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();
@ -25,14 +57,6 @@ 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()

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;

2231
scripts/romnr-baseline.json Normal file

File diff suppressed because it is too large Load diff