Compare commits
2 commits
22fad9e1db
...
0efb599b8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0efb599b8c | |||
| e4f44ede97 |
12 changed files with 372 additions and 19 deletions
86
ACCESSIBILITY.md
Normal file
86
ACCESSIBILITY.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Tilgjengelighetserklæring for Tilfluktsrom
|
||||
|
||||
> **English preamble** (not part of the statement):
|
||||
> This is the accessibility statement for the Tilfluktsrom app (Android + PWA).
|
||||
> The authoritative version is in Norwegian Bokmål — that is what Uutilsynet and
|
||||
> Norwegian public-sector buyers will review. If you need an English translation
|
||||
> for procurement or contributor review, contact the maintainer (section 5).
|
||||
> A maintainer checklist in English follows at the end of this file and must be
|
||||
> stripped before publication.
|
||||
|
||||
---
|
||||
|
||||
## 1. Samsvarsstatus
|
||||
|
||||
Tilfluktsrom er **delvis i samsvar** med kravene i EN 301 549 (basert på WCAG 2.2 nivå AA). Statusen er «delvis» fordi ett punkt er dokumentert som uforholdsmessig byrde (se avsnitt 3.2); egenkontroll har ikke identifisert andre gjenværende avvik på dette nivået per versjon 1.9.0.
|
||||
|
||||
## 2. Referansestandard
|
||||
|
||||
Erklæringen bygger på EU-direktiv 2016/2102 (Web Accessibility Directive, WAD) slik det er innført i norsk rett, og bruker EN 301 549 / WCAG 2.2 AA som teknisk referanse. For publikumsveiledning henviser vi til Digitaliseringsdirektoratet (Digdir) / Uutilsynet på https://www.uutilsynet.no/.
|
||||
|
||||
## 3. Ikke-tilgjengelig innhold
|
||||
|
||||
### 3.1 Manglende samsvar med regelverket
|
||||
|
||||
Per versjon 1.9.0 har egenkontroll ikke identifisert gjenværende avvik på dette nivået i Android-appen. Tidligere identifiserte avvik som er utbedret, er dokumentert i avsnitt 7 (Historikk).
|
||||
|
||||
### 3.2 Uforholdsmessig byrde
|
||||
|
||||
- **Full tastaturnavigasjon i kartet (PWA).** Leaflet-kartet støtter zoom og grunnleggende pan med tastatur, men ikke effektiv tastaturnavigasjon til vilkårlige geografiske punkter. Å bygge et fullt tilgjengelig alternativt kart vil være uforholdsmessig for en privatfinansiert kodebase. Avviket er delvis avbøtet ved at appen alltid viser en tekstbasert liste over nærmeste tilfluktsrom som fungerer fullt ut uten mus eller kart.
|
||||
|
||||
Vurderingen skal dokumenteres nærmere før erklæringen brukes i en offentlig anskaffelsessammenheng; se vedlikeholdsnotatet nederst.
|
||||
|
||||
### 3.3 Innhold utenfor virkeområdet
|
||||
|
||||
Følgende innhold er ikke omfattet av regelverket og vurderes heller ikke som en del av erklæringen:
|
||||
|
||||
- Kartfliser fra OpenStreetMap er tredjepartsinnhold som ikke er under vår kontroll.
|
||||
- Offentlige tilfluktsromdata fra DSB/Geonorge vises uendret. Eventuelle feil i kildedata (adresse, kapasitet, plassering) kan ikke rettes i appen og må meldes til dataeier.
|
||||
- Operativsystemkomponenter (bl.a. Android systemdialoger, nettleserens UI for PWA-installasjon) er ikke vårt ansvar.
|
||||
|
||||
## 4. Utarbeiding av erklæringen
|
||||
|
||||
- **Vurderingsmetode:** Egenkontroll. Ingen uavhengig tredjepartsrevisjon er gjennomført.
|
||||
- **Dato erklæringen ble utarbeidet:** 17. april 2026.
|
||||
- **Dato for siste gjennomgang:** 17. april 2026.
|
||||
|
||||
## 5. Tilbakemelding og kontaktinformasjon
|
||||
|
||||
Har du oppdaget en tilgjengelighetsfeil som ikke er nevnt her, eller trenger du innholdet i et annet format?
|
||||
|
||||
- **E-post:** olemd@odinprosjekt.no
|
||||
- **Forventet svartid:** 14 dager.
|
||||
|
||||
## 6. Klageordning
|
||||
|
||||
Er du ikke fornøyd med svaret du får, kan saken meldes til tilsynsorganet:
|
||||
|
||||
- **Tilsynsmyndighet:** Digitaliseringsdirektoratet (Digdir) / Uutilsynet.
|
||||
- **Nettsted:** https://www.uutilsynet.no/
|
||||
- **Klagerett:** Du har rett til å melde manglende universell utforming av digitale tjenester til tilsynet.
|
||||
|
||||
## 7. Historikk
|
||||
|
||||
### Versjon 1.9.0 — 17. april 2026
|
||||
|
||||
Følgende avvik ble identifisert ved egenkontroll og utbedret i denne versjonen:
|
||||
|
||||
- **WCAG 2.4.7 Synlig fokus.** Systemets standard fokusmarkering er slått på for temaet `Theme.Tilfluktsrom` via `android:defaultFocusHighlightEnabled`, og bundne ripple-bakgrunner (`?attr/selectableItemBackground`) er tatt i bruk på ikonknappene slik at fokusringen er synlig ved tastatur- og bryternavigasjon.
|
||||
- **WCAG 4.1.2 Navn, rolle, verdi.** `DirectionArrowView` rapporterer seg nå som `ImageView` og setter innholdsbeskrivelse på formen «Retning til tilfluktsrom: <retning>, <avstand> unna»; endringer annonseres til TalkBack hver gang retningen krysser en 45°-sektor, med 750 ms-struping mot spam. Retningsbegrepene er oversatt til både bokmål og nynorsk.
|
||||
- **WCAG 1.4.3 Minimumskontrast.** Advarselsbanneret for manglende offline-kart hadde tidligere bakgrunnsfarge `#E65100` med hvit tekst (kontrastforhold 3,75 : 1, under AA-kravet på 4,5 : 1). Bakgrunnsfargen er endret til `#BF360C`, som gir kontrast på omkring 5,5 : 1 mot hvit tekst.
|
||||
- **WCAG 2.3.3 Bevegelse fra interaksjon.** Retningspilen rotert tidligere kontinuerlig med kompasset, uten hensyn til Android-innstillingen «Fjern animasjoner». Når `Settings.Global.ANIMATOR_DURATION_SCALE` er 0, snapper retningspilen nå til nærmeste av åtte 45°-sektorer i stedet for å rotere kontinuerlig.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the maintainer (English — strip before publication)
|
||||
|
||||
This statement is honest today but must be kept up to date. Before using it as a deliverable alongside a public-sector licensing agreement or a live public-sector deployment:
|
||||
|
||||
- [ ] Replace `olemd@odinprosjekt.no` in §5 with a dedicated, monitored, public-facing address that the licensee (or you, if self-hosting) commits to reading within the stated response time.
|
||||
- [ ] After each release that ships an accessibility fix, move the corresponding item from §3.1 to §7 (Historikk) under a new `Versjon X.Y.Z` subsection, and update §1 *Samsvarsstatus* accordingly. When §3.1 and §3.2 are both empty, §1 becomes "i samsvar".
|
||||
- [ ] Update §4 *Dato for siste gjennomgang* at every release where user-facing code or content changes, even if nothing in §3 changes.
|
||||
- [ ] Commission an independent WCAG 2.2 AA audit before the first public-sector deployment. §4 must then be amended from "Egenkontroll" to cite the auditor, their contact, and the audit date.
|
||||
- [x] Confirm §6 *Tilsynsmyndighet*. Digdir / Uutilsynet is the correct supervisory body for this app (confirmed 2026-04-17); no buyer-specific override expected.
|
||||
- [ ] Re-verify the §3.2 disproportionate-burden claim before publication. WAD requires a documented cost-vs-benefit assessment, not a bare claim. Keep a short internal memo on file.
|
||||
- [ ] If an English translation is provided for international reviewers, mark it clearly as a courtesy translation and keep the Bokmål version authoritative.
|
||||
- [ ] Re-verify: is the deployed service actually covered by WAD? Privately-owned apps are exempt; coverage begins once the app is procured by a public-sector body or otherwise delivers a public-sector service.
|
||||
|
|
@ -14,8 +14,8 @@ android {
|
|||
applicationId = "no.naiv.tilfluktsrom"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 12
|
||||
versionName = "1.8.0"
|
||||
versionCode = 13
|
||||
versionName = "1.9.0"
|
||||
|
||||
// Deep link domain — single source of truth for manifest + Kotlin code
|
||||
val deepLinkDomain = "tilfluktsrom.naiv.no"
|
||||
|
|
@ -109,4 +109,7 @@ dependencies {
|
|||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
// JVM unit tests
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
// Only the full-screen compass arrow speaks direction changes to
|
||||
// TalkBack; the mini arrow in the bottom sheet would otherwise
|
||||
// double-announce every turn of the device.
|
||||
binding.miniArrow.announceDirectionChanges = false
|
||||
|
||||
repository = ShelterRepository(this)
|
||||
locationProvider = LocationProvider(this)
|
||||
mapCacheManager = MapCacheManager(this)
|
||||
|
|
@ -516,22 +521,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
R.string.shelter_capacity, selected.shelter.plasser
|
||||
) + " - " + distanceText
|
||||
|
||||
// Update direction arrows with accessibility descriptions
|
||||
// Update direction arrows. The view derives its own accessibility
|
||||
// description from distanceText + the angle we feed into
|
||||
// setDirection(), so we don't set contentDescription manually.
|
||||
val bearing = selected.bearingDegrees.toFloat()
|
||||
val arrowAngle = bearing - deviceHeading
|
||||
binding.miniArrow.setDistanceText(distanceText)
|
||||
binding.miniArrow.setDirection(arrowAngle)
|
||||
binding.miniArrow.contentDescription = getString(
|
||||
R.string.direction_arrow_description, distanceText
|
||||
)
|
||||
|
||||
// Update compass view (large arrow gets a north indicator)
|
||||
binding.compassDistanceText.text = distanceText
|
||||
binding.compassAddressText.text = selected.shelter.adresse
|
||||
binding.directionArrow.setDistanceText(distanceText)
|
||||
binding.directionArrow.setDirection(arrowAngle)
|
||||
binding.directionArrow.setNorthAngle(-deviceHeading)
|
||||
binding.directionArrow.contentDescription = getString(
|
||||
R.string.direction_arrow_description, distanceText
|
||||
)
|
||||
|
||||
// Emphasize the selected marker on the map
|
||||
highlightSelectedMarker(selected.shelter.lokalId)
|
||||
|
|
|
|||
|
|
@ -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.8.0")
|
||||
.header("User-Agent", "Tilfluktsrom/1.9.0")
|
||||
.build())
|
||||
})
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import android.graphics.Canvas
|
|||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import no.naiv.tilfluktsrom.R
|
||||
|
||||
/**
|
||||
|
|
@ -20,6 +23,19 @@ import no.naiv.tilfluktsrom.R
|
|||
*
|
||||
* Optionally draws a discrete north indicator on the perimeter so users
|
||||
* can validate compass calibration against a known direction.
|
||||
*
|
||||
* Accessibility:
|
||||
* - Exposes direction ("straight ahead" / "to the right" / ...) and
|
||||
* distance to TalkBack via contentDescription, updated on every call
|
||||
* to [setDirection] using the distance set via [setDistanceText].
|
||||
* - Announces changes when the direction crosses a 45° sector boundary,
|
||||
* throttled so rapid device rotation doesn't spam the screen reader.
|
||||
*
|
||||
* Reduced motion (WCAG 2.3.3):
|
||||
* - When the user has set Android's animator duration scale to 0
|
||||
* ("Remove animations"), the arrow snaps to the nearest 45° sector
|
||||
* instead of rotating smoothly, removing the continuous motion that
|
||||
* can trigger vestibular symptoms.
|
||||
*/
|
||||
class DirectionArrowView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
|
|
@ -30,6 +46,26 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
private var rotationAngle = 0f
|
||||
private var northAngle = Float.NaN
|
||||
|
||||
/** Last distance text set by the owner (e.g. "1.2 km"). */
|
||||
private var distanceText: String = ""
|
||||
|
||||
/** Controls whether this instance announces direction changes to
|
||||
* TalkBack. The mini arrow in the bottom sheet sets this to false
|
||||
* so the full-screen compass arrow is the only one that speaks. */
|
||||
var announceDirectionChanges: Boolean = true
|
||||
|
||||
private var lastAnnouncedSector: Int = -1
|
||||
private var lastAnnounceTimeMs: Long = 0L
|
||||
|
||||
/** True when the user has disabled OS animations. Read once; users
|
||||
* rarely change this setting while the view is alive. */
|
||||
private val reduceMotion: Boolean =
|
||||
Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f
|
||||
) == 0f
|
||||
|
||||
private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = context.getColor(R.color.shelter_primary)
|
||||
style = Paint.Style.FILL
|
||||
|
|
@ -54,13 +90,33 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
private val arrowPath = Path()
|
||||
private val northPath = Path()
|
||||
|
||||
/**
|
||||
* Set the distance text used in the accessibility description
|
||||
* (e.g. "1.2 km"). Should be called whenever the selected shelter
|
||||
* or user location changes. Does not trigger a redraw on its own;
|
||||
* the next [setDirection] call picks up the new text.
|
||||
*/
|
||||
fun setDistanceText(text: String) {
|
||||
distanceText = text
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the rotation angle in degrees.
|
||||
* 0 = pointing up (north/forward), positive = clockwise.
|
||||
* 0 = pointing up (toward the shelter if facing it), positive = clockwise.
|
||||
*
|
||||
* Also refreshes the accessibility description and, if the sector
|
||||
* changed, announces the new direction to TalkBack.
|
||||
*/
|
||||
fun setDirection(degrees: Float) {
|
||||
rotationAngle = degrees
|
||||
invalidate()
|
||||
// Visual: snap to 8 cardinal sectors when reduce-motion is on so
|
||||
// the arrow doesn't rotate continuously with device movement.
|
||||
val displayAngle = if (reduceMotion) snapToSector(degrees) else degrees
|
||||
if (displayAngle != rotationAngle) {
|
||||
rotationAngle = displayAngle
|
||||
invalidate()
|
||||
}
|
||||
|
||||
updateAccessibility(degrees)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -132,4 +188,71 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
|
||||
super.onInitializeAccessibilityNodeInfo(info)
|
||||
// Present as an ImageView so screen readers treat the description
|
||||
// as the full semantic content, not a container label.
|
||||
info.className = "android.widget.ImageView"
|
||||
}
|
||||
|
||||
/**
|
||||
* Update [contentDescription] from the latest bearing and distance,
|
||||
* and announce the change if the user has crossed a 45° sector
|
||||
* boundary and enough time has passed since the last announcement.
|
||||
*/
|
||||
private fun updateAccessibility(angleDegrees: Float) {
|
||||
val sector = sectorIndex(angleDegrees)
|
||||
val directionText = context.getString(sectorStringRes(sector))
|
||||
val description = context.getString(
|
||||
R.string.a11y_direction_with_distance,
|
||||
directionText,
|
||||
distanceText
|
||||
)
|
||||
contentDescription = description
|
||||
|
||||
if (!announceDirectionChanges) return
|
||||
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (sector != lastAnnouncedSector &&
|
||||
now - lastAnnounceTimeMs >= ANNOUNCE_INTERVAL_MS
|
||||
) {
|
||||
lastAnnouncedSector = sector
|
||||
lastAnnounceTimeMs = now
|
||||
announceForAccessibility(description)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sectorStringRes(sector: Int): Int = when (sector) {
|
||||
0 -> R.string.a11y_dir_forward
|
||||
1 -> R.string.a11y_dir_forward_right
|
||||
2 -> R.string.a11y_dir_right
|
||||
3 -> R.string.a11y_dir_back_right
|
||||
4 -> R.string.a11y_dir_back
|
||||
5 -> R.string.a11y_dir_back_left
|
||||
6 -> R.string.a11y_dir_left
|
||||
7 -> R.string.a11y_dir_forward_left
|
||||
else -> R.string.a11y_dir_forward
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Minimum gap between TalkBack announcements. Prevents a rapidly
|
||||
* turning user from flooding the screen reader queue. */
|
||||
private const val ANNOUNCE_INTERVAL_MS = 750L
|
||||
|
||||
/** Map a raw angle (any float) to one of 8 sectors, 0..7 starting
|
||||
* at "forward" (0°) and going clockwise in 45° steps. Pure function;
|
||||
* exposed as `internal` so it can be unit-tested on the JVM without
|
||||
* the Android framework. */
|
||||
internal fun sectorIndex(angleDegrees: Float): Int {
|
||||
var a = angleDegrees % 360f
|
||||
if (a < 0f) a += 360f
|
||||
return (((a + 22.5f) / 45f).toInt()) % 8
|
||||
}
|
||||
|
||||
/** Snap to the centre of the sector the current angle falls in.
|
||||
* Returns a value in {0, 45, 90, 135, 180, 225, 270, 315}. */
|
||||
internal fun snapToSector(angleDegrees: Float): Float =
|
||||
sectorIndex(angleDegrees) * 45f
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,11 +36,14 @@
|
|||
android:textSize="12sp"
|
||||
tools:text="@string/status_ready" />
|
||||
|
||||
<!-- Use bounded selectableItemBackground (not borderless) so the
|
||||
focus ring is clearly visible when navigating by keyboard or
|
||||
switch control — required for WCAG 2.4.7 Focus Visible. -->
|
||||
<ImageButton
|
||||
android:id="@+id/infoButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:contentDescription="@string/action_civil_defense_info"
|
||||
android:src="@drawable/ic_info"
|
||||
app:tint="@color/status_text" />
|
||||
|
|
@ -49,7 +52,7 @@
|
|||
android:id="@+id/refreshButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:contentDescription="@string/action_refresh"
|
||||
android:src="@drawable/ic_refresh"
|
||||
app:tint="@color/status_text" />
|
||||
|
|
@ -90,7 +93,9 @@
|
|||
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
||||
android:id="@+id/directionArrow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="true"
|
||||
android:importantForAccessibility="yes" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/compassDistanceText"
|
||||
|
|
@ -197,7 +202,8 @@
|
|||
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
||||
android:id="@+id/miniArrow"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp" />
|
||||
android:layout_height="48dp"
|
||||
android:importantForAccessibility="yes" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
|
|
@ -229,7 +235,7 @@
|
|||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:contentDescription="@string/action_share"
|
||||
android:src="@drawable/ic_share"
|
||||
app:tint="@color/text_secondary" />
|
||||
|
|
|
|||
|
|
@ -69,6 +69,15 @@
|
|||
<string name="compass_accuracy_warning">Upresist kompass - %s</string>
|
||||
<string name="a11y_map">Tilfluktsromkart</string>
|
||||
<string name="a11y_compass">Kompassnavigasjon</string>
|
||||
<string name="a11y_direction_with_distance">Retning til tilfluktsrom: %1$s, %2$s unna</string>
|
||||
<string name="a11y_dir_forward">rett frem</string>
|
||||
<string name="a11y_dir_forward_right">fremover til høyre</string>
|
||||
<string name="a11y_dir_right">til høyre</string>
|
||||
<string name="a11y_dir_back_right">bak til høyre</string>
|
||||
<string name="a11y_dir_back">rett bak</string>
|
||||
<string name="a11y_dir_back_left">bak til venstre</string>
|
||||
<string name="a11y_dir_left">til venstre</string>
|
||||
<string name="a11y_dir_forward_left">fremover til venstre</string>
|
||||
|
||||
<!-- Sivilforsvar -->
|
||||
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,15 @@
|
|||
<string name="compass_accuracy_warning">Upresis kompass - %s</string>
|
||||
<string name="a11y_map">Tilfluktsromkart</string>
|
||||
<string name="a11y_compass">Kompassnavigasjon</string>
|
||||
<string name="a11y_direction_with_distance">Retning til tilfluktsrom: %1$s, %2$s unna</string>
|
||||
<string name="a11y_dir_forward">rett fram</string>
|
||||
<string name="a11y_dir_forward_right">framover til høgre</string>
|
||||
<string name="a11y_dir_right">til høgre</string>
|
||||
<string name="a11y_dir_back_right">bak til høgre</string>
|
||||
<string name="a11y_dir_back">rett bak</string>
|
||||
<string name="a11y_dir_back_left">bak til venstre</string>
|
||||
<string name="a11y_dir_left">til venstre</string>
|
||||
<string name="a11y_dir_forward_left">framover til venstre</string>
|
||||
|
||||
<!-- Sivilforsvar -->
|
||||
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
<color name="text_primary">#ECEFF1</color>
|
||||
<color name="text_secondary">#90A4AE</color>
|
||||
|
||||
<color name="warning_bg">#E65100</color>
|
||||
<!-- Warning banner: darker orange so white text meets WCAG 2.2 AA (SC 1.4.3).
|
||||
#BF360C gives ~5.5:1 vs white; previous #E65100 was only 3.75:1. -->
|
||||
<color name="warning_bg">#BF360C</color>
|
||||
<color name="warning_text">#FFFFFF</color>
|
||||
|
||||
<color name="white">#FFFFFF</color>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,16 @@
|
|||
<string name="compass_accuracy_warning">Low accuracy - %s</string>
|
||||
<string name="a11y_map">Shelter map</string>
|
||||
<string name="a11y_compass">Compass navigation</string>
|
||||
<!-- %1$s is one of the a11y_dir_* strings, %2$s is the distance. -->
|
||||
<string name="a11y_direction_with_distance">Direction to shelter: %1$s, %2$s away</string>
|
||||
<string name="a11y_dir_forward">straight ahead</string>
|
||||
<string name="a11y_dir_forward_right">ahead to the right</string>
|
||||
<string name="a11y_dir_right">to the right</string>
|
||||
<string name="a11y_dir_back_right">behind to the right</string>
|
||||
<string name="a11y_dir_back">behind you</string>
|
||||
<string name="a11y_dir_back_left">behind to the left</string>
|
||||
<string name="a11y_dir_left">to the left</string>
|
||||
<string name="a11y_dir_forward_left">ahead to the left</string>
|
||||
|
||||
<!-- Civil defense info -->
|
||||
<string name="action_civil_defense_info">Civil defense information</string>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
<item name="android:windowBackground">@color/background</item>
|
||||
<item name="android:statusBarColor">@color/status_bar_bg</item>
|
||||
<item name="android:navigationBarColor">@color/background</item>
|
||||
<!-- WCAG 2.4.7 Focus Visible: ensure the framework draws a default
|
||||
focus highlight on any focusable view that doesn't supply one of
|
||||
its own (e.g. our custom DirectionArrowView). -->
|
||||
<item name="android:defaultFocusHighlightEnabled">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Tilfluktsrom.Dialog" parent="Theme.Material3.Dark.Dialog">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package no.naiv.tilfluktsrom.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure-JVM tests for [DirectionArrowView.sectorIndex] and
|
||||
* [DirectionArrowView.snapToSector].
|
||||
*
|
||||
* These two functions are what the reduce-motion (WCAG 2.3.3) remediation
|
||||
* relies on: when animations are disabled, the arrow snaps to one of eight
|
||||
* 45° sectors instead of rotating continuously. The sector index also drives
|
||||
* the TalkBack accessibility description (WCAG 4.1.2), so any off-by-one in
|
||||
* this arithmetic is a silent a11y regression. Worth locking down with tests
|
||||
* that can run in milliseconds without booting an emulator.
|
||||
*/
|
||||
class DirectionArrowViewTest {
|
||||
|
||||
@Test
|
||||
fun sectorIndex_wholeCardinals_returnExpectedSector() {
|
||||
assertEquals(0, DirectionArrowView.sectorIndex(0f)) // forward
|
||||
assertEquals(1, DirectionArrowView.sectorIndex(45f)) // forward-right
|
||||
assertEquals(2, DirectionArrowView.sectorIndex(90f)) // right
|
||||
assertEquals(3, DirectionArrowView.sectorIndex(135f)) // back-right
|
||||
assertEquals(4, DirectionArrowView.sectorIndex(180f)) // back
|
||||
assertEquals(5, DirectionArrowView.sectorIndex(225f)) // back-left
|
||||
assertEquals(6, DirectionArrowView.sectorIndex(270f)) // left
|
||||
assertEquals(7, DirectionArrowView.sectorIndex(315f)) // forward-left
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sectorIndex_boundariesSnapToNextSector() {
|
||||
// A sector covers [center - 22.5°, center + 22.5°). The boundary
|
||||
// value itself should belong to the following sector.
|
||||
assertEquals(0, DirectionArrowView.sectorIndex(22.4f))
|
||||
assertEquals(1, DirectionArrowView.sectorIndex(22.5f))
|
||||
assertEquals(1, DirectionArrowView.sectorIndex(44.9f))
|
||||
assertEquals(1, DirectionArrowView.sectorIndex(67.4f))
|
||||
assertEquals(2, DirectionArrowView.sectorIndex(67.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sectorIndex_wrapsAroundAt360() {
|
||||
// Anything within 22.5° of north should land back in sector 0.
|
||||
assertEquals(7, DirectionArrowView.sectorIndex(337.4f))
|
||||
assertEquals(0, DirectionArrowView.sectorIndex(337.5f))
|
||||
assertEquals(0, DirectionArrowView.sectorIndex(359.99f))
|
||||
assertEquals(0, DirectionArrowView.sectorIndex(360f))
|
||||
assertEquals(0, DirectionArrowView.sectorIndex(720f))
|
||||
assertEquals(2, DirectionArrowView.sectorIndex(450f)) // 450 % 360 = 90
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sectorIndex_handlesNegativeAngles() {
|
||||
// Sensors can produce angles slightly below zero due to smoothing;
|
||||
// subtracting deviceHeading from shelterBearing routinely yields
|
||||
// negative values. The function must normalise rather than throw.
|
||||
assertEquals(7, DirectionArrowView.sectorIndex(-45f))
|
||||
assertEquals(4, DirectionArrowView.sectorIndex(-180f))
|
||||
assertEquals(0, DirectionArrowView.sectorIndex(-10f))
|
||||
assertEquals(6, DirectionArrowView.sectorIndex(-90f))
|
||||
assertEquals(0, DirectionArrowView.sectorIndex(-360f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapToSector_alwaysReturnsMultipleOf45InZeroTo315() {
|
||||
// Sweep a dense range of inputs; every output must be a whole 45°
|
||||
// step in [0, 315]. This is the invariant that prevents vestibular
|
||||
// motion triggers when reduce-motion is enabled.
|
||||
var angle = -720f
|
||||
while (angle <= 720f) {
|
||||
val snapped = DirectionArrowView.snapToSector(angle)
|
||||
val remainder = snapped % 45f
|
||||
assertEquals(
|
||||
"snap($angle) = $snapped is not a multiple of 45°",
|
||||
0f, remainder, 0.0001f
|
||||
)
|
||||
assertEquals(
|
||||
"snap($angle) = $snapped is outside [0, 315]",
|
||||
true, snapped in 0f..315f
|
||||
)
|
||||
angle += 0.37f // irrational-ish step hits boundaries from both sides
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapToSector_knownPoints() {
|
||||
assertEquals(0f, DirectionArrowView.snapToSector(0f), 0.0001f)
|
||||
assertEquals(0f, DirectionArrowView.snapToSector(22.4f), 0.0001f)
|
||||
assertEquals(45f, DirectionArrowView.snapToSector(22.5f), 0.0001f)
|
||||
assertEquals(90f, DirectionArrowView.snapToSector(90f), 0.0001f)
|
||||
assertEquals(180f, DirectionArrowView.snapToSector(180f), 0.0001f)
|
||||
// -45° normalises to 315° (sector 7), which snaps to 315°.
|
||||
assertEquals(315f, DirectionArrowView.snapToSector(-45f), 0.0001f)
|
||||
// Just past full rotation wraps to forward.
|
||||
assertEquals(0f, DirectionArrowView.snapToSector(359.9f), 0.0001f)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue