Utbedre WCAG 2.2 AA-avvik og legg til tilgjengelighetserklæring
Fire WCAG-avvik utbedret før appen kan lisensieres til offentlig sektor (WAD / EN 301 549): - SC 1.4.3: Advarselsbanner til #BF360C (~5,5:1 mot hvit, var 3,75:1). - SC 2.4.7: defaultFocusHighlightEnabled i temaet; ikonknapper bruker bundet selectableItemBackground. - SC 4.1.2: DirectionArrowView rapporterer ImageView-rolle og annonserer retning + avstand til TalkBack når en 45°-sektor krysses, med 750 ms-struping mot spam. - SC 2.3.3: Snapper til nærmeste 45° når brukeren har slått av animasjoner (ANIMATOR_DURATION_SCALE=0). Nye retningsstrenger i nb/nn/en. JVM-unittester for sektoraritmetikken sikrer grensetilfeller (negative vinkler, 360°-overgang, vilkårlige vinkler). ACCESSIBILITY.md dokumenterer tilgjengelighetserklæring etter WAD artikkel 7, i bokmål med engelsk preambel og vedlikeholdsjekkliste. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
22fad9e1db
commit
e4f44ede97
11 changed files with 364 additions and 16 deletions
81
ACCESSIBILITY.md
Normal file
81
ACCESSIBILITY.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# 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). De kjente avvikene er listet opp i avsnitt 3.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Følgende avvik er identifisert ved egenkontroll. Utbedringer er lagt inn i kildekoden per 17. april 2026 og inngår i første utgave som bygges og publiseres etter denne datoen. Når utgaven er publisert, flyttes hvert punkt til et «Utbedret»-avsnitt med konkret versjonsnummer, og avsnitt 1 *Samsvarsstatus* oppdateres tilsvarende.
|
||||||
|
|
||||||
|
- **WCAG 2.4.7 Synlig fokus.** Android-appen hadde ikke tydelig synlig fokusmarkering på alle egendefinerte visninger (bl.a. FAB-knapper og den egenutviklede retningspilen `DirectionArrowView`). Skjermtastatur- og bryterbrukere kunne derfor miste oversikten over hvor fokus var plassert. *Status per 17. april 2026: utbedret i kildekode. 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 blir synlig ved tastatur- og bryternavigasjon. Venter på utgivelse.*
|
||||||
|
- **WCAG 4.1.2 Navn, rolle, verdi.** Retningspilen i kompassvisningen er tegnet på et `Canvas` og eksponerte ikke retnings- og avstandsinformasjon til TalkBack på en måte som ble oppdatert dynamisk. Skjermleserbrukere fikk derfor ikke samme navigasjonsstøtte som seende brukere. *Status per 17. april 2026: utbedret i kildekode. `DirectionArrowView` rapporterer seg 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. Venter på utgivelse.*
|
||||||
|
- **WCAG 1.4.3 Minimumskontrast.** Advarselsbanneret for manglende offline-kart brukte bakgrunnsfarge `#E65100` med hvit tekst. Kontrastforholdet var 3,75 : 1, under AA-kravet på 4,5 : 1. *Status per 17. april 2026: utbedret i kildekode. Bakgrunnsfargen er endret til `#BF360C`, som gir kontrast på omkring 5,5 : 1 mot hvit tekst. Venter på utgivelse.*
|
||||||
|
- **WCAG 2.3.3 Bevegelse fra interaksjon.** Retningspilen roterte kontinuerlig med kompasset mens brukeren vendte på enheten. Appen tok ikke hensyn til Android-innstillingen «Fjern animasjoner» / redusert bevegelse, og brukere med vestibulære plager hadde ikke mulighet til å slå av den kontinuerlige rotasjonen. *Status per 17. april 2026: utbedret i kildekode. Når `Settings.Global.ANIMATOR_DURATION_SCALE` er 0 (brukeren har slått av animasjoner), snapper retningspilen til nærmeste av åtte 45°-sektorer i stedet for å rotere kontinuerlig. Venter på utgivelse.*
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 an "Utbedret i versjon X.Y.Z" (resolved) list and update §1 *Samsvarsstatus* accordingly. When §3.1 is empty, §1 becomes "i samsvar".
|
||||||
|
- [ ] Replace each *Venter på utgivelse* status with a concrete version number as soon as that version ships, so buyers can verify against the changelog.
|
||||||
|
- [ ] 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.
|
||||||
|
- [ ] Confirm §6 *Tilsynsmyndighet* matches the actual supervisory body for the buyer's sector. For most Norwegian public-sector bodies it is Digdir/Uutilsynet, but sector-specific services (e.g. under DSB or Sivilforsvaret) may have alternative complaint paths.
|
||||||
|
- [ ] 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.
|
||||||
|
|
@ -109,4 +109,7 @@ dependencies {
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
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)
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
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)
|
repository = ShelterRepository(this)
|
||||||
locationProvider = LocationProvider(this)
|
locationProvider = LocationProvider(this)
|
||||||
mapCacheManager = MapCacheManager(this)
|
mapCacheManager = MapCacheManager(this)
|
||||||
|
|
@ -516,22 +521,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
R.string.shelter_capacity, selected.shelter.plasser
|
R.string.shelter_capacity, selected.shelter.plasser
|
||||||
) + " - " + distanceText
|
) + " - " + 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 bearing = selected.bearingDegrees.toFloat()
|
||||||
val arrowAngle = bearing - deviceHeading
|
val arrowAngle = bearing - deviceHeading
|
||||||
|
binding.miniArrow.setDistanceText(distanceText)
|
||||||
binding.miniArrow.setDirection(arrowAngle)
|
binding.miniArrow.setDirection(arrowAngle)
|
||||||
binding.miniArrow.contentDescription = getString(
|
|
||||||
R.string.direction_arrow_description, distanceText
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update compass view (large arrow gets a north indicator)
|
// Update compass view (large arrow gets a north indicator)
|
||||||
binding.compassDistanceText.text = distanceText
|
binding.compassDistanceText.text = distanceText
|
||||||
binding.compassAddressText.text = selected.shelter.adresse
|
binding.compassAddressText.text = selected.shelter.adresse
|
||||||
|
binding.directionArrow.setDistanceText(distanceText)
|
||||||
binding.directionArrow.setDirection(arrowAngle)
|
binding.directionArrow.setDirection(arrowAngle)
|
||||||
binding.directionArrow.setNorthAngle(-deviceHeading)
|
binding.directionArrow.setNorthAngle(-deviceHeading)
|
||||||
binding.directionArrow.contentDescription = getString(
|
|
||||||
R.string.direction_arrow_description, distanceText
|
|
||||||
)
|
|
||||||
|
|
||||||
// Emphasize the selected marker on the map
|
// Emphasize the selected marker on the map
|
||||||
highlightSelectedMarker(selected.shelter.lokalId)
|
highlightSelectedMarker(selected.shelter.lokalId)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@ import android.graphics.Canvas
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.provider.Settings
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo
|
||||||
import no.naiv.tilfluktsrom.R
|
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
|
* Optionally draws a discrete north indicator on the perimeter so users
|
||||||
* can validate compass calibration against a known direction.
|
* 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(
|
class DirectionArrowView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -30,6 +46,26 @@ class DirectionArrowView @JvmOverloads constructor(
|
||||||
private var rotationAngle = 0f
|
private var rotationAngle = 0f
|
||||||
private var northAngle = Float.NaN
|
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 {
|
private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
color = context.getColor(R.color.shelter_primary)
|
color = context.getColor(R.color.shelter_primary)
|
||||||
style = Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
|
|
@ -54,15 +90,35 @@ class DirectionArrowView @JvmOverloads constructor(
|
||||||
private val arrowPath = Path()
|
private val arrowPath = Path()
|
||||||
private val northPath = 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.
|
* 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) {
|
fun setDirection(degrees: Float) {
|
||||||
rotationAngle = degrees
|
// 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()
|
invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAccessibility(degrees)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the angle to north in the view's coordinate space.
|
* Set the angle to north in the view's coordinate space.
|
||||||
* This is typically -deviceHeading (where north is on screen).
|
* This is typically -deviceHeading (where north is on screen).
|
||||||
|
|
@ -132,4 +188,71 @@ class DirectionArrowView @JvmOverloads constructor(
|
||||||
|
|
||||||
canvas.restore()
|
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"
|
android:textSize="12sp"
|
||||||
tools:text="@string/status_ready" />
|
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
|
<ImageButton
|
||||||
android:id="@+id/infoButton"
|
android:id="@+id/infoButton"
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="48dp"
|
android:layout_height="48dp"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackground"
|
||||||
android:contentDescription="@string/action_civil_defense_info"
|
android:contentDescription="@string/action_civil_defense_info"
|
||||||
android:src="@drawable/ic_info"
|
android:src="@drawable/ic_info"
|
||||||
app:tint="@color/status_text" />
|
app:tint="@color/status_text" />
|
||||||
|
|
@ -49,7 +52,7 @@
|
||||||
android:id="@+id/refreshButton"
|
android:id="@+id/refreshButton"
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="48dp"
|
android:layout_height="48dp"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackground"
|
||||||
android:contentDescription="@string/action_refresh"
|
android:contentDescription="@string/action_refresh"
|
||||||
android:src="@drawable/ic_refresh"
|
android:src="@drawable/ic_refresh"
|
||||||
app:tint="@color/status_text" />
|
app:tint="@color/status_text" />
|
||||||
|
|
@ -90,7 +93,9 @@
|
||||||
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
||||||
android:id="@+id/directionArrow"
|
android:id="@+id/directionArrow"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent"
|
||||||
|
android:focusable="true"
|
||||||
|
android:importantForAccessibility="yes" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/compassDistanceText"
|
android:id="@+id/compassDistanceText"
|
||||||
|
|
@ -197,7 +202,8 @@
|
||||||
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
<no.naiv.tilfluktsrom.ui.DirectionArrowView
|
||||||
android:id="@+id/miniArrow"
|
android:id="@+id/miniArrow"
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="48dp" />
|
android:layout_height="48dp"
|
||||||
|
android:importantForAccessibility="yes" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|
@ -229,7 +235,7 @@
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="48dp"
|
android:layout_height="48dp"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackground"
|
||||||
android:contentDescription="@string/action_share"
|
android:contentDescription="@string/action_share"
|
||||||
android:src="@drawable/ic_share"
|
android:src="@drawable/ic_share"
|
||||||
app:tint="@color/text_secondary" />
|
app:tint="@color/text_secondary" />
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,15 @@
|
||||||
<string name="compass_accuracy_warning">Upresist kompass - %s</string>
|
<string name="compass_accuracy_warning">Upresist kompass - %s</string>
|
||||||
<string name="a11y_map">Tilfluktsromkart</string>
|
<string name="a11y_map">Tilfluktsromkart</string>
|
||||||
<string name="a11y_compass">Kompassnavigasjon</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 -->
|
<!-- Sivilforsvar -->
|
||||||
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,15 @@
|
||||||
<string name="compass_accuracy_warning">Upresis kompass - %s</string>
|
<string name="compass_accuracy_warning">Upresis kompass - %s</string>
|
||||||
<string name="a11y_map">Tilfluktsromkart</string>
|
<string name="a11y_map">Tilfluktsromkart</string>
|
||||||
<string name="a11y_compass">Kompassnavigasjon</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 -->
|
<!-- Sivilforsvar -->
|
||||||
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
<color name="text_primary">#ECEFF1</color>
|
<color name="text_primary">#ECEFF1</color>
|
||||||
<color name="text_secondary">#90A4AE</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="warning_text">#FFFFFF</color>
|
||||||
|
|
||||||
<color name="white">#FFFFFF</color>
|
<color name="white">#FFFFFF</color>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,16 @@
|
||||||
<string name="compass_accuracy_warning">Low accuracy - %s</string>
|
<string name="compass_accuracy_warning">Low accuracy - %s</string>
|
||||||
<string name="a11y_map">Shelter map</string>
|
<string name="a11y_map">Shelter map</string>
|
||||||
<string name="a11y_compass">Compass navigation</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 -->
|
<!-- Civil defense info -->
|
||||||
<string name="action_civil_defense_info">Civil defense information</string>
|
<string name="action_civil_defense_info">Civil defense information</string>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@
|
||||||
<item name="android:windowBackground">@color/background</item>
|
<item name="android:windowBackground">@color/background</item>
|
||||||
<item name="android:statusBarColor">@color/status_bar_bg</item>
|
<item name="android:statusBarColor">@color/status_bar_bg</item>
|
||||||
<item name="android:navigationBarColor">@color/background</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>
|
||||||
|
|
||||||
<style name="Theme.Tilfluktsrom.Dialog" parent="Theme.Material3.Dark.Dialog">
|
<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