From c1ac68e746cda548c978060b018ea1ffcbd46dec Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 23 Mar 2026 14:27:45 +0100 Subject: [PATCH] =?UTF-8?q?Legg=20til=20om-side,=20personvernerkl=C3=A6rin?= =?UTF-8?q?g=20og=20sikkerheitsforbetring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Om-side (Android + PWA): - Ny AboutDialog med personvernerklæring, datakjelder og opphavsrett - Opphavsrett flytta frå sivilforsvardialogen til om-sida - Tilgjengeleg via «Om denne appen»-lenke i sivilforsvarsdialogen (Android) og ny om-knapp i statuslinja (PWA) - Lokalisert til en/nb/nn Personvern og sikkerheit: - Lagra GPS-posisjon utløper etter 24 timar (widget_prefs) - Widget viser «Trykk for å oppdatere» når posisjon manglar eller er utløpt - Eigendefinert User-Agent (Tilfluktsrom/1.6.1) i OkHttp - Content Security Policy (CSP) meta-tag i PWA - Tenararbeidar bufrar berre HTTP 200-svar (ikkje opake) - Kartbuffer-metadata runda til ~11km presisjon i localStorage - crossorigin="anonymous" på Leaflet CSS i18n-opprydding: - Unicode-escapes erstatta med UTF-8-teikn i nb.ts og nn.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- .../widget/ShelterWidgetProvider.kt | 3 + .../tilfluktsrom/widget/WidgetUpdateWorker.kt | 3 + .../tilfluktsrom/data/ShelterRepository.kt | 6 + .../no/naiv/tilfluktsrom/ui/AboutDialog.kt | 43 ++++++++ .../tilfluktsrom/ui/CivilDefenseInfoDialog.kt | 8 +- app/src/main/res/layout/dialog_about.xml | 104 ++++++++++++++++++ .../main/res/layout/dialog_civil_defense.xml | 14 ++- app/src/main/res/values-nb/strings.xml | 12 ++ app/src/main/res/values-nn/strings.xml | 12 ++ app/src/main/res/values/strings.xml | 12 ++ .../widget/ShelterWidgetProvider.kt | 5 +- .../tilfluktsrom/widget/WidgetUpdateWorker.kt | 5 +- pwa/index.html | 5 +- pwa/src/app.ts | 8 +- pwa/src/cache/map-cache-manager.ts | 5 +- pwa/src/i18n/en.ts | 18 +++ pwa/src/i18n/nb.ts | 38 +++++-- pwa/src/i18n/nn.ts | 38 +++++-- pwa/src/styles/main.css | 70 ++++++++++++ pwa/src/ui/about-dialog.ts | 92 ++++++++++++++++ pwa/vite.config.ts | 2 +- 21 files changed, 469 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt create mode 100644 app/src/main/res/layout/dialog_about.xml create mode 100644 pwa/src/ui/about-dialog.ts diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt index b144b01..1f42882 100644 --- a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt +++ b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt @@ -211,9 +211,12 @@ class ShelterWidgetProvider : AppWidgetProvider() { return getSavedLocation(context) } + /** Returns null if older than 24 hours to avoid retaining stale location data. */ private fun getSavedLocation(context: Context): Location? { val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) if (!prefs.contains("last_lat")) return null + val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) + if (age > 24 * 60 * 60 * 1000L) return null return Location("saved").apply { latitude = prefs.getFloat("last_lat", 0f).toDouble() longitude = prefs.getFloat("last_lon", 0f).toDouble() diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt index 1b83c09..8d482f4 100644 --- a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt +++ b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt @@ -69,9 +69,12 @@ class WidgetUpdateWorker( return Result.success() } + /** Returns null if older than 24 hours. */ private fun getSavedLocation(): Location? { val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) if (!prefs.contains("last_lat")) return null + val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) + if (age > 24 * 60 * 60 * 1000L) return null return Location("saved").apply { latitude = prefs.getFloat("last_lat", 0f).toDouble() longitude = prefs.getFloat("last_lon", 0f).toDouble() diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt index 4298c39..c076d8b 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONArray @@ -43,6 +44,11 @@ class ShelterRepository(private val context: Context) { private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) + .addInterceptor(Interceptor { chain -> + chain.proceed(chain.request().newBuilder() + .header("User-Agent", "Tilfluktsrom/1.6.1") + .build()) + }) .build() /** Reactive stream of all shelters from local cache. */ diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt new file mode 100644 index 0000000..712562e --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt @@ -0,0 +1,43 @@ +package no.naiv.tilfluktsrom.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import no.naiv.tilfluktsrom.R + +/** + * Full-screen dialog showing app info, privacy statement, and copyright. + * Static content — all text comes from string resources for offline use and i18n. + */ +class AboutDialog : DialogFragment() { + + companion object { + const val TAG = "AboutDialog" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.Theme_Tilfluktsrom_Dialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.dialog_about, container, false) + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt index dd5aa2d..053497b 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt @@ -1,6 +1,5 @@ package no.naiv.tilfluktsrom.ui -import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -32,6 +31,13 @@ class CivilDefenseInfoDialog : DialogFragment() { return inflater.inflate(R.layout.dialog_civil_defense, container, false) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById(R.id.aboutLink)?.setOnClickListener { + AboutDialog().show(parentFragmentManager, AboutDialog.TAG) + } + } + override fun onStart() { super.onStart() dialog?.window?.apply { diff --git a/app/src/main/res/layout/dialog_about.xml b/app/src/main/res/layout/dialog_about.xml new file mode 100644 index 0000000..16e9885 --- /dev/null +++ b/app/src/main/res/layout/dialog_about.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_civil_defense.xml b/app/src/main/res/layout/dialog_civil_defense.xml index 29333d4..b4d0691 100644 --- a/app/src/main/res/layout/dialog_civil_defense.xml +++ b/app/src/main/res/layout/dialog_civil_defense.xml @@ -114,14 +114,16 @@ android:textSize="12sp" android:textStyle="italic" /> - + + android:layout_height="48dp" + android:layout_marginTop="8dp" + android:gravity="center_vertical" + android:text="@string/action_about" + android:textColor="@color/shelter_primary" + android:textSize="14sp" /> diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 3dc77cf..cd0e02a 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -85,6 +85,18 @@ Én sammenhengende tone på omtrent 30 sekunder. Faren eller angrepet er over. Fortsett å følge instruksjoner fra myndighetene. Kilde: DSB (Direktoratet for samfunnssikkerhet og beredskap) + + Om Tilfluktsrom + Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen er laget for å fungere uten internett etter første oppsett — du trenger ikke nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din. + Personvern + Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester.\n\nGPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server. + Datakilder + Tilfluktsromdata er offentlig informasjon fra Geonorge (Kartverket). Kartfliser lastes fra OpenStreetMap. Begge lagres lokalt for frakoblet bruk. + Lagret på enheten din + • Tilfluktsromdatabase (offentlige data fra Geonorge)\n• Kartfliser for frakoblet bruk\n• Din siste GPS-posisjon (for hjemmeskjerm-widgeten)\n\nIngen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser. + Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom + Om denne appen + Opphavsrett © Ole-Morten Duesund diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 129289d..49fca39 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -85,6 +85,18 @@ Éin samanhengande tone på omtrent 30 sekund. Faren eller åtaket er over. Hald fram med å følgje instruksjonar frå styresmaktene. Kjelde: DSB (Direktoratet for samfunnstryggleik og beredskap) + + Om Tilfluktsrom + Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen er laga for å fungere utan internett etter fyrste oppsett — du treng ikkje nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din. + Personvern + Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester.\n\nGPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar. + Datakjelder + Tilfluktsromdata er offentleg informasjon frå Geonorge (Kartverket). Kartfliser vert lasta frå OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk. + Lagra på eininga di + • Tilfluktsromdatabase (offentlege data frå Geonorge)\n• Kartfliser for fråkopla bruk\n• Din siste GPS-posisjon (for heimeskjerm-widgeten)\n\nIngen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser. + Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom + Om denne appen + Opphavsrett © Ole-Morten Duesund diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c742ab4..5caf5a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,18 @@ One continuous tone lasting approximately 30 seconds. The danger or attack is over. Continue to follow instructions from authorities. Source: DSB (Norwegian Directorate for Civil Protection) + + About Tilfluktsrom + Tilfluktsrom helps you find the nearest public shelter in Norway. The app is designed to work offline after initial setup — no internet required to find shelters, navigate by compass, or share your location. + Privacy + This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services.\n\nYour GPS location is used only on your device to find nearby shelters and is never sent to any server. + Data sources + Shelter data is public information from Geonorge (Norwegian Mapping Authority). Map tiles are loaded from OpenStreetMap. Both are cached locally for offline use. + Stored on your device + • Shelter database (public data from Geonorge)\n• Map tiles for offline use\n• Your last GPS position (for the home screen widget)\n\nNo data leaves your device except requests to download shelter data and map tiles. + Open source — kode.naiv.no/olemd/tilfluktsrom + About this app + Copyright © Ole-Morten Duesund diff --git a/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt index 119f475..42a9fdc 100644 --- a/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt +++ b/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt @@ -252,10 +252,13 @@ class ShelterWidgetProvider : AppWidgetProvider() { return getSavedLocation(context) } - /** Read the last GPS fix persisted by MainActivity to SharedPreferences. */ + /** Read the last GPS fix persisted by MainActivity to SharedPreferences. + * Returns null if older than 24 hours to avoid retaining stale location data. */ private fun getSavedLocation(context: Context): Location? { val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) if (!prefs.contains("last_lat")) return null + val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) + if (age > 24 * 60 * 60 * 1000L) return null return Location("saved").apply { latitude = prefs.getFloat("last_lat", 0f).toDouble() longitude = prefs.getFloat("last_lon", 0f).toDouble() diff --git a/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt index f89993a..91e5d3b 100644 --- a/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt +++ b/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt @@ -84,10 +84,13 @@ class WidgetUpdateWorker( return Result.success() } - /** Read the last GPS fix persisted by MainActivity. */ + /** Read the last GPS fix persisted by MainActivity. + * Returns null if older than 24 hours. */ private fun getSavedLocation(): Location? { val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) if (!prefs.contains("last_lat")) return null + val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) + if (age > 24 * 60 * 60 * 1000L) return null return Location("saved").apply { latitude = prefs.getFloat("last_lat", 0f).toDouble() longitude = prefs.getFloat("last_lon", 0f).toDouble() diff --git a/pwa/index.html b/pwa/index.html index c6088c1..876bde3 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -5,19 +5,22 @@ + Tilfluktsrom + crossorigin="anonymous" />
diff --git a/pwa/src/app.ts b/pwa/src/app.ts index 5f4d6a9..6a2113e 100644 --- a/pwa/src/app.ts +++ b/pwa/src/app.ts @@ -20,6 +20,7 @@ import * as shelterList from './ui/shelter-list'; import * as statusBar from './ui/status-bar'; import * as loading from './ui/loading-overlay'; import * as mapCache from './cache/map-cache-manager'; +import * as aboutDialog from './ui/about-dialog'; const NEAREST_COUNT = 3; @@ -44,8 +45,13 @@ export async function init(): Promise { await loadData(); } -/** Set localized aria-labels on landmark elements. */ +/** Set localized aria-labels and wire the about button. */ function applyA11yLabels(): void { + document.getElementById('about-btn')?.setAttribute('aria-label', t('action_about')); + document.getElementById('about-btn')?.addEventListener('click', () => { + navigator.vibrate?.(10); + aboutDialog.showAbout(); + }); document.getElementById('map-container')?.setAttribute('aria-label', t('a11y_map')); document.getElementById('compass-container')?.setAttribute('aria-label', t('a11y_compass')); document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info')); diff --git a/pwa/src/cache/map-cache-manager.ts b/pwa/src/cache/map-cache-manager.ts index 09bb584..83217be 100644 --- a/pwa/src/cache/map-cache-manager.ts +++ b/pwa/src/cache/map-cache-manager.ts @@ -94,9 +94,10 @@ export async function cacheMapArea( // Restore original view map.setView(originalCenter, originalZoom, { animate: false }); + // Round coordinates to 1 decimal (~11km) to limit location precision in storage saveCacheMeta({ - lat, - lon, + lat: Math.round(lat * 10) / 10, + lon: Math.round(lon * 10) / 10, radius: CACHE_RADIUS_DEGREES, complete: true, }); diff --git a/pwa/src/i18n/en.ts b/pwa/src/i18n/en.ts index 506e718..ff38b03 100644 --- a/pwa/src/i18n/en.ts +++ b/pwa/src/i18n/en.ts @@ -53,4 +53,22 @@ export const en: Record = { a11y_compass: 'Compass', a11y_shelter_info: 'Shelter info', a11y_nearest_shelters: 'Nearest shelters', + + // About + about_title: 'About Tilfluktsrom', + about_description: + 'Tilfluktsrom helps you find the nearest public shelter in Norway. The app works offline after initial setup.', + about_privacy_title: 'Privacy', + about_privacy_body: + 'This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services. Your GPS location is used only on your device to find nearby shelters and is never sent to any server.', + about_data_title: 'Data sources', + about_data_body: + 'Shelter data: Geonorge (Norwegian Mapping Authority). Map tiles: OpenStreetMap. Both are cached locally for offline use.', + about_stored_title: 'Stored on your device', + about_stored_body: + 'Shelter database (public data), map tiles for offline use, and map cache metadata. No data leaves your device except requests to download shelter data and map tiles.', + about_copyright: 'Copyright © Ole-Morten Duesund', + about_open_source: 'Open source — kode.naiv.no/olemd/tilfluktsrom', + action_about: 'About', + action_close: 'Close', }; diff --git a/pwa/src/i18n/nb.ts b/pwa/src/i18n/nb.ts index 8506849..eb78f14 100644 --- a/pwa/src/i18n/nb.ts +++ b/pwa/src/i18n/nb.ts @@ -1,4 +1,4 @@ -/** Norwegian Bokm\u00e5l strings. Ported from res/values-nb/strings.xml. */ +/** Norwegian Bokmål strings. Ported from res/values-nb/strings.xml. */ export const nb: Record = { app_name: 'Tilfluktsrom', @@ -7,40 +7,40 @@ export const nb: Record = { status_updating: 'Oppdaterer\u2026', status_offline: 'Frakoblet modus', status_shelters_loaded: '%d tilfluktsrom lastet', - status_no_location: 'Venter p\u00e5 GPS\u2026', + status_no_location: 'Venter på GPS\u2026', status_caching_map: 'Lagrer kart for frakoblet bruk\u2026', loading_shelters: 'Laster ned tilfluktsromdata\u2026', loading_map: 'Lagrer kartfliser\u2026', loading_map_explanation: - 'Forbereder frakoblet kart.\nKartet vil rulle kort for \u00e5 lagre omgivelsene dine.', - loading_first_time: 'Gj\u00f8r klar for f\u00f8rste gangs bruk\u2026', + 'Forbereder frakoblet kart.\nKartet vil rulle kort for å lagre omgivelsene dine.', + loading_first_time: 'Gjør klar for første gangs bruk\u2026', shelter_capacity: '%d plasser', shelter_room_nr: 'Rom %d', - nearest_shelter: 'N\u00e6rmeste tilfluktsrom', + nearest_shelter: 'Nærmeste tilfluktsrom', no_shelters: 'Ingen tilfluktsromdata tilgjengelig', action_refresh: 'Oppdater data', action_toggle_view: 'Bytt mellom kart og kompassvisning', action_skip: 'Hopp over', action_cache_ok: 'Lagre kart', - action_cache_now: 'Lagre n\u00e5', + action_cache_now: 'Lagre nå', warning_no_map_cache: 'Ingen frakoblet kart lagret. Kartet krever internett.', permission_location_title: 'Posisjonstillatelse kreves', permission_location_message: - 'Denne appen trenger din posisjon for \u00e5 finne n\u00e6rmeste tilfluktsrom. Vennligst gi tilgang til posisjon.', + 'Denne appen trenger din posisjon for å finne nærmeste tilfluktsrom. Vennligst gi tilgang til posisjon.', permission_denied: - 'Posisjonstillatelse avsl\u00e5tt. Appen kan ikke finne tilfluktsrom i n\u00e6rheten uten den.', + 'Posisjonstillatelse avslått. Appen kan ikke finne tilfluktsrom i nærheten uten den.', error_download_failed: 'Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.', error_no_data_offline: - 'Ingen lagrede data tilgjengelig. Koble til internett for \u00e5 laste ned tilfluktsromdata.', + 'Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.', update_success: 'Tilfluktsromdata oppdatert', - update_failed: 'Oppdatering mislyktes \u2014 bruker lagrede data', + update_failed: 'Oppdatering mislyktes — bruker lagrede data', // Tilgjengelighet direction_arrow_description: 'Retning til tilfluktsrom, %s unna', @@ -48,4 +48,22 @@ export const nb: Record = { a11y_compass: 'Kompass', a11y_shelter_info: 'Tilfluktsrominfo', a11y_nearest_shelters: 'Nærmeste tilfluktsrom', + + // Om + about_title: 'Om Tilfluktsrom', + about_description: + 'Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen fungerer uten internett etter første oppsett.', + about_privacy_title: 'Personvern', + about_privacy_body: + 'Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester. GPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server.', + about_data_title: 'Datakilder', + about_data_body: + 'Tilfluktsromdata: Geonorge (Kartverket). Kartfliser: OpenStreetMap. Begge lagres lokalt for frakoblet bruk.', + about_stored_title: 'Lagret på enheten din', + about_stored_body: + 'Tilfluktsromdatabase (offentlige data), kartfliser for frakoblet bruk og kartbuffer-metadata. Ingen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser.', + about_copyright: 'Opphavsrett © Ole-Morten Duesund', + about_open_source: 'Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom', + action_about: 'Om', + action_close: 'Lukk', }; diff --git a/pwa/src/i18n/nn.ts b/pwa/src/i18n/nn.ts index 2ecd5e2..7132eaa 100644 --- a/pwa/src/i18n/nn.ts +++ b/pwa/src/i18n/nn.ts @@ -5,20 +5,20 @@ export const nn: Record = { status_ready: 'Klar', status_loading: 'Lastar tilfluktsromdata\u2026', status_updating: 'Oppdaterer\u2026', - status_offline: 'Fr\u00e5kopla modus', + status_offline: 'Fråkopla modus', status_shelters_loaded: '%d tilfluktsrom lasta', - status_no_location: 'Ventar p\u00e5 GPS\u2026', - status_caching_map: 'Lagrar kart for fr\u00e5kopla bruk\u2026', + status_no_location: 'Ventar på GPS\u2026', + status_caching_map: 'Lagrar kart for fråkopla bruk\u2026', loading_shelters: 'Lastar ned tilfluktsromdata\u2026', loading_map: 'Lagrar kartfliser\u2026', loading_map_explanation: - 'F\u00f8rebur fr\u00e5kopla kart.\nKartet vil rulle kort for \u00e5 lagre omgjevnadene dine.', + 'Førebur fråkopla kart.\nKartet vil rulle kort for å lagre omgjevnadene dine.', loading_first_time: 'Gjer klar for fyrste gongs bruk\u2026', shelter_capacity: '%d plassar', shelter_room_nr: 'Rom %d', - nearest_shelter: 'N\u00e6raste tilfluktsrom', + nearest_shelter: 'Næraste tilfluktsrom', no_shelters: 'Ingen tilfluktsromdata tilgjengeleg', action_refresh: 'Oppdater data', @@ -27,20 +27,20 @@ export const nn: Record = { action_cache_ok: 'Lagre kart', action_cache_now: 'Lagre no', warning_no_map_cache: - 'Ingen fr\u00e5kopla kart lagra. Kartet krev internett.', + 'Ingen fråkopla kart lagra. Kartet krev internett.', permission_location_title: 'Posisjonsløyve krevst', permission_location_message: - 'Denne appen treng posisjonen din for \u00e5 finne n\u00e6raste tilfluktsrom. Ver venleg og gje tilgang til posisjon.', + 'Denne appen treng posisjonen din for å finne næraste tilfluktsrom. Ver venleg og gje tilgang til posisjon.', permission_denied: - 'Posisjonsløyve avsl\u00e5tt. Appen kan ikkje finne tilfluktsrom i n\u00e6rleiken utan det.', + 'Posisjonsløyve avslått. Appen kan ikkje finne tilfluktsrom i nærleiken utan det.', error_download_failed: 'Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.', error_no_data_offline: - 'Ingen lagra data tilgjengeleg. Kopla til internett for \u00e5 laste ned tilfluktsromdata.', + 'Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.', update_success: 'Tilfluktsromdata oppdatert', - update_failed: 'Oppdatering mislukkast \u2014 brukar lagra data', + update_failed: 'Oppdatering mislukkast — brukar lagra data', // Tilgjenge direction_arrow_description: 'Retning til tilfluktsrom, %s unna', @@ -48,4 +48,22 @@ export const nn: Record = { a11y_compass: 'Kompass', a11y_shelter_info: 'Tilfluktsrominfo', a11y_nearest_shelters: 'Nærmaste tilfluktsrom', + + // Om + about_title: 'Om Tilfluktsrom', + about_description: + 'Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen fungerer utan internett etter fyrste oppsett.', + about_privacy_title: 'Personvern', + about_privacy_body: + 'Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester. GPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar.', + about_data_title: 'Datakjelder', + about_data_body: + 'Tilfluktsromdata: Geonorge (Kartverket). Kartfliser: OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk.', + about_stored_title: 'Lagra på eininga di', + about_stored_body: + 'Tilfluktsromdatabase (offentlege data), kartfliser for fråkopla bruk og kartbuffer-metadata. Ingen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser.', + about_copyright: 'Opphavsrett © Ole-Morten Duesund', + about_open_source: 'Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom', + action_about: 'Om', + action_close: 'Lukk', }; diff --git a/pwa/src/styles/main.css b/pwa/src/styles/main.css index 5106319..47d9768 100644 --- a/pwa/src/styles/main.css +++ b/pwa/src/styles/main.css @@ -62,6 +62,7 @@ html, body { text-overflow: ellipsis; } +#about-btn, #refresh-btn { background: none; border: none; @@ -73,6 +74,7 @@ html, body { flex-shrink: 0; } +#about-btn:hover, #refresh-btn:hover { color: #ECEFF1; } @@ -399,3 +401,71 @@ html, body { .leaflet-popup-close-button { color: #CFD8DC !important; } + +/* --- About dialog --- */ +#about-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.about-content { + background: #16213E; + border-radius: 12px; + padding: 24px; + max-width: 480px; + width: 100%; + max-height: 80vh; + overflow-y: auto; +} + +.about-heading { + color: #FF6B35; + font-size: 20px; + font-weight: bold; + margin-bottom: 12px; +} + +.about-subheading { + color: #ECEFF1; + font-size: 16px; + font-weight: bold; + margin-top: 16px; + margin-bottom: 4px; +} + +.about-para { + color: #90A4AE; + font-size: 14px; + line-height: 1.5; + margin-bottom: 8px; +} + +.about-footer { + margin-top: 20px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.about-small { + color: #90A4AE; + font-size: 11px; + margin-bottom: 2px; +} + +.about-close-btn { + display: block; + margin: 16px auto 0; + padding: 10px 32px; + background: #FF6B35; + border: none; + border-radius: 6px; + color: #FFFFFF; + font-size: 14px; + cursor: pointer; +} diff --git a/pwa/src/ui/about-dialog.ts b/pwa/src/ui/about-dialog.ts new file mode 100644 index 0000000..19c5d08 --- /dev/null +++ b/pwa/src/ui/about-dialog.ts @@ -0,0 +1,92 @@ +/** + * About dialog: app info, privacy statement, data sources, copyright. + * Opens as a modal overlay, same pattern as loading-overlay. + */ + +import { t } from '../i18n/i18n'; + +let overlay: HTMLDivElement | null = null; +let previousFocus: HTMLElement | null = null; + +/** Show the about dialog. */ +export function showAbout(): void { + if (overlay) return; + + previousFocus = document.activeElement as HTMLElement | null; + + overlay = document.createElement('div'); + overlay.id = 'about-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', t('about_title')); + + const content = document.createElement('div'); + content.className = 'about-content'; + + content.appendChild(heading(t('about_title'))); + content.appendChild(para(t('about_description'))); + + content.appendChild(subheading(t('about_privacy_title'))); + content.appendChild(para(t('about_privacy_body'))); + + content.appendChild(subheading(t('about_data_title'))); + content.appendChild(para(t('about_data_body'))); + + content.appendChild(subheading(t('about_stored_title'))); + content.appendChild(para(t('about_stored_body'))); + + const footer = document.createElement('div'); + footer.className = 'about-footer'; + footer.appendChild(small(t('about_copyright'))); + footer.appendChild(small(t('about_open_source'))); + content.appendChild(footer); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'about-close-btn'; + closeBtn.textContent = t('action_close'); + closeBtn.addEventListener('click', hideAbout); + content.appendChild(closeBtn); + + overlay.appendChild(content); + document.body.appendChild(overlay); + + closeBtn.focus(); +} + +/** Hide the about dialog and restore focus. */ +export function hideAbout(): void { + if (overlay) { + overlay.remove(); + overlay = null; + } + previousFocus?.focus(); + previousFocus = null; +} + +function heading(text: string): HTMLElement { + const el = document.createElement('h2'); + el.textContent = text; + el.className = 'about-heading'; + return el; +} + +function subheading(text: string): HTMLElement { + const el = document.createElement('h3'); + el.textContent = text; + el.className = 'about-subheading'; + return el; +} + +function para(text: string): HTMLElement { + const el = document.createElement('p'); + el.textContent = text; + el.className = 'about-para'; + return el; +} + +function small(text: string): HTMLElement { + const el = document.createElement('p'); + el.textContent = text; + el.className = 'about-small'; + return el; +} diff --git a/pwa/vite.config.ts b/pwa/vite.config.ts index 2c345b4..6be5179 100644 --- a/pwa/vite.config.ts +++ b/pwa/vite.config.ts @@ -39,7 +39,7 @@ export default defineConfig({ maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days }, cacheableResponse: { - statuses: [0, 200], + statuses: [200], }, }, },