Gjør romnr til autoritativ tilfluktsrom-ID (Room-PK og IndexedDB-key)
Produktbeslutning (Forgejo #15): romnr — DSBs romnummer — er den autoritative IDen. lokalId regenereres av Geonorge ved hver eksport og beholdes bare som informasjonsfelt; det skal aldri sammenlignes eller nøkles på. Utredningen mot Geonorge/DSB droppes. Dette retter også en reell feil på Android: etter «Oppdater data» holdt selectedShelter det gamle objektet med utdatert lokalId, så rebuildShelterList() la til en duplikatrad med «utenfor nærområdet»- badge for et rom som var blant de nærmeste, og markørhøylysingen mistet målet (markørkartet var nøklet på nye lokalId-er). Android: - Shelter: @PrimaryKey romnr; lokalId vanlig kolonne - ShelterDatabase: versjon 2 + fallbackToDestructiveMigration() — tabellen er ren cache som reseedes fra asset når den er tom - Parsere (GeoJSON + bundled): krever romnr > 0, lokalId valgfri - MainActivity: markørkart Map<Int, Marker>, høylysing og isSelectedAmongNearest/indexOfFirst på romnr - ShelterListAdapter: DiffUtil areItemsTheSame på romnr PWA: - shelter-db.ts: DB_VERSION 2, keyPath 'romnr', upgrade sletter og gjenoppretter store ved oldVersion < 2 - map-view.ts: _shelterRomnr i stedet for _shelterLokalId - types.ts: dokumentert identitet; fetch-shelters.ts hopper over features uten romnr (speiler Android-parseren) ARCHITECTURE.md: nytt avsnitt «Shelter identity — romnr is authoritative», felt-tabell, valideringsregel og IndexedDB-skjema oppdatert. Verifisert: emulator med v1.10.2-DB → migrering til v2 + reseed (556 rom); velg rad 2 → Oppdater → ingen duplikatrad, valg og høylysing beholdt. Playwright: seedet v1-IndexedDB → app oppgraderer til v2 (keyPath romnr, 556 poster, get(1681) treffer), dyplenke og markør OK. Forgejo: #15 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrDGDmN2WAXvNPS85rWJGK
This commit is contained in:
parent
5b463f0ca6
commit
c3ab73c915
13 changed files with 108 additions and 54 deletions
|
|
@ -86,9 +86,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
// 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
|
||||
// Map from shelter romnr to its map marker, for icon swapping on selection.
|
||||
// Keyed on romnr (not lokalId): the selection survives a data refresh
|
||||
// that re-rolls every lokalId, so the highlight must too.
|
||||
private var shelterMarkerMap: MutableMap<Int, Marker> = mutableMapOf()
|
||||
private var highlightedMarkerRomnr: Int? = null
|
||||
|
||||
// Whether a compass sensor is available on this device
|
||||
private var hasCompassSensor = false
|
||||
|
|
@ -99,7 +101,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
// 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".
|
||||
// See ARCHITECTURE.md → "Shelter identity".
|
||||
private var pendingDeepLinkRomnr: Int? = null
|
||||
|
||||
// The currently selected shelter — can be any shelter, not just one from nearestShelters
|
||||
|
|
@ -524,9 +526,12 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
.map { ShelterListItem(it, isOutsideNearest = false) }
|
||||
.toMutableList()
|
||||
|
||||
// Compare by romnr: after a data refresh selectedShelter still holds
|
||||
// the pre-refresh object, whose lokalId no longer matches anything —
|
||||
// comparing lokalIds would append a duplicate "outside nearest" row.
|
||||
val selected = selectedShelter
|
||||
val isSelectedAmongNearest = selected != null &&
|
||||
nearestShelters.any { it.shelter.lokalId == selected.shelter.lokalId }
|
||||
nearestShelters.any { it.shelter.romnr == selected.shelter.romnr }
|
||||
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.
|
||||
|
|
@ -541,7 +546,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
shelterAdapter.submitList(items)
|
||||
|
||||
val selectedIdx = if (selected != null) {
|
||||
items.indexOfFirst { it.swd.shelter.lokalId == selected.shelter.lokalId }
|
||||
items.indexOfFirst { it.swd.shelter.romnr == selected.shelter.romnr }
|
||||
} else -1
|
||||
shelterAdapter.selectPosition(selectedIdx)
|
||||
|
||||
|
|
@ -644,7 +649,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
binding.directionArrow.setNorthAngle(-deviceHeading)
|
||||
|
||||
// Emphasize the selected marker on the map
|
||||
highlightSelectedMarker(selected.shelter.lokalId)
|
||||
highlightSelectedMarker(selected.shelter.romnr)
|
||||
|
||||
// Only auto-zoom the map if the user hasn't manually panned/zoomed
|
||||
if (!isCompassMode && !userHasInteractedWithMap) {
|
||||
|
|
@ -653,20 +658,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
/** Swap marker icons so the selected shelter stands out. */
|
||||
private fun highlightSelectedMarker(lokalId: String) {
|
||||
if (lokalId == highlightedMarkerId) return
|
||||
private fun highlightSelectedMarker(romnr: Int) {
|
||||
if (romnr == highlightedMarkerRomnr) return
|
||||
|
||||
val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter)
|
||||
val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected)
|
||||
|
||||
// Reset previous
|
||||
highlightedMarkerId?.let { prevId ->
|
||||
shelterMarkerMap[prevId]?.icon = normalIcon
|
||||
highlightedMarkerRomnr?.let { prev ->
|
||||
shelterMarkerMap[prev]?.icon = normalIcon
|
||||
}
|
||||
|
||||
// Highlight new
|
||||
shelterMarkerMap[lokalId]?.icon = selectedIcon
|
||||
highlightedMarkerId = lokalId
|
||||
shelterMarkerMap[romnr]?.icon = selectedIcon
|
||||
highlightedMarkerRomnr = romnr
|
||||
|
||||
binding.mapView.invalidate()
|
||||
}
|
||||
|
|
@ -675,15 +680,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
// Remove old markers
|
||||
shelterMarkerMap.values.forEach { binding.mapView.overlays.remove(it) }
|
||||
shelterMarkerMap.clear()
|
||||
highlightedMarkerId = null
|
||||
highlightedMarkerRomnr = null
|
||||
|
||||
val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter)
|
||||
val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected)
|
||||
val currentSelectedId = selectedShelter?.shelter?.lokalId
|
||||
val currentSelectedRomnr = selectedShelter?.shelter?.romnr
|
||||
|
||||
// Add markers for all shelters — tapping any marker selects it
|
||||
allShelters.forEach { shelter ->
|
||||
val isSelected = shelter.lokalId == currentSelectedId
|
||||
val isSelected = shelter.romnr == currentSelectedRomnr
|
||||
val marker = Marker(binding.mapView).apply {
|
||||
position = GeoPoint(shelter.latitude, shelter.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
|
|
@ -696,8 +701,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
true
|
||||
}
|
||||
}
|
||||
if (isSelected) highlightedMarkerId = shelter.lokalId
|
||||
shelterMarkerMap[shelter.lokalId] = marker
|
||||
if (isSelected) highlightedMarkerRomnr = shelter.romnr
|
||||
shelterMarkerMap[shelter.romnr] = marker
|
||||
binding.mapView.overlays.add(marker)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,18 @@ import androidx.room.PrimaryKey
|
|||
/**
|
||||
* A public shelter (offentlig tilfluktsrom).
|
||||
* Coordinates are stored in WGS84 (EPSG:4326) after conversion from UTM33N.
|
||||
*
|
||||
* Identity is [romnr] — DSB's room number, the authoritative shelter ID
|
||||
* (product decision 2026-08-17, Forgejo #15). The upstream Geonorge export
|
||||
* re-rolls [lokalId] on every publication, so it is kept only as an
|
||||
* informational field and must never be used for equality, map keys or
|
||||
* persistence. See ARCHITECTURE.md → "Shelter identity".
|
||||
*/
|
||||
@Entity(tableName = "shelters")
|
||||
data class Shelter(
|
||||
@PrimaryKey
|
||||
val lokalId: String,
|
||||
val romnr: Int,
|
||||
val lokalId: String,
|
||||
val plasser: Int,
|
||||
val adresse: String,
|
||||
val latitude: Double,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,15 @@ import androidx.room.Database
|
|||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(entities = [Shelter::class], version = 1, exportSchema = false)
|
||||
/**
|
||||
* Version history:
|
||||
* 1 — primary key lokalId (upstream UUID)
|
||||
* 2 — primary key romnr (Forgejo #15). The table is a pure cache that is
|
||||
* re-seeded from the bundled asset (and refreshed from Geonorge) whenever
|
||||
* it is empty, so a destructive migration is safe and simpler than a
|
||||
* hand-written one.
|
||||
*/
|
||||
@Database(entities = [Shelter::class], version = 2, exportSchema = false)
|
||||
abstract class ShelterDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun shelterDao(): ShelterDao
|
||||
|
|
@ -20,7 +28,10 @@ abstract class ShelterDatabase : RoomDatabase() {
|
|||
context.applicationContext,
|
||||
ShelterDatabase::class.java,
|
||||
"shelters.db"
|
||||
).build().also { INSTANCE = it }
|
||||
)
|
||||
// See version history above: cache-only table, re-seeded on empty.
|
||||
.fallbackToDestructiveMigration()
|
||||
.build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,13 +91,16 @@ object ShelterGeoJsonParser {
|
|||
continue
|
||||
}
|
||||
|
||||
// Require a valid primary key — without it shelters can collide in the DB
|
||||
val lokalId = properties.optString("lokalId", null)
|
||||
if (lokalId.isNullOrBlank()) {
|
||||
Log.w(TAG, "Skipping shelter at index $i: missing lokalId")
|
||||
// romnr is the primary key and the authoritative shelter ID —
|
||||
// without it the row can't be identified or deep-linked.
|
||||
// lokalId is informational only (re-rolled upstream, see Shelter).
|
||||
val romnr = properties.optInt("romnr", 0)
|
||||
if (romnr <= 0) {
|
||||
Log.w(TAG, "Skipping shelter at index $i: missing romnr")
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
val lokalId = properties.optString("lokalId", "")
|
||||
|
||||
val plasser = properties.optInt("plasser", 0)
|
||||
if (plasser < 0) {
|
||||
|
|
@ -108,8 +111,8 @@ object ShelterGeoJsonParser {
|
|||
|
||||
shelters.add(
|
||||
Shelter(
|
||||
romnr = romnr,
|
||||
lokalId = lokalId,
|
||||
romnr = properties.optInt("romnr", 0),
|
||||
plasser = plasser,
|
||||
adresse = properties.optString("adresse", ""),
|
||||
latitude = latLon.latitude,
|
||||
|
|
|
|||
|
|
@ -152,13 +152,15 @@ class ShelterRepository(private val context: Context) {
|
|||
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(i)
|
||||
val lokalId: String? = obj.optString("lokalId", null)
|
||||
if (lokalId.isNullOrBlank()) continue
|
||||
// Same rule as ShelterGeoJsonParser: romnr is the identity, lokalId
|
||||
// is optional/informational.
|
||||
val romnr = obj.optInt("romnr", 0)
|
||||
if (romnr <= 0) continue
|
||||
|
||||
shelters.add(
|
||||
Shelter(
|
||||
lokalId = lokalId,
|
||||
romnr = obj.optInt("romnr", 0),
|
||||
romnr = romnr,
|
||||
lokalId = obj.optString("lokalId", ""),
|
||||
plasser = obj.optInt("plasser", 0),
|
||||
adresse = obj.optString("adresse", ""),
|
||||
latitude = obj.getDouble("latitude"),
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class ShelterListAdapter(
|
|||
companion object {
|
||||
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ShelterListItem>() {
|
||||
override fun areItemsTheSame(a: ShelterListItem, b: ShelterListItem) =
|
||||
a.swd.shelter.lokalId == b.swd.shelter.lokalId
|
||||
a.swd.shelter.romnr == b.swd.shelter.romnr
|
||||
|
||||
override fun areContentsTheSame(a: ShelterListItem, b: ShelterListItem) =
|
||||
a == b
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue