Compare commits

...

3 commits

Author SHA1 Message Date
29d7747b51
fix: track location 2026-01-12 18:28:22 +01:00
9b92fed825
fix: a11y wip 2026-01-12 18:27:57 +01:00
256d0a49a0
chore: vscode up 2026-01-12 15:29:48 +01:00
91 changed files with 2762 additions and 491 deletions

View file

@ -40,7 +40,7 @@ module.exports = {
emulator: { emulator: {
type: 'android.emulator', type: 'android.emulator',
device: { device: {
avdName: process.env.ANDROID_EMULATOR_NAME || 'Pixel_6_API_30' avdName: process.env.ANDROID_EMULATOR_NAME || 'Medium_Phone_API_36.0'
} }
} }
}, },

View file

@ -27,6 +27,7 @@ module.exports = {
"sort-keys-fix", "sort-keys-fix",
"react", "react",
"react-native", "react-native",
"react-native-a11y",
"unused-imports", "unused-imports",
"autoimport-declarative", "autoimport-declarative",
], ],
@ -39,7 +40,7 @@ module.exports = {
typescript: {}, typescript: {},
}, },
}, },
ignorePatterns: ["build", "node_modules", "e2e"], ignorePatterns: ["build", "node_modules", "e2e", "**/*.bak.js"],
rules: { rules: {
"no-undef": [2], "no-undef": [2],
"react/forbid-prop-types": [0], "react/forbid-prop-types": [0],
@ -47,6 +48,12 @@ module.exports = {
"react/jsx-uses-vars": 1, "react/jsx-uses-vars": 1,
"react-hooks/exhaustive-deps": "error", "react-hooks/exhaustive-deps": "error",
"jsx-a11y/no-autofocus": 0, "jsx-a11y/no-autofocus": 0,
// React-Native accessibility: start as warnings to enable gradual adoption
// without breaking the build; we will tighten to errors as we fix surfaces.
"react-native-a11y/has-accessibility-hint": "error",
"react-native-a11y/has-valid-accessibility-descriptors": "error",
"import/no-named-as-default": 0, "import/no-named-as-default": 0,
"import/no-named-as-default-member": 0, "import/no-named-as-default-member": 0,
// 'unused-imports/no-unused-imports-ts': 1, # enable and run yarn lint --fix to autoremove all unused imports // 'unused-imports/no-unused-imports-ts': 1, # enable and run yarn lint --fix to autoremove all unused imports

View file

@ -30,6 +30,9 @@ jobs:
# uses: https://git.devthefuture.org/devthefuture/actions/yarn-install@v0.4.0 # uses: https://git.devthefuture.org/devthefuture/actions/yarn-install@v0.4.0
uses: devthefuture-org/actions/yarn-install@v0.4.0 uses: devthefuture-org/actions/yarn-install@v0.4.0
- name: ✅ Lint (including a11y rules)
run: yarn lint
- name: ⛾ Gradle cache - name: ⛾ Gradle cache
uses: actions/cache@v3 uses: actions/cache@v3
with: with:

1
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -11,6 +11,7 @@ This document contains technical information for developers working on the Alert
- [Android](#android) - [Android](#android)
- [iOS](#ios) - [iOS](#ios)
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [Accessibility](#accessibility)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
## Project Overview ## Project Overview
@ -197,6 +198,35 @@ Guidelines for contributing to the project:
3. Update documentation as needed 3. Update documentation as needed
4. Use the ESLint and Prettier configurations 4. Use the ESLint and Prettier configurations
## Accessibility
This app has an accessibility baseline (WCAG 2.2 AA, VoiceOver/TalkBack) and app-specific conventions.
### Docs
- Baseline checklist: [`docs/a11y-wcag22-aa.md`](docs/a11y-wcag22-aa.md:1)
- Code conventions + helpers: [`docs/a11y-usage.md`](docs/a11y-usage.md:1)
- Color contrast guidance: [`docs/a11y-color-contrast.md`](docs/a11y-color-contrast.md:1)
- `testID` conventions: [`docs/testids.md`](docs/testids.md:1)
- QA runbook (iOS VoiceOver): [`docs/qa-voiceover.md`](docs/qa-voiceover.md:1)
- QA runbook (Android TalkBack): [`docs/qa-talkback.md`](docs/qa-talkback.md:1)
### PR checklist (required for any UI change)
- [ ] **Roles / labels / hints / states**: all interactive controls expose correct `accessibilityRole`, meaningful `accessibilityLabel`, helpful `accessibilityHint` (especially icon-only actions), and state where applicable.
- [ ] **Focus management**: any modal/dialog/sheet sets initial focus on open, returns focus on close, and avoids focus traps.
- [ ] **Touch target size**: critical tap targets are comfortably tappable (aim ~44x44pt minimum).
- [ ] **Color contrast**: text + icons meet WCAG AA contrast; do not rely on color alone for meaning.
- [ ] **`testID`s for critical controls**: stable `testID` added/updated for key actions and navigation chrome (per [`docs/testids.md`](docs/testids.md:1)).
- [ ] **Tests**: add/update tests covering the new UI behavior (and its states). Prefer assertions that dont depend on translated text; include E2E coverage for critical flows when applicable.
### Manual validation (screen readers)
When a PR changes UI or navigation, do a quick pass with the platform screen reader:
- iOS: follow [`docs/qa-voiceover.md`](docs/qa-voiceover.md:1) and validate labels/hints, navigation order, and activation behavior on the affected screens.
- Android: follow [`docs/qa-talkback.md`](docs/qa-talkback.md:1) with the same focus on discoverability, focus order, and activation.
## Troubleshooting ## Troubleshooting
### Common Issues ### Common Issues

174
docs/a11y-audit-log.md Normal file
View file

@ -0,0 +1,174 @@
# A11y audit log
Keep this log lightweight and practical. Prefer short entries that state:
- what was checked
- what was fixed
- what remains
Format:
```
YYYY-MM-DD - Area - Result - Notes
```
## Log
- 2026-01-06 - P0 conventions - Added documentation and testID conventions; added minimal `testID` for header actions, Send Alert CTAs, and ChatInput controls. (lint clean)
# Accessibility Audit Log
This log tracks manual accessibility verification for WCAG 2.2 AA on iOS (VoiceOver) and Android (TalkBack).
Reference docs:
- WCAG mapping: [docs/a11y-wcag22-aa.md](docs/a11y-wcag22-aa.md:1)
- VoiceOver runbook: [docs/qa-voiceover.md](docs/qa-voiceover.md:1)
- TalkBack runbook: [docs/qa-talkback.md](docs/qa-talkback.md:1)
- A11y target inventory: [docs/a11y-audit-targets.md](docs/a11y-audit-targets.md:1)
- TestID convention: [docs/testids.md](docs/testids.md:1)
---
## Template (copy/paste for each audit run)
### Audit run
- Date:
- Commit SHA:
- App version/build:
- Tester:
#### Devices
- iOS device:
- iOS version:
- VoiceOver settings used:
- Android device:
- Android version:
- TalkBack settings used:
#### Global checks (apply on every screen)
- [ ] Focus order matches visual order
- [ ] No focus traps (especially maps, drawers, modals)
- [ ] Controls have correct name/role/state
- [ ] Target size: 44x44 pt (iOS) / 48dp (Android) for primary controls
- [ ] Dynamic type / font scaling does not clip or hide text
- [ ] Error/success states are perceivable and announced when appropriate
- [ ] No duplicate focusable decorative elements
#### Results summary
- Blockers:
- Majors:
- Minors:
- Notes:
---
### Core journeys checklist (fill out)
#### 1) Send Alert (P0)
Targets:
- [src/scenes/SendAlert/index.js](src/scenes/SendAlert/index.js:1)
- [src/scenes/SendAlert/RadarModal.js](src/scenes/SendAlert/RadarModal.js:1)
Steps:
- [ ] Navigate to Send Alert
- [ ] Help toggle readable + state announced
- [ ] Radar button opens modal, focus goes to modal title
- [ ] Radar loading/success/error announced once (no spam)
- [ ] Close radar modal, focus returns to radar trigger
- [ ] All CTAs (red/yellow/green/unknown/call) are reachable and correctly described
Findings:
- iOS:
- Android:
#### 2) Chat + Messaging (P0)
Targets:
- [src/containers/ChatInput/index.js](src/containers/ChatInput/index.js:1)
- [src/containers/ChatMessages/index.js](src/containers/ChatMessages/index.js:1)
- [src/lib/expo-audio-player/index.js](src/lib/expo-audio-player/index.js:1)
Precondition:
- An alert with an open chat thread is available (or use a staging account).
Steps:
- [ ] Open chat thread
- [ ] Message list reads in correct order (sender, time, content)
- [ ] Text input is labeled and discoverable (testID `chat-input-text`)
- [ ] Send/mic control describes current action
- [ ] Start recording: announcement once
- [ ] Stop recording: announcement once
- [ ] Delete recording: destructive hint present
- [ ] Send text message: focus remains stable
- [ ] Play/pause audio: control labeled + state conveyed
- [ ] New incoming message: concise announcement (no spam)
Findings:
- iOS:
- Android:
#### 3) Map + Routing (P0)
Targets:
- [src/scenes/AlertCurMap/index.js](src/scenes/AlertCurMap/index.js:1)
- [src/containers/Map/MapView.js](src/containers/Map/MapView.js:1)
- [src/scenes/AlertCurMap/RoutingSteps.js](src/scenes/AlertCurMap/RoutingSteps.js:1)
Steps:
- [ ] Open Alert map screen
- [ ] Map surface is not focusable (no trap)
- [ ] Overlay controls are reachable and described (zoom/recenter/toggles)
- [ ] Non-visual alternative entry is discoverable before the map
- [ ] Open route steps list/drawer, focus goes to title
- [ ] Close route steps list/drawer, focus returns to trigger
Findings:
- iOS:
- Android:
#### 4) Settings / Permissions (P0)
Targets:
- [src/scenes/Params/Permissions.js](src/scenes/Params/Permissions.js:1)
- [src/containers/PermissionWizard/index.js](src/containers/PermissionWizard/index.js:1)
Steps:
- [ ] Open Permissions settings
- [ ] Each permission item exposes switch semantics (role switch + checked/disabled)
- [ ] Blocked permission shows accessible button to open OS settings
- [ ] Permission request success/failure is announced once
- [ ] Permission wizard screens have headings and initial focus
Findings:
- iOS:
- Android:
#### 5) Profile + Account management (P0)
Targets:
- [src/scenes/Profile/Form.js](src/scenes/Profile/Form.js:1)
- [src/scenes/Profile/AvatarUploader.js](src/scenes/Profile/AvatarUploader.js:1)
- [src/scenes/Profile/AccountManagement.js](src/scenes/Profile/AccountManagement.js:1)
Steps:
- [ ] Open Profile
- [ ] Edit fields: labels and errors announced
- [ ] Open avatar edit modal: focus to modal header
- [ ] Close avatar edit modal: focus returns to trigger
- [ ] Open account modal(s): focus to modal header
- [ ] Destructive action confirmation: clear label/hint and error focus
Findings:
- iOS:
- Android:
---
## Audit runs
<!-- Paste completed runs above this line -->

View file

@ -0,0 +1,41 @@
# A11y audit targets
This file lists the **P0 screens/components** that we audit first.
Each target should be validated with:
- iOS VoiceOver (see [`docs/qa-voiceover.md`](docs/qa-voiceover.md:1))
- Android TalkBack (see [`docs/qa-talkback.md`](docs/qa-talkback.md:1))
- Static checks (ESLint a11y rules) and smoke E2E tests.
## P0 targets
### Navigation chrome
- Header left (back / home fallback)
- File: [`src/navigation/HeaderLeft.js`](src/navigation/HeaderLeft.js:1)
- Risks: icon-only action labeling, predictable behavior.
- Header right (quick nav + menu)
- File: [`src/navigation/HeaderRight.js`](src/navigation/HeaderRight.js:1)
- Risks: icon-only labeling, unread indicators.
### Send Alert
- Primary alert CTAs (red/yellow/green/call/unknown)
- File: [`src/scenes/SendAlert/index.js`](src/scenes/SendAlert/index.js:1)
- Risks: color-only meaning, CTA clarity, focus order.
### Chat
- Chat input (text field, send/mic, delete recording)
- File: [`src/containers/ChatInput/index.js`](src/containers/ChatInput/index.js:1)
- Risks: custom touchables, dynamic mode switching, recording countdown.
## Nice-to-have targets (later)
- Drawer navigation and menu items
- Permissions wizard flows
- Maps and map overlays
- Notifications settings

View file

@ -0,0 +1,81 @@
# A11y: Color contrast (WCAG 2.2 AA)
This project uses theme **tokens** (see [`src/theme/app/Light.js`](../src/theme/app/Light.js:1) / [`src/theme/app/Dark.js`](../src/theme/app/Dark.js:1)) as the single source of truth for core UI colors.
The goal is to ensure **text and icons** meet **WCAG 2.2 AA** contrast:
- **Normal text**: contrast ratio **≥ 4.5:1**
- **Large text / icons**: contrast ratio **≥ 3:1**
We intentionally enforce **4.5:1** for our key UI states (buttons, alert levels, banners/toasts), because:
- buttons mix text + icons, and size can vary with device scaling
- alert-level CTAs are primary critical actions
## Approved core pairs (tokens)
All pairs below were validated with a standard WCAG contrast formula (relative luminance).
### Buttons (react-native-paper)
Implementation notes:
- Contained buttons typically use `colors.primary` background with `colors.onPrimary` text.
- Outlined buttons in this codebase render with white background and primary-colored label (see [`src/components/CustomButton.js`](../src/components/CustomButton.js:1)).
| Usage | Foreground token | Background token | Target |
|---|---|---|---|
| Contained primary button label/icon | `colors.onPrimary` | `colors.primary` | ≥ 4.5:1 |
| Outlined button label/icon (on white) | `colors.primary` | `colors.onPrimary` | ≥ 4.5:1 |
| Disabled states (material defaults) | `colors.onSurfaceDisabled` | `colors.surfaceDisabled` | N/A (disabled content is excluded from contrast requirements) |
### Alert levels (CTA backgrounds)
Alert level colors are under `theme.custom.appColors`.
These are used as:
- **backgrounds** for the “send alert” CTAs (text + icons), with `custom.appColors.onColor` as the foreground
- **foreground indicators** (dots/icons) on surfaces in places like notifications
| Alert level | Background token | Foreground token | Target |
|---|---|---|---|
| Red | `custom.appColors.red` | `custom.appColors.onColor` | ≥ 4.5:1 |
| Yellow | `custom.appColors.yellow` | `custom.appColors.onColor` | ≥ 4.5:1 |
| Green | `custom.appColors.green` | `custom.appColors.onColor` | ≥ 4.5:1 |
| Unknown | `custom.appColors.unknown` | `custom.appColors.onColor` | ≥ 4.5:1 |
| Call | `custom.appColors.call` | `custom.appColors.onColor` | ≥ 4.5:1 |
### Error / success / warning (banners + toasts)
This codebase uses a mix of:
- `colors.error` + `colors.onError` when error is used as a **background**
- `colors.ok`, `colors.no`, `colors.warn` for status backgrounds (e.g. confirmation/rejection)
- toast “normal” uses `colors.surfaceVariant` background with `colors.onSurfaceVariant` text (see [`src/lib/toast-notifications/toast.js`](../src/lib/toast-notifications/toast.js:1))
| Usage | Foreground token | Background token | Target |
|---|---|---|---|
| Error background + white text | `colors.onError` | `colors.error` | ≥ 4.5:1 |
| Success background + white text | `colors.onPrimary` | `colors.ok` | ≥ 4.5:1 |
| Reject/Danger background + white text | `colors.onPrimary` | `colors.no` | ≥ 4.5:1 |
| Warning background + white text | `colors.onWarning` | `colors.warn` | ≥ 4.5:1 |
| Toast (normal) | `colors.onSurfaceVariant` | `colors.surfaceVariant` | ≥ 4.5:1 |
## Rationale for token adjustments
We keep component code unchanged and only adjust theme tokens.
Key changes (both Light/Dark) were made because the previous palette failed AA in multiple critical states:
- white on `error` / `no` / `critical` reds
- white on `ok` greens
- white on alert-level colors (especially yellow/green/unknown)
- dark theme `primary` was too light for white text
- light theme `onSurfaceVariant` narrowly missed 4.5:1 on `surfaceVariant`
Branding impact was minimized by:
- preserving the overall hue families (blue primary, red/yellow/green alerts)
- applying the smallest darkening needed to cross AA thresholds

87
docs/a11y-usage.md Normal file
View file

@ -0,0 +1,87 @@
# A11y usage guide (app-specific)
This document explains how to use the shared accessibility helpers exposed by [`src/lib/a11y/index.js`](src/lib/a11y/index.js:1) and the conventions we follow in this codebase.
## Shared helpers
The a11y helpers are re-exported from [`src/lib/a11y/index.js`](src/lib/a11y/index.js:1):
- `announceForA11y(message)`
- `announceForA11yIfScreenReaderEnabled(message)`
- `setA11yFocus(refOrNode)`
- `setA11yFocusAfterInteractions(refOrNode)`
### Announcements
Use announcements for **important state changes** that are not otherwise obvious to a screen reader user.
- Prefer short, user-facing messages.
- Keep the language aligned with the UI (French strings in this app).
Example:
```js
import { announceForA11y } from "~/lib/a11y";
await announceForA11y("Alerte envoyée");
```
Implementation lives in [`src/lib/a11y/announce.js`](src/lib/a11y/announce.js:1).
### Focus management
Use focus management when navigation or UI updates would otherwise leave screen reader focus in an unexpected place.
- `setA11yFocus(refOrNode)` sets focus immediately.
- `setA11yFocusAfterInteractions(refOrNode)` is safer after navigation, animations, or heavy re-renders.
Example:
```js
import React, { useRef } from "react";
import { View, Text } from "react-native";
import { setA11yFocusAfterInteractions } from "~/lib/a11y";
export function Example() {
const titleRef = useRef(null);
React.useEffect(() => {
setA11yFocusAfterInteractions(titleRef);
}, []);
return (
<View>
<Text ref={titleRef} accessibilityRole="header">Mon écran</Text>
</View>
);
}
```
Implementation lives in [`src/lib/a11y/focus.js`](src/lib/a11y/focus.js:1).
## Component-level conventions
### Buttons / Icon buttons
- Provide `accessibilityLabel` (what it is) and `accessibilityHint` (what it does) for icon-only actions.
- Keep labels/hints in **French** to match the app.
Examples in headers:
- [`src/navigation/HeaderRight.js`](src/navigation/HeaderRight.js:70)
- [`src/navigation/HeaderLeft.js`](src/navigation/HeaderLeft.js:19)
### Text inputs
- Provide `accessibilityLabel` and a French `accessibilityHint`.
- Prefer a clear `placeholder` and keep it aligned with the on-screen label if any.
Example: [`src/containers/ChatInput/TextArea.js`](src/containers/ChatInput/TextArea.js:48).
## Test IDs and a11y
`testID` props are for test automation, not for accessibility.
- Add `testID` only when it is stable and safe.
- Follow the naming rules in [`docs/testids.md`](docs/testids.md:1).

50
docs/a11y-wcag22-aa.md Normal file
View file

@ -0,0 +1,50 @@
# A11y baseline: WCAG 2.2 AA (practical checklist)
This document is a **practical interpretation** of WCAG 2.2 AA for this React Native app.
Scope: iOS VoiceOver and Android TalkBack.
## P0 baseline (must-have)
### Perceivable
- **Text alternatives**: icon-only buttons have `accessibilityLabel` and (when helpful) `accessibilityHint`.
- Example: header icon buttons in [`src/navigation/HeaderRight.js`](src/navigation/HeaderRight.js:72).
- **Contrast**: text and icons meet AA contrast against their background.
- Note: color-coded alert levels (red/yellow/green) must remain readable.
### Operable
- **Keyboard/switch navigation**: focus order is logical, no traps.
- **Touch target size**: interactive controls are large enough (aim for ~44x44pt).
- Header icons and chat controls should remain comfortably tappable.
- **No time limits without control**: if an action auto-triggers (e.g. recording timeout), user feedback is provided.
### Understandable
- **Labels and instructions**: inputs and icon buttons expose meaningful labels in French.
- **Consistent navigation**: header left/back/menu behavior stays consistent.
### Robust
- **Role and state**: use `accessibilityRole` where the default is ambiguous (e.g. custom touchables).
- Avoid breaking semantics with nested touchables.
## App-specific high-risk areas
- **Send Alert**: primary CTAs must be discoverable and understandable by screen readers.
- Screen: [`src/scenes/SendAlert/index.js`](src/scenes/SendAlert/index.js:99)
- **Chat Input**: send/microphone/delete controls must be labeled and easily reachable.
- Component: [`src/containers/ChatInput/index.js`](src/containers/ChatInput/index.js:370)
- **Navigation headers**: icon-only buttons require clear labels.
- Files: [`src/navigation/HeaderLeft.js`](src/navigation/HeaderLeft.js:1), [`src/navigation/HeaderRight.js`](src/navigation/HeaderRight.js:1)
## What we track
- Audit targets: [`docs/a11y-audit-targets.md`](docs/a11y-audit-targets.md:1)
- Audit log: [`docs/a11y-audit-log.md`](docs/a11y-audit-log.md:1)

36
docs/qa-talkback.md Normal file
View file

@ -0,0 +1,36 @@
# QA: Android TalkBack checklist
Goal: validate the P0 flows with TalkBack enabled.
## Setup
1. Android Settings → Accessibility → TalkBack → On.
2. Optional: enable "Speak passwords" if testing auth flows.
## Gestures used during QA
- Swipe right/left: move to next/previous element
- Double tap: activate
## P0 checks
### Header actions
- Confirm menu/overflow/quick actions are labeled properly.
- Confirm back behavior is predictable.
### Send Alert
- Confirm each alert CTA announces a meaningful label and is reachable.
- Confirm there is no reliance on color only.
### Chat input
- Text input announces as editable with hint.
- Send/microphone announces correct action depending on whether there is text.
- In recording mode, delete control is reachable and announced as a button.
## Reporting
Record issues in [`docs/a11y-audit-log.md`](docs/a11y-audit-log.md:1).

43
docs/qa-voiceover.md Normal file
View file

@ -0,0 +1,43 @@
# QA: iOS VoiceOver checklist
Goal: validate the P0 flows with VoiceOver enabled.
## Setup
1. iOS Settings → Accessibility → VoiceOver → On.
2. Set Speech rate to a comfortable speed.
## Gestures used during QA
- Swipe right/left: move to next/previous element
- Double tap: activate
- Two-finger scrub: back (system)
## P0 checks
### Header actions
On a screen with header icons:
- Navigate through header controls and confirm each icon-only button announces a clear label and hint.
- Confirm back behavior is predictable.
Selectors for automation: see [`docs/testids.md`](docs/testids.md:1).
### Send Alert
Screen: "Quelle est votre situation ?"
- Swipe through CTAs and confirm each announces the alert level (Rouge / Jaune / Verte / …) and the hint explains it opens confirmation.
- Activate each CTA and verify the next screen is reachable and the focus does not get lost.
### Chat input
- Focus the text input: it should announce an editable field with a French hint.
- Focus the send/microphone control: it should announce the correct action depending on mode.
- In recording mode, ensure the delete button is reachable and announced as a button.
## Reporting
Record issues in [`docs/a11y-audit-log.md`](docs/a11y-audit-log.md:1) and link to the relevant file/line.

67
docs/testids.md Normal file
View file

@ -0,0 +1,67 @@
# Test IDs conventions
This app uses React Native `testID` props to support **E2E tests** (Detox / RN testing tools) and to enable reliable UI targeting without relying on translated text.
## Goals
- Stable selectors for tests across refactors.
- Human-readable IDs that encode *where* and *what*.
- Low risk: adding `testID` must not change UI behavior.
## When to add a `testID`
Add a `testID` when the element is:
- A primary action (CTA) users tap.
- Navigation chrome (header left/back/menu, header right quick actions).
- A key form control (input, send button, attachment/mic button).
- A container representing a screen root (rare; see existing example).
Avoid adding `testID` to purely decorative views/icons.
## Naming rules
### Format
Use lowercase, kebab-case.
Recommended pattern:
```
<area>-<screen-or-component>-<element>
```
Examples:
- `header-left-back`
- `header-right-menu`
- `send-alert-cta-red`
- `chat-input-send`
### Prefixes
Use a consistent prefix based on where the element lives:
- `header-left-*` for left header controls
- `header-right-*` for right header controls
- `send-alert-*` for Send Alert screen actions
- `chat-input-*` for ChatInput controls
### Uniqueness
Within a given screen/component, each `testID` must be unique.
### Do not encode text
Do not include localized labels (e.g. avoid `envoyer-message`). IDs must remain stable even if copy changes.
## Mapping to files
- Header buttons: [`src/navigation/HeaderLeft.js`](src/navigation/HeaderLeft.js:1), [`src/navigation/HeaderRight.js`](src/navigation/HeaderRight.js:1)
- Send Alert CTAs: [`src/scenes/SendAlert/index.js`](src/scenes/SendAlert/index.js:1)
- Chat input controls: [`src/containers/ChatInput/index.js`](src/containers/ChatInput/index.js:1)
## Existing usage
- Screen root container example: [`src/layout/Layout.js`](src/layout/Layout.js:49) uses `testID="main-layout"`.

48
e2e/a11y.smoke.e2e.js Normal file
View file

@ -0,0 +1,48 @@
const {
launchAppFresh,
reloadApp,
scrollUntilVisibleById,
waitForVisibleById,
} = require("./helpers/ui");
describe("A11y smoke (testID selectors)", () => {
beforeAll(async () => {
await launchAppFresh();
});
beforeEach(async () => {
await reloadApp();
});
it("Send Alert screen exposes primary CTAs by testID", async () => {
// On fresh install the app lands on the Send Alert tab.
await scrollUntilVisibleById("send-alert-cta-red");
await scrollUntilVisibleById("send-alert-cta-yellow");
await scrollUntilVisibleById("send-alert-cta-green");
await scrollUntilVisibleById("send-alert-cta-unknown");
await scrollUntilVisibleById("send-alert-cta-call");
});
it("Header right quick actions exist by testID", async () => {
await waitForVisibleById("header-right-send-alert");
await waitForVisibleById("header-right-alerts");
await waitForVisibleById("header-right-current-alert");
await waitForVisibleById("header-right-menu");
});
it("Header controls adapt across a push navigation (menu -> overflow) via testID", async () => {
await scrollUntilVisibleById("send-alert-cta-red");
await waitForVisibleById("header-right-menu");
await element(by.id("send-alert-cta-red")).tap();
// Confirmation screen should be pushed, showing a back button.
await waitForVisibleById("header-left-back");
await waitForVisibleById("header-right-overflow");
await element(by.id("header-left-back")).tap();
// Back on Send Alert screen.
await waitForVisibleById("send-alert-cta-red");
await waitForVisibleById("header-right-menu");
});
});

4
e2e/config.json Normal file
View file

@ -0,0 +1,4 @@
{
"setupFilesAfterEnv": ["./e2e/init.js"]
}

View file

@ -1,6 +1,6 @@
describe("App Initialization", () => { describe("App Initialization", () => {
beforeAll(async () => { beforeAll(async () => {
await device.launchApp(); await device.launchApp({ newInstance: true });
}); });
beforeEach(async () => { beforeEach(async () => {
@ -10,14 +10,4 @@ describe("App Initialization", () => {
it("should have main layout", async () => { it("should have main layout", async () => {
await expect(element(by.id("main-layout"))).toBeVisible(); await expect(element(by.id("main-layout"))).toBeVisible();
}); });
it("should navigate to a different screen", async () => {
// This is a placeholder test. You'll need to replace it with actual navigation in your app
// For example:
// await element(by.id('navigation-button')).tap();
// await expect(element(by.id('new-screen'))).toBeVisible();
console.log(
"Navigation test placeholder - implement based on your app structure",
);
});
}); });

76
e2e/helpers/ui.js Normal file
View file

@ -0,0 +1,76 @@
const DEFAULT_TIMEOUT_MS = 20_000;
async function launchAppFresh() {
await device.launchApp({
newInstance: true,
permissions: {
notifications: "YES",
location: "never",
microphone: "never",
},
});
}
async function reloadApp() {
await device.reloadReactNative();
}
function byId(id) {
return element(by.id(id));
}
async function waitForVisibleById(id, timeoutMs = DEFAULT_TIMEOUT_MS) {
await waitFor(byId(id)).toBeVisible().withTimeout(timeoutMs);
}
async function expectVisibleById(id) {
await expect(byId(id)).toBeVisible();
}
async function scrollUntilVisibleById(id, opts = {}) {
const { timeoutMs = DEFAULT_TIMEOUT_MS, stepPx = 240 } = opts;
// Fast-path: already visible.
try {
await waitForVisibleById(id, 500);
return;
} catch (_e) {
// Continue to scroll
}
const target = byId(id);
// Detox needs an explicit scrollable element to drive scrolling.
// This app uses RN ScrollView, which maps to different native class names.
const scrollViews = [
element(by.type("android.widget.ScrollView")),
element(by.type("RCTScrollView")),
];
const errors = [];
for (const scrollView of scrollViews) {
try {
await waitFor(target)
.toBeVisible()
.whileElement(scrollView)
.scroll(stepPx, "down");
return;
} catch (e) {
errors.push(e);
}
}
// Fall back to a direct wait to get a good assertion error.
await waitForVisibleById(id, timeoutMs);
}
module.exports = {
DEFAULT_TIMEOUT_MS,
byId,
expectVisibleById,
launchAppFresh,
reloadApp,
scrollUntilVisibleById,
waitForVisibleById,
};

5
e2e/init.js Normal file
View file

@ -0,0 +1,5 @@
// Shared Jest/Detox init for e2e suites.
// Keep this file side-effect-only and dependency-free.
jest.setTimeout(120000);

View file

@ -1,23 +1,9 @@
// describe('Example', () => { // Detox template placeholder.
// beforeAll(async () => { // Keep a real test here so `yarn test` (Jest) doesn't fail with:
// await device.launchApp(); // "Your test suite must contain at least one test."
// });
// beforeEach(async () => { describe("e2e starter placeholder", () => {
// await device.reloadReactNative(); it("is a placeholder", () => {
// }); expect(true).toBe(true);
});
// it('should have welcome screen', async () => { });
// await expect(element(by.id('welcome'))).toBeVisible();
// });
// it('should show hello screen after tap', async () => {
// await element(by.id('hello_button')).tap();
// await expect(element(by.text('Hello!!!'))).toBeVisible();
// });
// it('should show world screen after tap', async () => {
// await element(by.id('world_button')).tap();
// await expect(element(by.text('World!!!'))).toBeVisible();
// });
// });

View file

@ -2,9 +2,6 @@
import "./warnFilter"; import "./warnFilter";
import "expo-splash-screen"; import "expo-splash-screen";
import BackgroundGeolocation from "react-native-background-geolocation";
import { Platform } from "react-native";
import notifee from "@notifee/react-native"; import notifee from "@notifee/react-native";
import messaging from "@react-native-firebase/messaging"; import messaging from "@react-native-firebase/messaging";
@ -18,9 +15,6 @@ import App from "~/app";
import { onBackgroundEvent as notificationBackgroundEvent } from "~/notifications/onEvent"; import { onBackgroundEvent as notificationBackgroundEvent } from "~/notifications/onEvent";
import onMessageReceived from "~/notifications/onMessageReceived"; import onMessageReceived from "~/notifications/onMessageReceived";
import { createLogger } from "~/lib/logger";
// import { executeHeartbeatSync } from "~/location/backgroundTask";
// setup notification, this have to stay in index.js // setup notification, this have to stay in index.js
notifee.onBackgroundEvent(notificationBackgroundEvent); notifee.onBackgroundEvent(notificationBackgroundEvent);
messaging().setBackgroundMessageHandler(onMessageReceived); messaging().setBackgroundMessageHandler(onMessageReceived);
@ -29,29 +23,3 @@ messaging().setBackgroundMessageHandler(onMessageReceived);
// It also ensures that whether you load the app in Expo Go or in a native build, // It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately // the environment is set up appropriately
registerRootComponent(App); registerRootComponent(App);
const geolocBgLogger = createLogger({
service: "background-geolocation",
task: "headless",
});
// const HeadlessTask = async (event) => {
// try {
// switch (event?.name) {
// case "heartbeat":
// await executeHeartbeatSync();
// break;
// default:
// break;
// }
// } catch (error) {
// geolocBgLogger.error("HeadlessTask error", {
// error,
// event,
// });
// }
// };
// if (Platform.OS === "android") {
// BackgroundGeolocation.registerHeadlessTask(HeadlessTask);
// }

View file

@ -252,6 +252,7 @@
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-native": "^4.0.0", "eslint-plugin-react-native": "^4.0.0",
"eslint-plugin-react-native-a11y": "^3.5.1",
"eslint-plugin-sort-keys-fix": "^1.1.2", "eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-unused-imports": "^3.0.0", "eslint-plugin-unused-imports": "^3.0.0",
"husky": "^9.0.11", "husky": "^9.0.11",
@ -273,7 +274,7 @@
"binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk", "binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk",
"build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..", "build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
"device": { "device": {
"avdName": "Pixel_6_API_30" "avdName": "Medium_Phone_API_36.0"
} }
} }
} }

View file

@ -22,7 +22,10 @@ class Bubble extends React.PureComponent {
if (this.props.onPress) { if (this.props.onPress) {
innerChildView = ( innerChildView = (
<TouchableOpacity onPress={this.props.onPress}> <TouchableOpacity
accessibilityRole="button"
onPress={this.props.onPress}
>
{this.props.children} {this.props.children}
</TouchableOpacity> </TouchableOpacity>
); );

View file

@ -14,7 +14,11 @@ export default function ConnectivityErrorCompact({
<View {...containerProps} style={[styles.container, containerProps.style]}> <View {...containerProps} style={[styles.container, containerProps.style]}>
<MaterialCommunityIcons style={styles.icon} name="connection" /> <MaterialCommunityIcons style={styles.icon} name="connection" />
<Text style={styles.label}>Connexion perdue</Text> <Text style={styles.label}>Connexion perdue</Text>
<TouchableOpacity style={styles.button} onPress={retryConnect}> <TouchableOpacity
accessibilityRole="button"
style={styles.button}
onPress={retryConnect}
>
<Text style={styles.buttonText}>Réessayer</Text> <Text style={styles.buttonText}>Réessayer</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -13,7 +13,11 @@ export default function ConnectivityErrorExpanded({
<View {...containerProps} style={[styles.container, containerProps.style]}> <View {...containerProps} style={[styles.container, containerProps.style]}>
<MaterialCommunityIcons style={styles.icon} name="connection" /> <MaterialCommunityIcons style={styles.icon} name="connection" />
<Text style={styles.label}>Vous n'êtes pas connecté à internet</Text> <Text style={styles.label}>Vous n'êtes pas connecté à internet</Text>
<TouchableOpacity style={styles.button} onPress={retryConnect}> <TouchableOpacity
accessibilityRole="button"
style={styles.button}
onPress={retryConnect}
>
<Text style={styles.buttonText}>Réessayer</Text> <Text style={styles.buttonText}>Réessayer</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -8,6 +8,7 @@ const CustomButton = ({
contentStyle, contentStyle,
labelStyle, labelStyle,
mode = "contained", mode = "contained",
selected,
...props ...props
}) => { }) => {
const styles = useStyles(); const styles = useStyles();
@ -15,9 +16,27 @@ const CustomButton = ({
const theme = useTheme(); const theme = useTheme();
const isOutlined = mode === "outlined"; const isOutlined = mode === "outlined";
const computedAccessibilityLabel =
props.accessibilityLabel ??
(typeof children === "string" ? children : undefined);
// Hints are optional; we provide an empty hint to satisfy lint rules while
// encouraging callers to add meaningful hints for non-obvious actions.
const computedAccessibilityHint = props.accessibilityHint ?? "";
const computedAccessibilityState = {
...(props.accessibilityState ?? {}),
...(props.disabled != null ? { disabled: !!props.disabled } : null),
...(selected != null ? { selected: !!selected } : null),
};
return ( return (
<Button <Button
{...props} {...props}
accessibilityRole={props.accessibilityRole ?? "button"}
accessibilityLabel={computedAccessibilityLabel}
accessibilityHint={computedAccessibilityHint}
accessibilityState={computedAccessibilityState}
mode={mode} mode={mode}
style={[ style={[
styles.button, styles.button,

View file

@ -0,0 +1,60 @@
import React, { forwardRef } from "react";
import { Pressable, StyleSheet } from "react-native";
/**
* Icon-only touch target wrapper.
*
* - Enforces a minimum 44x44pt touch target (WCAG / Apple HIG).
* - Adds a default hitSlop to make small icons easier to tap.
* - Preserves accessibility semantics via accessibility* props.
*/
function IconTouchTarget(
{
children,
style,
hitSlop = { bottom: 8, left: 8, right: 8, top: 8 },
accessibilityRole = "button",
accessibilityState,
disabled,
selected,
...props
},
ref,
) {
const computedAccessibilityState = {
...(accessibilityState ?? {}),
...(disabled != null ? { disabled: !!disabled } : null),
...(selected != null ? { selected: !!selected } : null),
};
return (
<Pressable
ref={ref}
{...props}
accessibilityRole={accessibilityRole}
accessibilityState={computedAccessibilityState}
disabled={disabled}
hitSlop={hitSlop}
style={({ pressed }) => [
styles.base,
typeof style === "function" ? style({ pressed }) : style,
]}
>
{children}
</Pressable>
);
}
const ForwardedIconTouchTarget = forwardRef(IconTouchTarget);
ForwardedIconTouchTarget.displayName = "IconTouchTarget";
export default ForwardedIconTouchTarget;
const styles = StyleSheet.create({
base: {
alignItems: "center",
justifyContent: "center",
minHeight: 44,
minWidth: 44,
},
});

View file

@ -3,20 +3,74 @@ import { View, TextInput, Text } from "react-native";
import { createStyles, useTheme } from "~/theme"; import { createStyles, useTheme } from "~/theme";
function OutlinedInputText( function OutlinedInputText(
{ style, labelStyle, inputStyle, label, ...props }, {
style,
labelStyle,
inputStyle,
label,
accessibilityLabel,
accessibilityHint,
accessibilityState,
required,
error,
errorMessage,
...props
},
ref, ref,
) { ) {
const styles = useStyles(); const styles = useStyles();
const { colors } = useTheme(); const { colors } = useTheme();
const computedAccessibilityLabel = accessibilityLabel ?? label;
const computedAccessibilityHint =
accessibilityHint ?? (errorMessage ? errorMessage : undefined);
const computedAccessibilityState = {
...(accessibilityState ?? {}),
...(required != null ? { required: !!required } : null),
...(error != null || !!errorMessage
? { invalid: !!error || !!errorMessage }
: null),
};
return ( return (
<View style={[styles.container, style]}> <View
<Text style={[styles.label, labelStyle]}>{label}</Text> style={[
styles.container,
error || errorMessage ? styles.containerError : null,
style,
]}
>
{!!label && (
<Text
style={[styles.label, labelStyle]}
// Prevent the label from being announced as a separate element.
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
>
{label}
</Text>
)}
<TextInput <TextInput
placeholderTextColor={colors.outline} placeholderTextColor={colors.outline}
style={[styles.input, inputStyle]} style={[styles.input, inputStyle]}
ref={ref} ref={ref}
accessibilityLabel={computedAccessibilityLabel}
accessibilityHint={computedAccessibilityHint}
accessibilityState={computedAccessibilityState}
{...props} {...props}
/> />
{!!errorMessage && (
<Text
style={[styles.errorText]}
accessibilityLiveRegion="polite"
accessibilityRole="alert"
>
{errorMessage}
</Text>
)}
</View> </View>
); );
} }
@ -28,6 +82,9 @@ const useStyles = createStyles(({ fontSize, theme: { colors } }) => ({
borderColor: colors.outline, borderColor: colors.outline,
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
containerError: {
borderColor: colors.error,
},
label: { label: {
position: "absolute", position: "absolute",
left: 6, left: 6,
@ -37,6 +94,10 @@ const useStyles = createStyles(({ fontSize, theme: { colors } }) => ({
input: { input: {
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
errorText: {
color: colors.error,
marginTop: 6,
},
})); }));
export default forwardRef(OutlinedInputText); export default forwardRef(OutlinedInputText);

View file

@ -8,12 +8,17 @@ const defaulStyle = {
fontFamily, fontFamily,
}; };
export default function AppText({ style = {}, ...props }) { export default function AppText({
style = {},
allowFontScaling = true,
...props
}) {
const { colors } = useTheme(); const { colors } = useTheme();
// return <ScalableText style={[defaulStyle,style]} {...props} /> // return <ScalableText style={[defaulStyle,style]} {...props} />
return ( return (
<Text <Text
style={[defaulStyle, { color: colors.onSurface }, style]} style={[defaulStyle, { color: colors.onSurface }, style]}
allowFontScaling={allowFontScaling}
{...props} {...props}
/> />
); );

View file

@ -144,6 +144,7 @@ export default function AlertRow({ row, isLast, isFirst, sortBy }) {
<View style={styles.container}> <View style={styles.container}>
<TouchableRipple <TouchableRipple
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint="Ouvre l'alerte. Appui long pour afficher ou masquer les informations."
mode="outlined" mode="outlined"
style={[ style={[
styles.button, styles.button,

View file

@ -7,19 +7,37 @@ export default React.memo(function TextArea({
value, value,
onChangeText, onChangeText,
autoFocus, autoFocus,
inputRef,
}) { }) {
const { colors } = useTheme(); const { colors } = useTheme();
const styles = useStyles(); const styles = useStyles();
const [keyboardEnabled, setKeyboardEnabled] = useState(false); const [keyboardEnabled, setKeyboardEnabled] = useState(false);
const textInputRef = useRef(null); const textInputRef = useRef(null);
const didAutoFocusRef = useRef(false);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const setRefs = useCallback(
(node) => {
textInputRef.current = node;
if (!inputRef) return;
if (typeof inputRef === "function") {
inputRef(node);
} else {
inputRef.current = node;
}
},
[inputRef],
);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let timeout; let timeout;
const task = InteractionManager.runAfterInteractions(() => { const task = InteractionManager.runAfterInteractions(() => {
timeout = setTimeout(() => { timeout = setTimeout(() => {
if (autoFocus && textInputRef.current) { // Only auto-focus once per screen visit; re-focusing later can cause
// unwanted focus jumps for screen reader users.
if (autoFocus && textInputRef.current && !didAutoFocusRef.current) {
didAutoFocusRef.current = true;
textInputRef.current.focus(); textInputRef.current.focus();
} }
}, 500); }, 500);
@ -29,7 +47,7 @@ export default React.memo(function TextArea({
clearTimeout(timeout); clearTimeout(timeout);
setKeyboardEnabled(false); setKeyboardEnabled(false);
}; };
}, [textInputRef, autoFocus]), }, [autoFocus]),
); );
const onBlur = useCallback(() => { const onBlur = useCallback(() => {
@ -38,8 +56,15 @@ export default React.memo(function TextArea({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!keyboardEnabled && autoFocus && !isFocused) { if (
!keyboardEnabled &&
autoFocus &&
!isFocused &&
textInputRef.current &&
!didAutoFocusRef.current
) {
setTimeout(() => { setTimeout(() => {
didAutoFocusRef.current = true;
textInputRef.current?.focus(); textInputRef.current?.focus();
}, 100); }, 100);
} }
@ -47,12 +72,15 @@ export default React.memo(function TextArea({
return ( return (
<TextInput <TextInput
testID="chat-input-text"
accessibilityLabel="Message"
accessibilityHint="Saisissez votre message."
multiline multiline
maxLength={4096} maxLength={4096}
style={styles.input} style={styles.input}
onChangeText={onChangeText} onChangeText={onChangeText}
value={value} value={value}
ref={textInputRef} ref={setRefs}
textAlignVertical="center" textAlignVertical="center"
autoFocus={autoFocus} autoFocus={autoFocus}
showSoftInputOnFocus={keyboardEnabled} // controlled by state showSoftInputOnFocus={keyboardEnabled} // controlled by state

View file

@ -27,6 +27,7 @@ import network from "~/network";
import TextArea from "./TextArea"; import TextArea from "./TextArea";
import useInsertMessage from "~/hooks/useInsertMessage"; import useInsertMessage from "~/hooks/useInsertMessage";
import { announceForA11y } from "~/lib/a11y";
const MODE = { const MODE = {
EMPTY: "EMPTY", EMPTY: "EMPTY",
@ -139,6 +140,7 @@ export default React.memo(function ChatInput({
const styles = useStyles(); const styles = useStyles();
const [text, setText] = useState(""); const [text, setText] = useState("");
const textInputRef = useRef(null);
const { userId, username: sessionUsername } = useSessionState([ const { userId, username: sessionUsername } = useSessionState([
"userId", "userId",
"username", "username",
@ -153,6 +155,9 @@ export default React.memo(function ChatInput({
const [player, setPlayer] = useState(null); const [player, setPlayer] = useState(null);
const requestingMicRef = useRef(false); const requestingMicRef = useRef(false);
// A11y: avoid repeated announcements while recording (e.g. every countdown tick)
const lastRecordingAnnouncementRef = useRef(null);
const insertMessage = useInsertMessage(alertId); const insertMessage = useInsertMessage(alertId);
useEffect(() => { useEffect(() => {
@ -192,8 +197,15 @@ export default React.memo(function ChatInput({
username, username,
userId, userId,
}); });
// Keep focus stable for SR users: after sending, restore focus to the input.
// (Do not move focus elsewhere; let the user continue typing.)
setTimeout(() => {
textInputRef.current?.focus?.();
}, 0);
} catch (error) { } catch (error) {
console.error("Failed to send message:", error); console.error("Failed to send message:", error);
announceForA11y("Échec de l'envoi du message");
} }
}, [insertMessage, text, setText, userId, username]); }, [insertMessage, text, setText, userId, username]);
@ -271,6 +283,12 @@ export default React.memo(function ChatInput({
recorder.record(); recorder.record();
console.log("recording"); console.log("recording");
setIsRecording(true); setIsRecording(true);
// Announce once when recording starts.
if (lastRecordingAnnouncementRef.current !== "started") {
lastRecordingAnnouncementRef.current = "started";
announceForA11y("Enregistrement démarré");
}
} catch (error) { } catch (error) {
console.log("error while recording:", error); console.log("error while recording:", error);
} }
@ -290,6 +308,12 @@ export default React.memo(function ChatInput({
} }
if (isRecording) { if (isRecording) {
setIsRecording(false); setIsRecording(false);
// Announce once when recording stops.
if (lastRecordingAnnouncementRef.current !== "stopped") {
lastRecordingAnnouncementRef.current = "stopped";
announceForA11y("Enregistrement arrêté");
}
} }
}, [recorder, isRecording]); }, [recorder, isRecording]);
@ -329,13 +353,26 @@ export default React.memo(function ChatInput({
}, [alertId, recorder]); }, [alertId, recorder]);
const sendRecording = useCallback(async () => { const sendRecording = useCallback(async () => {
try {
await stopRecording(); await stopRecording();
await recordedToSound(); await recordedToSound();
await uploadAudio(); await uploadAudio();
// Keep focus stable: return focus to input after finishing recording flow.
setTimeout(() => {
textInputRef.current?.focus?.();
}, 0);
} catch (error) {
console.error("Failed to send recording:", error);
announceForA11y("Échec de l'envoi de l'enregistrement audio");
}
}, [stopRecording, recordedToSound, uploadAudio]); }, [stopRecording, recordedToSound, uploadAudio]);
const deleteRecording = useCallback(async () => { const deleteRecording = useCallback(async () => {
await stopRecording(); await stopRecording();
setTimeout(() => {
textInputRef.current?.focus?.();
}, 0);
}, [stopRecording]); }, [stopRecording]);
const triggerMicrophoneClick = useCallback(async () => { const triggerMicrophoneClick = useCallback(async () => {
@ -389,10 +426,16 @@ export default React.memo(function ChatInput({
value={text} value={text}
onChangeText={setText} onChangeText={setText}
autoFocus={autoFocus} autoFocus={autoFocus}
inputRef={textInputRef}
/> />
)} )}
{mode === MODE.RECORDING && ( {mode === MODE.RECORDING && (
<TouchableOpacity <TouchableOpacity
testID="chat-input-delete-recording"
accessibilityRole="button"
accessibilityLabel="Supprimer l'enregistrement"
accessibilityHint="Supprime l'enregistrement audio. Action destructive."
accessibilityState={{ disabled: false }}
activeOpacity={activeOpacity} activeOpacity={activeOpacity}
style={styles.deleteButton} style={styles.deleteButton}
onPress={deleteRecording} onPress={deleteRecording}
@ -406,29 +449,60 @@ export default React.memo(function ChatInput({
</TouchableOpacity> </TouchableOpacity>
)} )}
{mode === MODE.RECORDING && ( {mode === MODE.RECORDING && (
<View style={styles.countdownContainer}> <View
style={styles.countdownContainer}
accessible
accessibilityRole="text"
accessibilityLabel="Compte à rebours avant envoi automatique"
accessibilityHint="Affiche le temps restant avant l'envoi automatique."
>
<Countdown <Countdown
autoStart autoStart
date={Date.now() + RECORDING_TIMEOUT * 1000} date={Date.now() + RECORDING_TIMEOUT * 1000}
intervalDelay={1000} intervalDelay={1000}
onComplete={onRecordingCountDownComplete} onComplete={onRecordingCountDownComplete}
renderer={({ seconds }) => ( renderer={({ seconds }) => (
<Text style={styles.countdownText}> <Text
style={styles.countdownText}
accessible={false}
importantForAccessibility="no"
>
{seconds || RECORDING_TIMEOUT} {seconds || RECORDING_TIMEOUT}
</Text> </Text>
)} )}
/> />
<Text style={styles.countdownSubtitle}> <Text
style={styles.countdownSubtitle}
accessible={false}
importantForAccessibility="no"
>
Avant envoi automatique Avant envoi automatique
</Text> </Text>
</View> </View>
)} )}
<TouchableOpacity <TouchableOpacity
testID={hasText ? "chat-input-send" : "chat-input-mic"}
activeOpacity={activeOpacity} activeOpacity={activeOpacity}
style={styles.sendButton} style={styles.sendButton}
accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
hasText ? "envoyer le message" : "enregistrer un message audio" hasText
? "Envoyer le message"
: isRecording
? "Envoyer l'enregistrement audio"
: "Démarrer l'enregistrement audio"
} }
accessibilityHint={
hasText
? "Envoie le message."
: isRecording
? "Envoie l'enregistrement audio."
: "Démarre l'enregistrement audio."
}
accessibilityState={{
disabled: false,
...(isRecording ? { selected: true } : null),
}}
onPress={hasText ? sendTextMessage : triggerMicrophoneClick} onPress={hasText ? sendTextMessage : triggerMicrophoneClick}
> >
<MaterialCommunityIcons <MaterialCommunityIcons

View file

@ -1,4 +1,4 @@
import React from "react"; import React, { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { createStyles, useTheme } from "~/theme"; import { createStyles, useTheme } from "~/theme";
import Text from "~/components/Text"; import Text from "~/components/Text";
@ -36,6 +36,15 @@ export default function MessageRow({
const createdAtText = useTimeDisplay(createdAt); const createdAtText = useTimeDisplay(createdAt);
const a11ySummaryLabel = useMemo(() => {
const who = username || "anonyme";
const from = isMine ? `${who} (moi)` : who;
const contentSummary =
contentType === "audio" ? "message audio" : (text || "").trim();
const statusText = !row.isOptimistic && isMine ? ", envoyé" : "";
return `${from}. ${createdAtText}. ${contentSummary}${statusText}`;
}, [contentType, createdAtText, isMine, row.isOptimistic, text, username]);
// const usernameColor = bgColorBySeed(username); // const usernameColor = bgColorBySeed(username);
return ( return (
@ -45,7 +54,19 @@ export default function MessageRow({
styles.bubbleContainer, styles.bubbleContainer,
isMine ? styles.bubbleContainerRight : styles.bubbleContainerLeft, isMine ? styles.bubbleContainerRight : styles.bubbleContainerLeft,
]} ]}
// Don't set `accessible` here: it would group the whole row into one
// accessibility element, preventing SR users from reaching inner
// interactive controls like the audio player.
> >
{/* SR-only summary so users get a concise message description without
hiding interactive children (audio controls). */}
<Text
accessible
accessibilityRole="text"
accessibilityLabel={a11ySummaryLabel}
accessibilityHint="Message."
style={{ height: 0, width: 0, opacity: 0 }}
/>
{!isMine && ( {!isMine && (
<View style={styles.userAvatar}> <View style={styles.userAvatar}>
<Avatar message={row} /> <Avatar message={row} />
@ -56,12 +77,16 @@ export default function MessageRow({
styles.triangle, styles.triangle,
isMine ? styles.triangleRight : styles.triangleLeft, isMine ? styles.triangleRight : styles.triangleLeft,
]} ]}
accessible={false}
importantForAccessibility="no"
/> />
<View <View
style={[ style={[
styles.bubble, styles.bubble,
isMine ? styles.bubbleMe : styles.bubbleOthers, isMine ? styles.bubbleMe : styles.bubbleOthers,
]} ]}
accessible={false}
importantForAccessibility="no"
> >
{!sameUserAsPrevious && ( {!sameUserAsPrevious && (
<View style={styles.username}> <View style={styles.username}>
@ -101,6 +126,8 @@ export default function MessageRow({
name="check-circle-outline" name="check-circle-outline"
size={16} size={16}
style={styles.checkIcon} style={styles.checkIcon}
accessible={false}
importantForAccessibility="no"
/> />
)} )}
</View> </View>

View file

@ -1,12 +1,14 @@
import React, { useCallback, useEffect, useState, useMemo } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { View, ScrollView } from "react-native"; import { View, ScrollView } from "react-native";
import { createStyles, useTheme } from "~/theme"; import { createStyles, useTheme } from "~/theme";
import { Button } from "react-native-paper"; import { Button } from "react-native-paper";
import { AntDesign } from "@expo/vector-icons"; import { AntDesign } from "@expo/vector-icons";
import { alertActions, aggregatedMessagesActions } from "~/stores"; import { alertActions, useSessionState } from "~/stores";
import { getVisibleIndexes, isScrollAtBottom } from "./utils"; import { getVisibleIndexes, isScrollAtBottom } from "./utils";
import { announceForA11yIfScreenReaderEnabled } from "~/lib/a11y";
import MessageRow from "./MessageRow"; import MessageRow from "./MessageRow";
import MessageWelcome from "./MessageWelcome"; import MessageWelcome from "./MessageWelcome";
@ -18,12 +20,20 @@ const ChatMessages = React.memo(function ChatMessages({
const styles = useStyles(); const styles = useStyles();
const { colors } = useTheme(); const { colors } = useTheme();
const { userId: sessionUserId } = useSessionState(["userId"]);
const [messageLayouts, setMessageLayouts] = useState([]); const [messageLayouts, setMessageLayouts] = useState([]);
const [layoutsReady, setLayoutsReady] = useState(false); const [layoutsReady, setLayoutsReady] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const [lastMessageId, setLastMessageId] = useState(null); const [lastMessageId, setLastMessageId] = useState(null);
const [lastViewedIndex, setLastViewedIndex] = useState(messages.length - 1); const [lastViewedIndex, setLastViewedIndex] = useState(messages.length - 1);
// A11y: throttle announcements for incoming new messages (avoid SR spam)
const lastA11yAnnouncementRef = React.useRef({
id: null,
at: 0,
});
useEffect(() => { useEffect(() => {
alertActions.setHasMessages(messages.length > 0); alertActions.setHasMessages(messages.length > 0);
}, [messages.length]); }, [messages.length]);
@ -47,11 +57,30 @@ const ChatMessages = React.memo(function ChatMessages({
// Only scroll if it's a new message and we're at the bottom // Only scroll if it's a new message and we're at the bottom
if (lastMessage.id !== lastMessageId) { if (lastMessage.id !== lastMessageId) {
setLastMessageId(lastMessage.id); setLastMessageId(lastMessage.id);
// Announce a concise summary for new incoming messages when SR is enabled.
// Throttle so multiple rapid messages don't spam TalkBack/VoiceOver.
try {
const now = Date.now();
const last = lastA11yAnnouncementRef.current;
const isNewId = last.id !== lastMessage.id;
const isThrottled = now - last.at < 2500;
const isMine =
sessionUserId != null && lastMessage.userId === sessionUserId;
const isIncoming = !isMine;
const sender = lastMessage.username || "anonyme";
if (isNewId && !isThrottled && isIncoming) {
lastA11yAnnouncementRef.current = { id: lastMessage.id, at: now };
announceForA11yIfScreenReaderEnabled(`Nouveau message de ${sender}`);
}
} catch (_e) {}
if (isAtBottom) { if (isAtBottom) {
scrollToBottom(); scrollToBottom();
} }
} }
}, [messages, isAtBottom, scrollToBottom, lastMessageId]); }, [messages, isAtBottom, scrollToBottom, lastMessageId, sessionUserId]);
const messagesLength = messages.length; const messagesLength = messages.length;
@ -76,12 +105,6 @@ const ChatMessages = React.memo(function ChatMessages({
} }
// Mark visible messages as read in the aggregated messages store // Mark visible messages as read in the aggregated messages store
// aggregatedMessagesActions.markMultipleMessagesAsRead(
// visibleIndexes
// .filter((index) => !messages[index].isRead)
// .map((index) => messages[index].id),
// );
const maxVisibleIndex = Math.max(...visibleIndexes); const maxVisibleIndex = Math.max(...visibleIndexes);
if (maxVisibleIndex > lastViewedIndex) { if (maxVisibleIndex > lastViewedIndex) {
setLastViewedIndex(maxVisibleIndex); setLastViewedIndex(maxVisibleIndex);
@ -139,6 +162,8 @@ const ChatMessages = React.memo(function ChatMessages({
style={styles.newMessageButton} style={styles.newMessageButton}
labelStyle={styles.newMessageButtonLabel} labelStyle={styles.newMessageButtonLabel}
contentStyle={styles.newMessageButtonContent} contentStyle={styles.newMessageButtonContent}
accessibilityLabel={newMessagesText}
accessibilityHint="Aller aux nouveaux messages."
icon={() => ( icon={() => (
<AntDesign name="arrowdown" size={14} color={colors.onPrimary} /> <AntDesign name="arrowdown" size={14} color={colors.onPrimary} />
)} )}

View file

@ -1,14 +1,25 @@
import React, { forwardRef } from "react";
import { TextInput } from "react-native-paper"; import { TextInput } from "react-native-paper";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
export default function FieldInputText({
name, function FieldInputText(
shouldDirty = true, { name, shouldDirty = true, error, errorMessage, ...inputProps },
error, ref,
...inputProps ) {
}) {
const { setValue, trigger, watch } = useFormContext(); const { setValue, trigger, watch } = useFormContext();
const value = watch(name); const value = watch(name);
const computedErrorMessage = errorMessage ?? error?.message;
const computedAccessibilityHint =
inputProps.accessibilityHint ?? computedErrorMessage;
const computedAccessibilityState = {
...(inputProps.accessibilityState ?? {}),
...(error || computedErrorMessage ? { invalid: true } : null),
};
const handleChangeText = async (newValue) => { const handleChangeText = async (newValue) => {
await setValue(name, newValue, { shouldDirty }); await setValue(name, newValue, { shouldDirty });
@ -25,12 +36,17 @@ export default function FieldInputText({
return ( return (
<TextInput <TextInput
ref={ref}
name={name} name={name}
onChangeText={handleChangeText} onChangeText={handleChangeText}
onBlur={handleBlur} onBlur={handleBlur}
value={value} value={value}
error={error} error={!!error}
accessibilityHint={computedAccessibilityHint}
accessibilityState={computedAccessibilityState}
{...inputProps} {...inputProps}
/> />
); );
} }
export default forwardRef(FieldInputText);

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React from "react";
import Maplibre from "@maplibre/maplibre-react-native"; import Maplibre from "@maplibre/maplibre-react-native";
import env from "~/env"; import env from "~/env";
@ -24,6 +24,11 @@ export default function MapView({
return ( return (
<Maplibre.MapView <Maplibre.MapView
style={styles.mapView} style={styles.mapView}
// A11y: the map surface should not become a focus trap and should not
// expose internal native nodes to screen readers.
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
attributionEnabled={false} attributionEnabled={false}
logoEnabled={false} logoEnabled={false}
styleURL={`${mapStyleUrl}?cache=123456789`} styleURL={`${mapStyleUrl}?cache=123456789`}

View file

@ -46,6 +46,9 @@ export default function StepZoomButtonGroup({
<ToggleButton <ToggleButton
style={[styles.zoomButton, styles.zoomInButton]} style={[styles.zoomButton, styles.zoomInButton]}
onPress={zoomIn} onPress={zoomIn}
accessibilityRole="button"
accessibilityLabel="Zoom avant"
accessibilityHint="Augmente le niveau de zoom de la carte."
icon={() => ( icon={() => (
<Feather name="zoom-in" size={24} style={styles.zoomIcon} /> <Feather name="zoom-in" size={24} style={styles.zoomIcon} />
)} )}
@ -53,6 +56,9 @@ export default function StepZoomButtonGroup({
<ToggleButton <ToggleButton
style={[styles.zoomButton, styles.zoomOutButton]} style={[styles.zoomButton, styles.zoomOutButton]}
onPress={zoomOut} onPress={zoomOut}
accessibilityRole="button"
accessibilityLabel="Zoom arrière"
accessibilityHint="Diminue le niveau de zoom de la carte."
icon={() => ( icon={() => (
<Feather name="zoom-out" size={24} style={styles.zoomIcon} /> <Feather name="zoom-out" size={24} style={styles.zoomIcon} />
)} )}

View file

@ -22,6 +22,13 @@ export default function TargetButton({
<View style={styles.labelToggleButtonContainer}> <View style={styles.labelToggleButtonContainer}>
<Button <Button
labelStyle={styles.labelButton} labelStyle={styles.labelButton}
accessibilityRole="button"
accessibilityLabel={
boundType === BoundType.NAVIGATION
? "Recentrer sur l'itinéraire"
: "Passer en mode navigation"
}
accessibilityHint="Replace la carte sur votre position et l'itinéraire."
onPress={() => { onPress={() => {
setBoundType(BoundType.NAVIGATION); setBoundType(BoundType.NAVIGATION);
if (boundType === BoundType.NAVIGATION) { if (boundType === BoundType.NAVIGATION) {

View file

@ -34,6 +34,14 @@ export default function ToggleColorSchemeButton({
<View style={[styles.container, containerStyle]}> <View style={[styles.container, containerStyle]}>
<ToggleButton <ToggleButton
onPress={handleToggle} onPress={handleToggle}
accessibilityRole="switch"
accessibilityLabel={
isDarkMap
? "Basculer la carte en mode clair"
: "Basculer la carte en mode sombre"
}
accessibilityHint="Change le thème de la carte."
accessibilityState={{ checked: !!isDarkMap }}
icon={() => ( icon={() => (
<Ionicons name={isDarkMap ? "sunny" : "moon"} style={styles.icon} /> <Ionicons name={isDarkMap ? "sunny" : "moon"} style={styles.icon} />
)} )}

View file

@ -9,9 +9,23 @@ export default function ToggleZoomButton({ value, selected, iconName, icon }) {
if (selected !== value) { if (selected !== value) {
return null; return null;
} }
const a11yLabelByValue = {
TRACK_ALERT_RADIUS_ALL: "Afficher le rayon de toutes les alertes",
TRACK_ALERT_RADIUS_REACH:
"Afficher le rayon des alertes sans contact plus proche",
TRACK_ALERTING: "Afficher le rayon des alertes en cours",
NAVIGATION: "Afficher le mode navigation",
};
return ( return (
<ToggleButton <ToggleButton
style={styles.boundTypeButton} style={styles.boundTypeButton}
accessibilityRole="button"
accessibilityLabel={
a11yLabelByValue[String(value)] || "Changer le mode d'affichage"
}
accessibilityHint="Change le mode d'affichage de la carte."
icon={() => icon={() =>
icon ? ( icon ? (
icon icon

View file

@ -104,7 +104,13 @@ export default function ToogleZoomButtonGroup({ boundType, setBoundType }) {
return ( return (
<> <>
<View style={styles.boundTypeButtonGroup}> <View style={styles.boundTypeButtonGroup}>
<ToggleButton.Group onValueChange={zoomToggle} value={boundType}> <ToggleButton.Group
onValueChange={zoomToggle}
value={boundType}
accessibilityRole="radiogroup"
accessibilityLabel="Mode d'affichage de la carte"
accessibilityHint="Change le mode d'affichage de la carte."
>
<ToggleZoomButton <ToggleZoomButton
value={BoundType.TRACK_ALERT_RADIUS_ALL} value={BoundType.TRACK_ALERT_RADIUS_ALL}
selected={boundType} selected={boundType}

View file

@ -1,6 +1,6 @@
import { ToggleButton } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "~/theme"; import { useTheme } from "~/theme";
import IconTouchTarget from "~/components/IconTouchTarget";
export default function MapLinksPopupIconButton({ export default function MapLinksPopupIconButton({
setIsVisible, setIsVisible,
@ -9,23 +9,22 @@ export default function MapLinksPopupIconButton({
}) { }) {
const { colors, custom } = useTheme(); const { colors, custom } = useTheme();
return ( return (
<ToggleButton <IconTouchTarget
mode="contained" accessibilityLabel="Ouvrir dans une application de navigation"
accessibilityHint="Ouvre un choix d'applications pour naviguer vers l'emplacement."
onPress={() => setIsVisible(true)} onPress={() => setIsVisible(true)}
icon={() => ( style={({ pressed }) => ({
backgroundColor: colors.surface,
borderRadius: 4,
opacity: pressed ? 0.7 : 1,
})}
{...extraProps}
>
<MaterialCommunityIcons <MaterialCommunityIcons
name="arrow-top-right-bold-box-outline" name="arrow-top-right-bold-box-outline"
size={24} size={24}
color={colors.onSurface} color={colors.onSurface}
/> />
)} </IconTouchTarget>
style={{
width: 32,
height: 32,
backgroundColor: colors.surface,
color: colors.onSurface,
}}
{...extraProps}
/>
); );
} }

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect, useRef } from "react";
import { import {
View, View,
StyleSheet, StyleSheet,
@ -17,6 +17,10 @@ import {
} from "~/stores"; } from "~/stores";
import { createStyles, useTheme } from "~/theme"; import { createStyles, useTheme } from "~/theme";
import openSettings from "~/lib/native/openSettings"; import openSettings from "~/lib/native/openSettings";
import {
announceForA11yIfScreenReaderEnabled,
setA11yFocusAfterInteractions,
} from "~/lib/a11y";
import { import {
RequestDisableOptimization, RequestDisableOptimization,
BatteryOptEnabled, BatteryOptEnabled,
@ -53,6 +57,8 @@ const HeroMode = () => {
]); ]);
const theme = useTheme(); const theme = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const titleRef = useRef(null);
const lastAnnouncementRef = useRef({});
const [skipMessage] = useState(() => { const [skipMessage] = useState(() => {
const randomIndex = Math.floor(Math.random() * skipMessages.length); const randomIndex = Math.floor(Math.random() * skipMessages.length);
@ -116,11 +122,34 @@ const HeroMode = () => {
permissionsActions.setLocationBackground(locationGranted); permissionsActions.setLocationBackground(locationGranted);
console.log("Location background permission:", locationGranted); console.log("Location background permission:", locationGranted);
if (
lastAnnouncementRef.current.locationBackground !==
String(!!locationGranted)
) {
lastAnnouncementRef.current.locationBackground = String(
!!locationGranted,
);
await announceForA11yIfScreenReaderEnabled(
`Localisation en arrière-plan : ${
locationGranted ? "permission accordée" : "permission non accordée"
}.`,
);
}
// Request motion permission second // Request motion permission second
const motionGranted = await requestPermissionMotion.requestPermission(); const motionGranted = await requestPermissionMotion.requestPermission();
permissionsActions.setMotion(motionGranted); permissionsActions.setMotion(motionGranted);
console.log("Motion permission:", motionGranted); console.log("Motion permission:", motionGranted);
if (lastAnnouncementRef.current.motion !== String(!!motionGranted)) {
lastAnnouncementRef.current.motion = String(!!motionGranted);
await announceForA11yIfScreenReaderEnabled(
`Détection de mouvement : ${
motionGranted ? "permission accordée" : "permission non accordée"
}.`,
);
}
permissionWizardActions.setCurrentStep("tracking"); permissionWizardActions.setCurrentStep("tracking");
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
@ -128,6 +157,23 @@ const HeroMode = () => {
const batteryOptDisabled = await handleBatteryOptimization(); const batteryOptDisabled = await handleBatteryOptimization();
console.log("Battery optimization disabled:", batteryOptDisabled); console.log("Battery optimization disabled:", batteryOptDisabled);
if (
Platform.OS === "android" &&
lastAnnouncementRef.current.batteryOptimizationDisabled !==
String(!!batteryOptDisabled)
) {
lastAnnouncementRef.current.batteryOptimizationDisabled = String(
!!batteryOptDisabled,
);
await announceForA11yIfScreenReaderEnabled(
`Optimisation de la batterie : ${
batteryOptDisabled
? "désactivée"
: "toujours activée. Ouvrez les paramètres Android."
}`,
);
}
// Check if we should proceed to success immediately // Check if we should proceed to success immediately
if (locationGranted && motionGranted && batteryOptDisabled) { if (locationGranted && motionGranted && batteryOptDisabled) {
permissionWizardActions.setHeroPermissionsGranted(true); permissionWizardActions.setHeroPermissionsGranted(true);
@ -243,6 +289,10 @@ const HeroMode = () => {
} }
}, [hasAttempted, allGranted, handleNext]); }, [hasAttempted, allGranted, handleNext]);
useEffect(() => {
setA11yFocusAfterInteractions(titleRef);
}, []);
const styles = useStyles(); const styles = useStyles();
const renderWarnings = () => { const renderWarnings = () => {
@ -348,6 +398,8 @@ const HeroMode = () => {
mode="outlined" mode="outlined"
onPress={openSettings} onPress={openSettings}
style={styles.androidSettingsButton} style={styles.androidSettingsButton}
accessibilityLabel="Ouvrir les paramètres Android"
accessibilityHint="Ouvre les paramètres du téléphone pour activer les autorisations et désactiver l'optimisation de la batterie."
> >
Ouvrir les paramètres Ouvrir les paramètres
</CustomButton> </CustomButton>
@ -401,6 +453,8 @@ const HeroMode = () => {
mode="outlined" mode="outlined"
onPress={openSettings} onPress={openSettings}
style={styles.iosSettingsButton} style={styles.iosSettingsButton}
accessibilityLabel="Ouvrir les réglages iOS"
accessibilityHint="Ouvre les réglages du téléphone pour vérifier les autorisations et les options de fonctionnement en arrière-plan."
> >
Ouvrir les réglages Ouvrir les réglages
</CustomButton> </CustomButton>
@ -482,8 +536,14 @@ const HeroMode = () => {
source={require("~/assets/img/wizard-heromode.png")} source={require("~/assets/img/wizard-heromode.png")}
style={styles.heroImage} style={styles.heroImage}
resizeMode="contain" resizeMode="contain"
accessible={false}
importantForAccessibility="no"
/> />
<Title style={[styles.title, { color: theme.colors.primary }]}> <Title
ref={titleRef}
accessibilityRole="header"
style={[styles.title, { color: theme.colors.primary }]}
>
Rejoignez les vrais{"\n"} Rejoignez les vrais{"\n"}
<Text style={styles.subtitle}>Soyez prêt à agir</Text> <Text style={styles.subtitle}>Soyez prêt à agir</Text>
</Title> </Title>

View file

@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect, useRef } from "react";
import { View, StyleSheet, Image, ScrollView } from "react-native"; import { View, StyleSheet, Image, ScrollView } from "react-native";
import { Title } from "react-native-paper"; import { Title } from "react-native-paper";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@ -6,10 +6,16 @@ import { useTheme } from "~/theme";
import { permissionWizardActions } from "~/stores"; import { permissionWizardActions } from "~/stores";
import CustomButton from "~/components/CustomButton"; import CustomButton from "~/components/CustomButton";
import Text from "~/components/Text"; import Text from "~/components/Text";
import { setA11yFocusAfterInteractions } from "~/lib/a11y";
const SkipInfo = () => { const SkipInfo = () => {
const theme = useTheme(); const theme = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const titleRef = useRef(null);
useEffect(() => {
setA11yFocusAfterInteractions(titleRef);
}, []);
const handleFinish = () => { const handleFinish = () => {
permissionWizardActions.setCompleted(true); permissionWizardActions.setCompleted(true);
@ -32,9 +38,15 @@ const SkipInfo = () => {
source={require("~/assets/img/wizard-skip.png")} source={require("~/assets/img/wizard-skip.png")}
style={styles.titleImage} style={styles.titleImage}
resizeMode="contain" resizeMode="contain"
accessible={false}
importantForAccessibility="no"
/> />
</View> </View>
<Title style={[styles.title, { color: theme.colors.primary }]}> <Title
ref={titleRef}
accessibilityRole="header"
style={[styles.title, { color: theme.colors.primary }]}
>
Bon... D'accord...{"\n"} Bon... D'accord...{"\n"}
<Text style={styles.subtitle}>On ne vous en veut pas !</Text> <Text style={styles.subtitle}>On ne vous en veut pas !</Text>
</Title> </Title>

View file

@ -1,4 +1,4 @@
import React, { useCallback } from "react"; import React, { useCallback, useEffect, useRef } from "react";
import { View, StyleSheet, Image, ScrollView } from "react-native"; import { View, StyleSheet, Image, ScrollView } from "react-native";
import { Button, Title } from "react-native-paper"; import { Button, Title } from "react-native-paper";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@ -7,15 +7,21 @@ import { permissionWizardActions } from "~/stores";
import { useTheme } from "~/theme"; import { useTheme } from "~/theme";
import Text from "~/components/Text"; import Text from "~/components/Text";
import CustomButton from "~/components/CustomButton"; import CustomButton from "~/components/CustomButton";
import { setA11yFocusAfterInteractions } from "~/lib/a11y";
const Success = () => { const Success = () => {
const theme = useTheme(); const theme = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const titleRef = useRef(null);
const handleFinish = useCallback(() => { const handleFinish = useCallback(() => {
permissionWizardActions.setCompleted(true); permissionWizardActions.setCompleted(true);
}, []); }, []);
useEffect(() => {
setA11yFocusAfterInteractions(titleRef);
}, []);
return ( return (
<View <View
style={[styles.container, { backgroundColor: theme.colors.background }]} style={[styles.container, { backgroundColor: theme.colors.background }]}
@ -31,8 +37,14 @@ const Success = () => {
source={require("~/assets/img/wizard-success.png")} source={require("~/assets/img/wizard-success.png")}
style={styles.titleImage} style={styles.titleImage}
resizeMode="contain" resizeMode="contain"
accessible={false}
importantForAccessibility="no"
/> />
<Title style={[styles.title, { color: theme.colors.primary }]}> <Title
ref={titleRef}
accessibilityRole="header"
style={[styles.title, { color: theme.colors.primary }]}
>
Vous voilà prêt ! Vous voilà prêt !
</Title> </Title>

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect, useRef } from "react";
import { View, StyleSheet, Image, ScrollView } from "react-native"; import { View, StyleSheet, Image, ScrollView } from "react-native";
import { Title } from "react-native-paper"; import { Title } from "react-native-paper";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@ -10,6 +10,10 @@ import {
} from "~/stores"; } from "~/stores";
import { useTheme } from "~/theme"; import { useTheme } from "~/theme";
import openSettings from "~/lib/native/openSettings"; import openSettings from "~/lib/native/openSettings";
import {
announceForA11yIfScreenReaderEnabled,
setA11yFocusAfterInteractions,
} from "~/lib/a11y";
import requestPermissionPhoneCall from "~/permissions/requestPermissionPhoneCall"; import requestPermissionPhoneCall from "~/permissions/requestPermissionPhoneCall";
import requestPermissionFcm from "~/permissions/requestPermissionFcm"; import requestPermissionFcm from "~/permissions/requestPermissionFcm";
@ -21,6 +25,8 @@ const Welcome = () => {
const [requesting, setRequesting] = useState(false); const [requesting, setRequesting] = useState(false);
const [hasAttempted, setHasAttempted] = useState(false); const [hasAttempted, setHasAttempted] = useState(false);
const [hasRetried, setHasRetried] = useState(false); const [hasRetried, setHasRetried] = useState(false);
const titleRef = useRef(null);
const lastAnnouncementRef = useRef({});
const permissions = usePermissionsState([ const permissions = usePermissionsState([
"phoneCall", "phoneCall",
"fcm", "fcm",
@ -48,6 +54,24 @@ const Welcome = () => {
permissionWizardActions.setBasicPermissionsGranted(true); permissionWizardActions.setBasicPermissionsGranted(true);
permissionWizardActions.setCurrentStep("hero"); permissionWizardActions.setCurrentStep("hero");
} }
// Announce results (screen-reader only) without spamming repeated attempts.
const entries = [
["phoneCall", "Appels", phoneCall],
["fcm", "Notifications", fcm],
["locationForeground", "Localisation", location],
];
for (const [key, label, granted] of entries) {
const announceKey = `${key}:${String(!!granted)}`;
if (lastAnnouncementRef.current[key] === announceKey) continue;
lastAnnouncementRef.current[key] = announceKey;
await announceForA11yIfScreenReaderEnabled(
`${label} : ${
granted ? "permission accordée" : "permission non accordée"
}.`,
);
}
} catch (error) { } catch (error) {
console.error("Error requesting permissions:", error); console.error("Error requesting permissions:", error);
} }
@ -69,6 +93,10 @@ const Welcome = () => {
} }
}, [hasAttempted, allGranted, handleNext]); }, [hasAttempted, allGranted, handleNext]);
useEffect(() => {
setA11yFocusAfterInteractions(titleRef);
}, []);
const renderWarnings = () => { const renderWarnings = () => {
const warnings = []; const warnings = [];
if (!permissions.phoneCall) { if (!permissions.phoneCall) {
@ -100,6 +128,8 @@ const Welcome = () => {
mode="contained" mode="contained"
onPress={handleRequestPermissions} onPress={handleRequestPermissions}
loading={requesting} loading={requesting}
accessibilityLabel="Accorder les permissions"
accessibilityHint="Demande les autorisations d'appels, de notifications et de localisation pendant l'utilisation."
> >
J'accorde les permissions J'accorde les permissions
</CustomButton> </CustomButton>
@ -116,13 +146,20 @@ const Welcome = () => {
return ( return (
<> <>
<CustomButton mode="contained" onPress={handleNext}> <CustomButton
mode="contained"
onPress={handleNext}
accessibilityLabel="Ignorer"
accessibilityHint="Continue sans accorder les permissions maintenant."
>
Ignorer Ignorer
</CustomButton> </CustomButton>
<CustomButton <CustomButton
mode="contained" mode="contained"
onPress={handleRetry} onPress={handleRetry}
color={theme.colors.secondary} color={theme.colors.secondary}
accessibilityLabel="Réessayer d'accorder les permissions"
accessibilityHint="Relance la demande des autorisations."
> >
Réessayer d'accorder les permissions Réessayer d'accorder les permissions
</CustomButton> </CustomButton>
@ -160,8 +197,14 @@ const Welcome = () => {
<Image <Image
source={require("~/assets/img/logo.png")} source={require("~/assets/img/logo.png")}
style={styles.titleImage} style={styles.titleImage}
accessible={false}
importantForAccessibility="no"
/> />
<Title style={[styles.title, { color: theme.colors.primary }]}> <Title
ref={titleRef}
accessibilityRole="header"
style={[styles.title, { color: theme.colors.primary }]}
>
<Text>Alerte-Secours</Text> <Text>Alerte-Secours</Text>
{"\n"} {"\n"}
<Text style={styles.subtitle}>Toujours prêts !</Text> <Text style={styles.subtitle}>Toujours prêts !</Text>

View file

@ -43,6 +43,9 @@ export default function PermissionWizard({ visible }) {
onRequestClose={() => {}} onRequestClose={() => {}}
> >
<SafeAreaView <SafeAreaView
accessibilityViewIsModal
accessibilityLabel="Assistant d'autorisations"
accessibilityHint="Fenêtre modale. Suivez les étapes pour accorder les autorisations nécessaires."
style={[styles.container, { backgroundColor: theme.colors.background }]} style={[styles.container, { backgroundColor: theme.colors.background }]}
> >
<StepComponent /> <StepComponent />

18
src/lib/a11y/announce.js Normal file
View file

@ -0,0 +1,18 @@
import { AccessibilityInfo } from "react-native";
export async function announceForA11y(message) {
if (!message) return;
// RN uses platform-specific announcers internally.
// We keep this wrapper to centralize behavior and allow future throttling.
AccessibilityInfo.announceForAccessibility(String(message));
}
export async function announceForA11yIfScreenReaderEnabled(message) {
if (!message) return;
const enabled = await AccessibilityInfo.isScreenReaderEnabled();
if (!enabled) return;
AccessibilityInfo.announceForAccessibility(String(message));
}

16
src/lib/a11y/focus.js Normal file
View file

@ -0,0 +1,16 @@
import {
AccessibilityInfo,
findNodeHandle,
InteractionManager,
} from "react-native";
export function setA11yFocus(refOrNode) {
const node = findNodeHandle(refOrNode?.current ?? refOrNode);
if (!node) return;
AccessibilityInfo.setAccessibilityFocus(node);
}
export function setA11yFocusAfterInteractions(refOrNode) {
InteractionManager.runAfterInteractions(() => setA11yFocus(refOrNode));
}

6
src/lib/a11y/index.js Normal file
View file

@ -0,0 +1,6 @@
export {
announceForA11y,
announceForA11yIfScreenReaderEnabled,
} from "./announce";
export { setA11yFocus, setA11yFocusAfterInteractions } from "./focus";

View file

@ -6,6 +6,7 @@ import DigitalTimeString from "./DigitalTimeString";
import useStyles from "./styles"; import useStyles from "./styles";
import withHooks from "~/hoc/withHooks"; import withHooks from "~/hoc/withHooks";
import IconTouchTarget from "~/components/IconTouchTarget";
const TRACK_SIZE = 4; const TRACK_SIZE = 4;
const THUMB_SIZE = 20; const THUMB_SIZE = 20;
@ -138,6 +139,13 @@ function AudioSlider(props) {
} catch {} } catch {}
}; };
const a11yPlayPauseLabel = status.playing
? "Mettre en pause"
: "Lire le message audio";
const a11yPlayPauseHint = status.playing
? "Met en pause la lecture."
: "Démarre la lecture du message audio.";
// Pan handling for seeking // Pan handling for seeking
const panResponder = useMemo( const panResponder = useMemo(
() => () =>
@ -265,6 +273,10 @@ function AudioSlider(props) {
}} }}
> >
<TouchableOpacity <TouchableOpacity
// Make this wrapper non-accessible to avoid a duplicate SR target.
// The interactive, labeled touch target is provided by IconTouchTarget below.
accessible={false}
importantForAccessibility="no"
style={{ style={{
flex: 1, flex: 1,
flexDirection: "row", flexDirection: "row",
@ -290,6 +302,22 @@ function AudioSlider(props) {
)} )}
</TouchableOpacity> </TouchableOpacity>
{/* A11y: ensure minimum touch target and stateful labels for SR users */}
<IconTouchTarget
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 44,
zIndex: 3,
}}
accessibilityLabel={a11yPlayPauseLabel}
accessibilityHint={a11yPlayPauseHint}
accessibilityState={{ selected: !!status.playing }}
onPress={onPressPlayPause}
/>
<Animated.View <Animated.View
onLayout={measureTrack} onLayout={measureTrack}
style={[ style={[
@ -300,6 +328,10 @@ function AudioSlider(props) {
borderRadius: TRACK_SIZE / 2, borderRadius: TRACK_SIZE / 2,
}, },
]} ]}
accessible
accessibilityRole="adjustable"
accessibilityLabel="Position de lecture"
accessibilityHint="Faites glisser pour avancer ou reculer dans le message audio."
> >
<Animated.View <Animated.View
style={{ style={{

View file

@ -214,6 +214,7 @@ function Toast(props) {
props.renderToast(props) props.renderToast(props)
) : ( ) : (
<TouchableWithoutFeedback <TouchableWithoutFeedback
accessibilityRole="button"
disabled={!(onPress || hideOnPress)} disabled={!(onPress || hideOnPress)}
onPress={() => { onPress={() => {
onPress && onPress(id); onPress && onPress(id);

View file

@ -0,0 +1,85 @@
import BackgroundGeolocation from "react-native-background-geolocation";
import { TRACK_MOVE } from "~/misc/devicePrefs";
import env from "~/env";
// Common config: keep always-on tracking enabled, but default to an IDLE low-power profile.
// High-accuracy and moving mode are enabled only when an active alert is open.
//
// Notes:
// - We avoid `reset: true` in production because it can unintentionally wipe persisted / configured state.
// In dev, `reset: true` is useful to avoid config drift while iterating.
// - `maxRecordsToPersist` must be > 1 to support offline catch-up.
export const BASE_GEOLOCATION_CONFIG = {
// Android Headless Mode (requires registering a headless task entrypoint in index.js)
enableHeadless: true,
// Default to low-power (idle) profile; will be overridden when needed.
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
// Default to the IDLE profile behaviour: we still want distance-based updates
// even with no open alert (see TRACKING_PROFILES.idle).
distanceFilter: 50,
// debug: true,
logLevel: BackgroundGeolocation.LOG_LEVEL_VERBOSE,
// Permission request strategy
locationAuthorizationRequest: "Always",
// Lifecycle
stopOnTerminate: false,
startOnBoot: true,
// Background scheduling
heartbeatInterval: 3600,
// Android foreground service
foregroundService: true,
notification: {
title: "Alerte Secours",
text: "Suivi de localisation actif",
channelName: "Location tracking",
priority: BackgroundGeolocation.NOTIFICATION_PRIORITY_HIGH,
},
// Android 10+ rationale dialog
backgroundPermissionRationale: {
title:
"Autoriser Alerte-Secours à accéder à la localisation en arrière-plan",
message:
"Alerte-Secours nécessite la localisation en arrière-plan pour vous alerter en temps réel lorsqu'une personne à proximité a besoin d'aide urgente. Cette fonction est essentielle pour permettre une intervention rapide et efficace en cas d'urgence.",
positiveAction: "Autoriser",
negativeAction: "Désactiver",
},
// HTTP configuration
url: env.GEOLOC_SYNC_URL,
method: "POST",
httpRootProperty: "location",
batchSync: false,
autoSync: true,
// Persistence: keep enough records for offline catch-up.
// (The SDK already constrains with maxDaysToPersist; records are deleted after successful upload.)
maxRecordsToPersist: 1000,
maxDaysToPersist: 7,
// Development convenience
reset: !!__DEV__,
// Behavior tweaks
disableProviderChangeRecord: true,
};
export const TRACKING_PROFILES = {
idle: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
distanceFilter: 50,
heartbeatInterval: 3600,
},
active: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
distanceFilter: TRACK_MOVE,
heartbeatInterval: 900,
},
};

View file

@ -0,0 +1,122 @@
import BackgroundGeolocation from "react-native-background-geolocation";
import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import { BASE_GEOLOCATION_CONFIG } from "./backgroundGeolocationConfig";
const bgGeoLogger = createLogger({
module: BACKGROUND_SCOPES.GEOLOCATION,
feature: "bg-geo-service",
});
let readyPromise = null;
let lastReadyState = null;
let subscriptions = [];
let handlersSignature = null;
export async function ensureBackgroundGeolocationReady(
config = BASE_GEOLOCATION_CONFIG,
) {
if (readyPromise) return readyPromise;
readyPromise = (async () => {
bgGeoLogger.info("Calling BackgroundGeolocation.ready");
const state = await BackgroundGeolocation.ready(config);
lastReadyState = state;
bgGeoLogger.info("BackgroundGeolocation is ready", {
enabled: state?.enabled,
isMoving: state?.isMoving,
trackingMode: state?.trackingMode,
schedulerEnabled: state?.schedulerEnabled,
});
return state;
})().catch((error) => {
// Allow retry if ready fails.
readyPromise = null;
lastReadyState = null;
bgGeoLogger.error("BackgroundGeolocation.ready failed", {
error: error?.message,
stack: error?.stack,
code: error?.code,
});
throw error;
});
return readyPromise;
}
export function getLastReadyState() {
return lastReadyState;
}
export function setBackgroundGeolocationEventHandlers({
onLocation,
onLocationError,
onHttp,
onMotionChange,
onActivityChange,
onProviderChange,
onConnectivityChange,
onEnabledChange,
} = {}) {
// Avoid duplicate registration when `trackLocation()` is called multiple times.
// We use a simple signature so calling with identical functions is a no-op.
const sig = [
onLocation ? "L1" : "L0",
onHttp ? "H1" : "H0",
onMotionChange ? "M1" : "M0",
onActivityChange ? "A1" : "A0",
onProviderChange ? "P1" : "P0",
onConnectivityChange ? "C1" : "C0",
onEnabledChange ? "E1" : "E0",
].join("-");
if (handlersSignature === sig && subscriptions.length) {
return;
}
subscriptions.forEach((s) => s?.remove?.());
subscriptions = [];
if (onLocation) {
subscriptions.push(
BackgroundGeolocation.onLocation(onLocation, onLocationError),
);
}
if (onHttp) {
subscriptions.push(BackgroundGeolocation.onHttp(onHttp));
}
if (onMotionChange) {
subscriptions.push(BackgroundGeolocation.onMotionChange(onMotionChange));
}
if (onActivityChange) {
subscriptions.push(
BackgroundGeolocation.onActivityChange(onActivityChange),
);
}
if (onProviderChange) {
subscriptions.push(
BackgroundGeolocation.onProviderChange(onProviderChange),
);
}
if (onConnectivityChange) {
subscriptions.push(
BackgroundGeolocation.onConnectivityChange(onConnectivityChange),
);
}
if (onEnabledChange) {
subscriptions.push(BackgroundGeolocation.onEnabledChange(onEnabledChange));
}
handlersSignature = sig;
}
export async function stopBackgroundGeolocation() {
await ensureBackgroundGeolocationReady();
return BackgroundGeolocation.stop();
}
export async function startBackgroundGeolocation() {
await ensureBackgroundGeolocationReady();
return BackgroundGeolocation.start();
}

View file

@ -4,6 +4,9 @@ import { STORAGE_KEYS } from "~/storage/storageKeys";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService";
import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig";
// Global variables // Global variables
let emulatorIntervalId = null; let emulatorIntervalId = null;
let isEmulatorModeEnabled = false; let isEmulatorModeEnabled = false;
@ -43,6 +46,8 @@ export const enableEmulatorMode = async () => {
} }
try { try {
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Call immediately once // Call immediately once
await BackgroundGeolocation.changePace(true); await BackgroundGeolocation.changePace(true);
emulatorLogger.debug("Initial changePace call successful"); emulatorLogger.debug("Initial changePace call successful");

View file

@ -9,6 +9,9 @@ import setLocationState from "./setLocationState";
import camelCaseKeys from "~/utils/string/camelCaseKeys"; import camelCaseKeys from "~/utils/string/camelCaseKeys";
import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService";
import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig";
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second const RETRY_DELAY = 1000; // 1 second
@ -17,6 +20,10 @@ export async function getCurrentLocation() {
while (retries < MAX_RETRIES) { while (retries < MAX_RETRIES) {
try { try {
// Vendor requirement: never call APIs like getState/requestPermission/getCurrentPosition
// before `.ready()` has resolved.
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Check for location permissions and services // Check for location permissions and services
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();

View file

@ -1,5 +1,4 @@
import BackgroundGeolocation from "react-native-background-geolocation"; import BackgroundGeolocation from "react-native-background-geolocation";
import { TRACK_MOVE } from "~/misc/devicePrefs";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import jwtDecode from "jwt-decode"; import jwtDecode from "jwt-decode";
@ -20,67 +19,21 @@ import { storeLocation } from "~/location/storage";
import env from "~/env"; import env from "~/env";
// Common config: keep always-on tracking enabled, but default to an IDLE low-power profile. import {
// High-accuracy and "moving" mode are only enabled when an active alert is open. BASE_GEOLOCATION_CONFIG,
const baseConfig = { TRACKING_PROFILES,
// https://github.com/transistorsoft/react-native-background-geolocation/wiki/Android-Headless-Mode } from "~/location/backgroundGeolocationConfig";
enableHeadless: true, import {
disableProviderChangeRecord: true, ensureBackgroundGeolocationReady,
// disableMotionActivityUpdates: true, setBackgroundGeolocationEventHandlers,
// Default to low-power (idle) profile; will be overridden when needed. } from "~/location/backgroundGeolocationService";
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
// Larger distance filter in idle mode to prevent frequent GPS wakes.
distanceFilter: 200,
// debug: true, // Enable debug mode for more detailed logs
logLevel: BackgroundGeolocation.LOG_LEVEL_VERBOSE,
// Disable automatic permission requests
locationAuthorizationRequest: "Always",
stopOnTerminate: false,
startOnBoot: true,
// Keep heartbeat very infrequent in idle mode.
heartbeatInterval: 3600,
// Force the plugin to start aggressively
foregroundService: true,
notification: {
title: "Alerte Secours",
text: "Suivi de localisation actif",
channelName: "Location tracking",
priority: BackgroundGeolocation.NOTIFICATION_PRIORITY_HIGH,
},
backgroundPermissionRationale: {
title:
"Autoriser Alerte-Secours à accéder à la localisation en arrière-plan",
message:
"Alerte-Secours nécessite la localisation en arrière-plan pour vous alerter en temps réel lorsqu'une personne à proximité a besoin d'aide urgente. Cette fonction est essentielle pour permettre une intervention rapide et efficace en cas d'urgence.",
positiveAction: "Autoriser",
negativeAction: "Désactiver",
},
// Enhanced HTTP configuration
url: env.GEOLOC_SYNC_URL,
method: "POST", // Explicitly set HTTP method
httpRootProperty: "location", // Specify the root property for the locations array
// Configure persistence
maxRecordsToPersist: 1, // Limit the number of records to store
maxDaysToPersist: 7, // Limit the age of records to persist
batchSync: false,
autoSync: true,
reset: true,
};
const TRACKING_PROFILES = { let trackLocationStartPromise = null;
idle: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
distanceFilter: 200,
heartbeatInterval: 3600,
},
active: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
distanceFilter: TRACK_MOVE,
heartbeatInterval: 900,
},
};
export default async function trackLocation() { export default function trackLocation() {
if (trackLocationStartPromise) return trackLocationStartPromise;
trackLocationStartPromise = (async () => {
const locationLogger = createLogger({ const locationLogger = createLogger({
module: BACKGROUND_SCOPES.GEOLOCATION, module: BACKGROUND_SCOPES.GEOLOCATION,
feature: "tracking", feature: "tracking",
@ -91,6 +44,67 @@ export default async function trackLocation() {
let stopAlertSubscription = null; let stopAlertSubscription = null;
let stopSessionSubscription = null; let stopSessionSubscription = null;
// One-off startup refresh: when tracking is enabled at app launch, fetch a fresh fix once.
// This follows Transistorsoft docs guidance to use getCurrentPosition rather than forcing
// the SDK into moving mode with changePace(true).
let didRequestStartupFix = false;
let startupFixInFlight = null;
// When auth changes, we want a fresh persisted point for the newly effective identity.
// Debounced to avoid spamming `getCurrentPosition` if auth updates quickly (refresh/renew).
let authFixDebounceTimerId = null;
let authFixInFlight = null;
const AUTH_FIX_DEBOUNCE_MS = 1500;
const scheduleAuthFreshFix = () => {
if (authFixDebounceTimerId) {
clearTimeout(authFixDebounceTimerId);
authFixDebounceTimerId = null;
}
authFixInFlight = new Promise((resolve) => {
authFixDebounceTimerId = setTimeout(resolve, AUTH_FIX_DEBOUNCE_MS);
}).then(async () => {
try {
const before = await BackgroundGeolocation.getState();
locationLogger.info("Requesting auth-change location fix", {
enabled: before.enabled,
trackingMode: before.trackingMode,
isMoving: before.isMoving,
});
const location = await BackgroundGeolocation.getCurrentPosition({
samples: 3,
persist: true,
timeout: 30,
maximumAge: 5000,
desiredAccuracy: 50,
extras: {
auth_token_update: true,
},
});
locationLogger.info("Auth-change location fix acquired", {
accuracy: location?.coords?.accuracy,
latitude: location?.coords?.latitude,
longitude: location?.coords?.longitude,
timestamp: location?.timestamp,
});
} catch (error) {
locationLogger.warn("Auth-change location fix failed", {
error: error?.message,
code: error?.code,
stack: error?.stack,
});
} finally {
authFixDebounceTimerId = null;
authFixInFlight = null;
}
});
return authFixInFlight;
};
const computeHasOwnOpenAlert = () => { const computeHasOwnOpenAlert = () => {
try { try {
const { userId } = getSessionState(); const { userId } = getSessionState();
@ -131,10 +145,14 @@ export default async function trackLocation() {
try { try {
await BackgroundGeolocation.setConfig(profile); await BackgroundGeolocation.setConfig(profile);
// Key battery fix: // Motion state strategy:
// - IDLE profile forces stationary mode // - ACTIVE: force moving to begin aggressive tracking immediately.
// - ACTIVE profile forces moving mode // - IDLE: do NOT force stationary. Let the SDK's motion detection manage
await BackgroundGeolocation.changePace(profileName === "active"); // moving/stationary transitions so we still get distance-based updates
// (target: new point when moved ~50m+ even without an open alert).
if (profileName === "active") {
await BackgroundGeolocation.changePace(true);
}
currentProfile = profileName; currentProfile = profileName;
} catch (error) { } catch (error) {
@ -154,11 +172,29 @@ export default async function trackLocation() {
// Handle auth function - no throttling or cooldown // Handle auth function - no throttling or cooldown
async function handleAuth(userToken) { async function handleAuth(userToken) {
// Defensive: ensure `.ready()` is resolved before any API call.
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
locationLogger.info("Handling auth token update", { locationLogger.info("Handling auth token update", {
hasToken: !!userToken, hasToken: !!userToken,
}); });
if (!userToken) { if (!userToken) {
locationLogger.info("No auth token, stopping location tracking"); locationLogger.info("No auth token, stopping location tracking");
// Prevent any further uploads before stopping.
// This guards against persisted HTTP config continuing to flush queued records.
try {
await BackgroundGeolocation.setConfig({
url: "",
autoSync: false,
headers: {},
});
} catch (e) {
locationLogger.warn("Failed to clear BGGeo HTTP config on logout", {
error: e?.message,
});
}
await BackgroundGeolocation.stop(); await BackgroundGeolocation.stop();
locationLogger.debug("Location tracking stopped"); locationLogger.debug("Location tracking stopped");
@ -173,6 +209,12 @@ export default async function trackLocation() {
authReady = false; authReady = false;
currentProfile = null; currentProfile = null;
if (authFixDebounceTimerId) {
clearTimeout(authFixDebounceTimerId);
authFixDebounceTimerId = null;
}
authFixInFlight = null;
return; return;
} }
// unsub(); // unsub();
@ -218,6 +260,21 @@ export default async function trackLocation() {
} }
} }
// Always request a fresh persisted point on any token update.
// This ensures a newly connected user gets an immediate point even if they don't move.
scheduleAuthFreshFix();
// Request a single fresh location-fix on each app launch when tracking is enabled.
// - We do this only after auth headers are configured so the persisted point can sync.
// - We do NOT force moving mode.
if (!didRequestStartupFix) {
didRequestStartupFix = true;
startupFixInFlight = scheduleAuthFreshFix();
} else if (authFixInFlight) {
// Avoid concurrent fix calls if auth updates race.
await authFixInFlight;
}
// Ensure we are NOT forcing "moving" mode by default. // Ensure we are NOT forcing "moving" mode by default.
// Default profile is idle unless an active alert requires higher accuracy. // Default profile is idle unless an active alert requires higher accuracy.
const shouldBeActive = computeHasOwnOpenAlert(); const shouldBeActive = computeHasOwnOpenAlert();
@ -244,14 +301,20 @@ export default async function trackLocation() {
} }
} }
BackgroundGeolocation.onLocation(async (location) => { setBackgroundGeolocationEventHandlers({
onLocation: async (location) => {
locationLogger.debug("Location update received", { locationLogger.debug("Location update received", {
coords: location.coords, coords: location.coords,
timestamp: location.timestamp, timestamp: location.timestamp,
activity: location.activity, activity: location.activity,
battery: location.battery, battery: location.battery,
sample: location.sample,
}); });
// Ignore sampling locations (eg, emitted during getCurrentPosition) to avoid UI/storage churn.
// The final persisted location will arrive with sample=false.
if (location.sample) return;
if ( if (
location.coords && location.coords &&
location.coords.latitude && location.coords.latitude &&
@ -261,20 +324,55 @@ export default async function trackLocation() {
// Also store in AsyncStorage for last known location fallback // Also store in AsyncStorage for last known location fallback
storeLocation(location.coords, location.timestamp); storeLocation(location.coords, location.timestamp);
} }
},
onLocationError: (error) => {
locationLogger.warn("Location error", {
error: error?.message,
code: error?.code,
}); });
},
BackgroundGeolocation.onHttp(async (response) => { onHttp: async (response) => {
// log status code and response // Log success/failure for visibility into token expiry, server errors, etc.
locationLogger.debug("HTTP response received", { locationLogger.debug("HTTP response received", {
success: response?.success,
status: response?.status, status: response?.status,
responseText: response?.responseText, responseText: response?.responseText,
}); });
},
onMotionChange: (event) => {
locationLogger.info("Motion change", {
isMoving: event?.isMoving,
location: event?.location?.coords,
});
},
onActivityChange: (event) => {
locationLogger.info("Activity change", {
activity: event?.activity,
confidence: event?.confidence,
});
},
onProviderChange: (event) => {
locationLogger.info("Provider change", {
status: event?.status,
enabled: event?.enabled,
network: event?.network,
gps: event?.gps,
accuracyAuthorization: event?.accuracyAuthorization,
});
},
onConnectivityChange: (event) => {
locationLogger.info("Connectivity change", {
connected: event?.connected,
});
},
onEnabledChange: (enabled) => {
locationLogger.info("Enabled change", { enabled });
},
}); });
try { try {
locationLogger.info("Initializing background geolocation"); locationLogger.info("Initializing background geolocation");
await BackgroundGeolocation.ready(baseConfig); await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
await BackgroundGeolocation.setConfig(baseConfig);
// Only set the permission state if we already have the permission // Only set the permission state if we already have the permission
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();
@ -320,4 +418,7 @@ export default async function trackLocation() {
if (__DEV__ || env.IS_STAGING) { if (__DEV__ || env.IS_STAGING) {
initEmulatorMode(); initEmulatorMode();
} }
})();
return trackLocationStartPromise;
} }

View file

@ -21,6 +21,7 @@ export default function HeaderLeft(props) {
return ( return (
<HeaderBackButton <HeaderBackButton
{...props} {...props}
testID="header-left-back"
labelVisible={false} labelVisible={false}
style={[styles.size, styles.backButton]} style={[styles.size, styles.backButton]}
onPress={() => { onPress={() => {

View file

@ -70,7 +70,9 @@ export default function HeaderRight(props) {
return ( return (
<View style={[styles.container]}> <View style={[styles.container]}>
<IconButton <IconButton
testID="header-right-send-alert"
accessibilityLabel="Alerter" accessibilityLabel="Alerter"
accessibilityHint="Ouvre l'écran pour envoyer une alerte."
style={[ style={[
styles.button, styles.button,
styles.quickNavButton, styles.quickNavButton,
@ -99,7 +101,9 @@ export default function HeaderRight(props) {
/> />
<IconButton <IconButton
testID="header-right-alerts"
accessibilityLabel="Alertes" accessibilityLabel="Alertes"
accessibilityHint="Ouvre la liste des alertes."
style={[ style={[
styles.button, styles.button,
styles.quickNavButton, styles.quickNavButton,
@ -134,7 +138,9 @@ export default function HeaderRight(props) {
/> />
<IconButton <IconButton
testID="header-right-current-alert"
accessibilityLabel="Alerte en cours" accessibilityLabel="Alerte en cours"
accessibilityHint="Ouvre l'alerte en cours."
style={[ style={[
styles.button, styles.button,
styles.quickNavButton, styles.quickNavButton,
@ -167,7 +173,9 @@ export default function HeaderRight(props) {
{drawerExists && ( {drawerExists && (
<IconButton <IconButton
testID="header-right-menu"
accessibilityLabel="Menu" accessibilityLabel="Menu"
accessibilityHint="Ouvre ou ferme le menu."
style={[styles.button, styles.menuButton]} style={[styles.button, styles.menuButton]}
size={24} size={24}
icon={() => ( icon={() => (
@ -193,7 +201,9 @@ export default function HeaderRight(props) {
anchor={(() => { anchor={(() => {
return ( return (
<IconButton <IconButton
testID="header-right-overflow"
accessibilityLabel="Plus" accessibilityLabel="Plus"
accessibilityHint="Ouvre les options supplémentaires."
style={[styles.button, styles.menuButton]} style={[styles.button, styles.menuButton]}
size={24} size={24}
icon={() => ( icon={() => (

View file

@ -183,6 +183,7 @@ export default function About() {
<View style={{ paddingHorizontal: 15, paddingVertical: 10 }}> <View style={{ paddingHorizontal: 15, paddingVertical: 10 }}>
{/* Website Button */} {/* Website Button */}
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@ -219,6 +220,7 @@ export default function About() {
{/* Contribute Button */} {/* Contribute Button */}
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",

View file

@ -42,6 +42,7 @@ export default function AlertRow({ row, isLast, isFirst, sortBy }) {
<View style={styles.container}> <View style={styles.container}>
<Button <Button
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint="Ouvre le détail de l'alerte. Appui long pour afficher ou masquer le code."
mode="outlined" mode="outlined"
style={[ style={[
styles.button, styles.button,

View file

@ -28,6 +28,8 @@ export default function MapHeadRouting({
distance, distance,
profileDefaultMode, profileDefaultMode,
openStepper, openStepper,
openStepperTriggerRef,
seeAllStepsTriggerRef,
calculatingState, calculatingState,
}) { }) {
const { colors } = useTheme(); const { colors } = useTheme();
@ -81,7 +83,11 @@ export default function MapHeadRouting({
}} }}
> >
<Pressable <Pressable
onPress={openStepper} ref={openStepperTriggerRef}
accessibilityRole="button"
accessibilityLabel="Ouvrir les étapes de navigation"
accessibilityHint="Ouvre la liste complète des étapes de l'itinéraire."
onPress={() => openStepper(openStepperTriggerRef)}
style={{ style={{
justifyContent: "center", justifyContent: "center",
backgroundColor: colors.surface, backgroundColor: colors.surface,
@ -126,16 +132,18 @@ export default function MapHeadRouting({
> >
{displayOpenStepperButton && ( {displayOpenStepperButton && (
<Button <Button
ref={seeAllStepsTriggerRef}
compact compact
rippleColor={colors.secondary} rippleColor={colors.secondary}
accessibilityLabel={"Voir toutes les étapes"} accessibilityLabel={"Voir toutes les étapes"}
accessibilityHint="Affiche toutes les étapes de l'itinéraire."
contentStyle={{ contentStyle={{
flexDirection: "row-reverse", flexDirection: "row-reverse",
}} }}
style={{ style={{
borderRadius: 0, borderRadius: 0,
}} }}
onPress={openStepper} onPress={() => openStepper(seeAllStepsTriggerRef)}
icon={() => ( icon={() => (
<MaterialCommunityIcons <MaterialCommunityIcons
name={"chevron-right"} name={"chevron-right"}

View file

@ -1,4 +1,4 @@
import { View, ScrollView } from "react-native"; import { View, ScrollView, Text as RNText } from "react-native";
import { Button, ToggleButton } from "react-native-paper"; import { Button, ToggleButton } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
@ -11,6 +11,7 @@ import humanizeDuration from "~/utils/time/humanizeDuration";
import RoutingStep from "./RoutingStep"; import RoutingStep from "./RoutingStep";
import Text from "~/components/Text"; import Text from "~/components/Text";
import IconTouchTarget from "~/components/IconTouchTarget";
import { STATE_CALCULATING_LOADED } from "./constants"; import { STATE_CALCULATING_LOADED } from "./constants";
@ -25,6 +26,7 @@ export default function RoutingSteps({
distance, distance,
duration, duration,
calculatingState, calculatingState,
titleA11yRef,
}) { }) {
const { colors, custom } = useTheme(); const { colors, custom } = useTheme();
const profileDefaultMode = profileDefaultModes[profile]; const profileDefaultMode = profileDefaultModes[profile];
@ -38,6 +40,8 @@ export default function RoutingSteps({
return ( return (
<> <>
<ScrollView <ScrollView
accessibilityLabel="Liste des étapes de l'itinéraire"
accessibilityHint="Contient la destination, la distance, la durée et les étapes."
style={{ style={{
flex: 1, flex: 1,
backgroundColor: colors.surface, backgroundColor: colors.surface,
@ -50,6 +54,19 @@ export default function RoutingSteps({
borderBottomRightRadius: 8, borderBottomRightRadius: 8,
}} }}
> >
<RNText
ref={titleA11yRef}
accessibilityRole="header"
style={{
paddingTop: 10,
paddingBottom: 6,
fontSize: 18,
fontWeight: "700",
color: colors.primary,
}}
>
Itinéraire
</RNText>
<View <View
style={{ style={{
flex: 1, flex: 1,
@ -60,6 +77,11 @@ export default function RoutingSteps({
<ToggleButton.Group onValueChange={setProfile} value={profile}> <ToggleButton.Group onValueChange={setProfile} value={profile}>
<ToggleButton <ToggleButton
value="car" value="car"
accessibilityRole="radio"
accessibilityLabel="Itinéraire en voiture"
accessibilityHint="Sélectionne le mode voiture pour recalculer l'itinéraire."
accessibilityState={{ selected: profile === "car" }}
style={{ width: 44, height: 44 }}
icon={() => { icon={() => {
return ( return (
<MaterialCommunityIcons <MaterialCommunityIcons
@ -72,6 +94,11 @@ export default function RoutingSteps({
/> />
<ToggleButton <ToggleButton
value="foot" value="foot"
accessibilityRole="radio"
accessibilityLabel="Itinéraire à pied"
accessibilityHint="Sélectionne le mode à pied pour recalculer l'itinéraire."
accessibilityState={{ selected: profile === "foot" }}
style={{ width: 44, height: 44 }}
icon={() => { icon={() => {
return ( return (
<MaterialCommunityIcons <MaterialCommunityIcons
@ -84,6 +111,11 @@ export default function RoutingSteps({
/> />
<ToggleButton <ToggleButton
value="bicycle" value="bicycle"
accessibilityRole="radio"
accessibilityLabel="Itinéraire à vélo"
accessibilityHint="Sélectionne le mode vélo pour recalculer l'itinéraire."
accessibilityState={{ selected: profile === "bicycle" }}
style={{ width: 44, height: 44 }}
icon={() => { icon={() => {
return ( return (
<MaterialCommunityIcons <MaterialCommunityIcons
@ -158,36 +190,25 @@ export default function RoutingSteps({
/> />
)} )}
</ScrollView> </ScrollView>
<View <IconTouchTarget
style={{ accessibilityLabel="Fermer la liste des étapes"
accessibilityHint="Ferme la liste et revient à la carte."
onPress={closeStepper}
style={({ pressed }) => ({
position: "absolute", position: "absolute",
overflow: "hidden",
top: 4, top: 4,
right: 0, right: 0,
flex: 1,
width: 26,
height: 26,
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderRadius: 8, borderRadius: 8,
}} opacity: pressed ? 0.7 : 1,
})}
> >
<Button
style={{
flex: 1,
borderRadius: 0,
alignSelf: "center",
left: 5,
}}
onPress={closeStepper}
icon={() => (
<MaterialCommunityIcons <MaterialCommunityIcons
name="close" name="close"
size={26} size={26}
style={{ flex: 1 }} color={colors.onSurface}
/> />
)} </IconTouchTarget>
/>
</View>
</> </>
); );
} }

View file

@ -39,6 +39,14 @@ import MapLinksPopup from "~/containers/MapLinksPopup";
import ControlButtons from "./ControlButtons"; import ControlButtons from "./ControlButtons";
import MapHeadRouting from "./MapHeadRouting.js"; import MapHeadRouting from "./MapHeadRouting.js";
import {
announceForA11yIfScreenReaderEnabled,
setA11yFocusAfterInteractions,
} from "~/lib/a11y";
import IconTouchTarget from "~/components/IconTouchTarget";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "~/theme";
import useFeatures from "./useFeatures"; import useFeatures from "./useFeatures";
import useOnRegionDidChange from "./useOnRegionDidChange"; import useOnRegionDidChange from "./useOnRegionDidChange";
@ -64,6 +72,7 @@ const compassViewPosition = 2; // 0: TopLeft, 1: TopRight, 2: BottomLeft, 3: Bot
const compassViewMargin = { x: 2, y: 100 }; const compassViewMargin = { x: 2, y: 100 };
function AlertCurMap() { function AlertCurMap() {
const { colors } = useTheme();
const [userCoords, setUserCoords] = useState({ const [userCoords, setUserCoords] = useState({
latitude: null, latitude: null,
longitude: null, longitude: null,
@ -478,24 +487,41 @@ function AlertCurMap() {
const [stepperIsOpened, setStepperIsOpened] = useState(false); const [stepperIsOpened, setStepperIsOpened] = useState(false);
const openStepper = useCallback(() => { const routingSheetTitleA11yRef = useRef(null);
const a11yStepsEntryRef = useRef(null);
const mapHeadOpenRef = useRef(null);
const mapHeadSeeAllRef = useRef(null);
const lastStepsTriggerRef = useRef(null);
const openStepper = useCallback(
(triggerRef) => {
if (triggerRef) {
lastStepsTriggerRef.current = triggerRef;
}
setStepperIsOpened(true); setStepperIsOpened(true);
}, [setStepperIsOpened]); },
[setStepperIsOpened],
);
const closeStepper = useCallback(() => { const closeStepper = useCallback(() => {
setStepperIsOpened(false); setStepperIsOpened(false);
setA11yFocusAfterInteractions(lastStepsTriggerRef.current);
}, [setStepperIsOpened]); }, [setStepperIsOpened]);
const stepperOnOpen = useCallback(() => { const stepperOnOpen = useCallback(() => {
if (!stepperIsOpened) { if (!stepperIsOpened) {
setStepperIsOpened(true); setStepperIsOpened(true);
} }
setA11yFocusAfterInteractions(routingSheetTitleA11yRef);
announceForA11yIfScreenReaderEnabled("Liste des étapes ouverte");
}, [stepperIsOpened, setStepperIsOpened]); }, [stepperIsOpened, setStepperIsOpened]);
const stepperOnClose = useCallback(() => { const stepperOnClose = useCallback(() => {
if (stepperIsOpened) { if (stepperIsOpened) {
setStepperIsOpened(false); setStepperIsOpened(false);
} }
announceForA11yIfScreenReaderEnabled("Liste des étapes fermée");
setA11yFocusAfterInteractions(lastStepsTriggerRef.current);
}, [stepperIsOpened, setStepperIsOpened]); }, [stepperIsOpened, setStepperIsOpened]);
const [externalGeoIsVisible, setExternalGeoIsVisible] = useState(false); const [externalGeoIsVisible, setExternalGeoIsVisible] = useState(false);
@ -526,6 +552,7 @@ function AlertCurMap() {
duration={duration} duration={duration}
instructions={instructions} instructions={instructions}
calculatingState={calculating} calculatingState={calculating}
titleA11yRef={routingSheetTitleA11yRef}
/> />
} }
> >
@ -535,6 +562,28 @@ function AlertCurMap() {
alignItems: "stretch", alignItems: "stretch",
}} }}
> >
{/* A11y-first entry point to routing information (before the map in focus order) */}
<IconTouchTarget
ref={a11yStepsEntryRef}
accessibilityLabel="Ouvrir la liste des étapes de l'itinéraire"
accessibilityHint="Affiche la destination, la distance, la durée et toutes les étapes sans utiliser la carte."
onPress={() => openStepper(a11yStepsEntryRef)}
style={({ pressed }) => ({
position: "absolute",
top: 4,
left: 4,
zIndex: 10,
backgroundColor: colors.surface,
borderRadius: 8,
opacity: pressed ? 0.7 : 1,
})}
>
<MaterialCommunityIcons
name="format-list-bulleted"
size={24}
color={colors.onSurface}
/>
</IconTouchTarget>
<MapView <MapView
mapRef={mapRef} mapRef={mapRef}
onRegionDidChange={onRegionDidChange} onRegionDidChange={onRegionDidChange}
@ -590,6 +639,8 @@ function AlertCurMap() {
instructions={instructions} instructions={instructions}
distance={distance} distance={distance}
openStepper={openStepper} openStepper={openStepper}
openStepperTriggerRef={mapHeadOpenRef}
seeAllStepsTriggerRef={mapHeadSeeAllRef}
calculatingState={calculating} calculatingState={calculating}
/> />
</View> </View>

View file

@ -59,6 +59,7 @@ export default function Contribute() {
<Text style={styles.sectionTitle}>Soutenir le projet</Text> <Text style={styles.sectionTitle}>Soutenir le projet</Text>
{/* Liberapay Button */} {/* Liberapay Button */}
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={[ style={[
styles.donationButton, styles.donationButton,
styles.buttonContent, styles.buttonContent,
@ -83,6 +84,7 @@ export default function Contribute() {
{/* Buy Me a Coffee Button */} {/* Buy Me a Coffee Button */}
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={[ style={[
styles.donationButton, styles.donationButton,
styles.buttonContent, styles.buttonContent,
@ -112,6 +114,7 @@ export default function Contribute() {
{/* GitHub Sponsors Button */} {/* GitHub Sponsors Button */}
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={[ style={[
styles.donationButton, styles.donationButton,
styles.buttonContent, styles.buttonContent,
@ -151,6 +154,7 @@ export default function Contribute() {
faire partie de l'aventure. faire partie de l'aventure.
</Text> </Text>
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={styles.contactButton} style={styles.contactButton}
onPress={() => openURL("mailto:contact@alertesecours.fr")} onPress={() => openURL("mailto:contact@alertesecours.fr")}
activeOpacity={0.8} activeOpacity={0.8}

View file

@ -22,6 +22,9 @@ import {
import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger"; import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger";
import { config as loggerConfig } from "~/lib/logger/config"; import { config as loggerConfig } from "~/lib/logger/config";
import { ensureBackgroundGeolocationReady } from "~/location/backgroundGeolocationService";
import { BASE_GEOLOCATION_CONFIG } from "~/location/backgroundGeolocationConfig";
const reset = async () => { const reset = async () => {
await authActions.logout(); await authActions.logout();
}; };
@ -75,6 +78,8 @@ export default function Developer() {
setSyncStatus("syncing"); setSyncStatus("syncing");
setSyncResult(""); setSyncResult("");
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
// Get the count of pending records first // Get the count of pending records first
const count = await BackgroundGeolocation.getCount(); const count = await BackgroundGeolocation.getCount();

View file

@ -105,6 +105,7 @@ export default function HelpSignal() {
</Text> </Text>
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={[styles.linkContainer, styles.sectionLink]} style={[styles.linkContainer, styles.sectionLink]}
onPress={openArticle} onPress={openArticle}
> >

View file

@ -305,6 +305,7 @@ const NotificationItem = ({
{/* Mark as Read button - only show if not acknowledged and not a virtual notification */} {/* Mark as Read button - only show if not acknowledged and not a virtual notification */}
{!notification.acknowledged && !notification.isVirtual && ( {!notification.acknowledged && !notification.isVirtual && (
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
onPress={handleMarkAsRead} onPress={handleMarkAsRead}
style={[styles.actionButton]} style={[styles.actionButton]}
activeOpacity={0.6} activeOpacity={0.6}
@ -319,6 +320,7 @@ const NotificationItem = ({
{/* Delete button */} {/* Delete button */}
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
onPress={handleDelete} onPress={handleDelete}
style={[ style={[
styles.actionButton, styles.actionButton,
@ -352,6 +354,7 @@ const NotificationItem = ({
}} }}
> >
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={itemStyle} style={itemStyle}
onPress={handleNotificationPress} onPress={handleNotificationPress}
activeOpacity={0.7} activeOpacity={0.7}

View file

@ -141,6 +141,7 @@ export default withConnectivity(function Notifications() {
return ( return (
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
style={styles.loadMoreButton} style={styles.loadMoreButton}
onPress={loadMoreNotifications} onPress={loadMoreNotifications}
disabled={loadingMore} disabled={loadingMore}

View file

@ -35,7 +35,9 @@ export default function ParamsEmergencyCall({ data }) {
return ( return (
<> <>
<Title style={styles.title}>Préférences d'accessibilité</Title> <Title accessibilityRole="header" style={styles.title}>
Préférences d'accessibilité
</Title>
<View style={styles.box}> <View style={styles.box}>
<Text style={styles.label}> <Text style={styles.label}>
Lors des appels aux services de secours Lors des appels aux services de secours

View file

@ -36,7 +36,9 @@ export default function ParamsNotifications({ data }) {
return ( return (
<> <>
<Title style={styles.title}>Notifications</Title> <Title accessibilityRole="header" style={styles.title}>
Notifications
</Title>
<View style={styles.box}> <View style={styles.box}>
<Text style={styles.label}>Je souhaite recevoir des notifications</Text> <Text style={styles.label}>Je souhaite recevoir des notifications</Text>
<View style={styles.radioGroup}> <View style={styles.radioGroup}>

View file

@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from "react"; import React, { useEffect, useCallback, useRef } from "react";
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
@ -9,11 +9,16 @@ import {
import { Button, Title } from "react-native-paper"; import { Button, Title } from "react-native-paper";
import { usePermissionsState, permissionsActions } from "~/stores"; import { usePermissionsState, permissionsActions } from "~/stores";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { check, PERMISSIONS, RESULTS } from "react-native-permissions";
import { import {
requestBatteryOptimizationExemption, requestBatteryOptimizationExemption,
isBatteryOptimizationEnabled, isBatteryOptimizationEnabled,
} from "~/lib/native/batteryOptimization"; } from "~/lib/native/batteryOptimization";
import openSettings from "~/lib/native/openSettings"; import openSettings from "~/lib/native/openSettings";
import {
announceForA11yIfScreenReaderEnabled,
setA11yFocusAfterInteractions,
} from "~/lib/a11y";
import requestPermissionFcm from "~/permissions/requestPermissionFcm"; import requestPermissionFcm from "~/permissions/requestPermissionFcm";
import requestPermissionLocationBackground from "~/permissions/requestPermissionLocationBackground"; import requestPermissionLocationBackground from "~/permissions/requestPermissionLocationBackground";
@ -76,6 +81,49 @@ const titlePermissions = {
batteryOptimizationDisabled: "Optimisation de la batterie", batteryOptimizationDisabled: "Optimisation de la batterie",
}; };
const a11yDescriptions = {
fcm: {
purpose: "Recevoir des alertes et des messages importants en temps réel.",
settingsGuidance:
"Si la permission est bloquée, ouvrez les paramètres du téléphone pour l'activer.",
},
phoneCall: {
purpose:
"Permettre à l'application de lancer un appel vers les secours quand vous le demandez.",
settingsGuidance:
"Si la permission est bloquée, ouvrez les paramètres du téléphone pour l'activer.",
},
locationForeground: {
purpose: "Partager votre position pendant l'utilisation de l'application.",
settingsGuidance:
"Si la permission est bloquée, ouvrez les paramètres du téléphone pour l'activer.",
},
locationBackground: {
purpose:
"Partager votre position même quand l'application est fermée, pour être alerté à proximité.",
settingsGuidance:
"Si la permission est bloquée, ouvrez les paramètres du téléphone pour l'activer.",
},
motion: {
purpose:
"Optimiser la localisation en arrière-plan sans enregistrer de données de mouvement.",
settingsGuidance:
"Si la permission est bloquée, ouvrez les paramètres du téléphone pour l'activer.",
},
readContacts: {
purpose:
"Accéder à vos contacts pour faciliter le choix d'un proche (si vous utilisez cette fonctionnalité).",
settingsGuidance:
"Si la permission est bloquée, ouvrez les paramètres du téléphone pour l'activer.",
},
batteryOptimizationDisabled: {
purpose:
"Désactiver l'optimisation de la batterie pour permettre le fonctionnement en arrière-plan sur Android.",
settingsGuidance:
"Ouvrez les paramètres Android pour définir l'application sur « Ne pas optimiser ».",
},
};
// Function to check current permission status // Function to check current permission status
const checkPermissionStatus = async (permission) => { const checkPermissionStatus = async (permission) => {
try { try {
@ -98,9 +146,10 @@ const checkPermissionStatus = async (permission) => {
case "motion": case "motion":
return await requestPermissionMotion.checkPermission(); return await requestPermissionMotion.checkPermission();
case "phoneCall": case "phoneCall":
// Note: Phone call permissions on iOS are determined at build time if (Platform.OS !== "android") return true;
// and on Android they're requested at runtime return (
return true; // This might need adjustment based on your specific implementation (await check(PERMISSIONS.ANDROID.CALL_PHONE)) === RESULTS.GRANTED
);
case "batteryOptimizationDisabled": case "batteryOptimizationDisabled":
if (Platform.OS !== "android") { if (Platform.OS !== "android") {
return true; // iOS doesn't have battery optimization return true; // iOS doesn't have battery optimization
@ -121,22 +170,143 @@ const checkPermissionStatus = async (permission) => {
} }
}; };
const PermissionItem = ({ permission, status, onRequestPermission }) => ( const getPermissionA11yMeta = async (permission) => {
try {
switch (permission) {
case "fcm": {
const { status, canAskAgain } =
await Notifications.getPermissionsAsync();
return {
granted: status === "granted",
blocked: status !== "granted" && canAskAgain === false,
};
}
case "locationForeground": {
const { status, canAskAgain } =
await Location.getForegroundPermissionsAsync();
return {
granted: status === "granted",
blocked: status !== "granted" && canAskAgain === false,
};
}
case "locationBackground": {
const { status, canAskAgain } =
await Location.getBackgroundPermissionsAsync();
return {
granted: status === "granted",
blocked: status !== "granted" && canAskAgain === false,
};
}
case "readContacts": {
const { status, canAskAgain } = await Contacts.getPermissionsAsync();
return {
granted: status === "granted",
blocked: status !== "granted" && canAskAgain === false,
};
}
case "motion": {
if (Platform.OS !== "android") {
return { granted: true, blocked: false };
}
const status = await check(PERMISSIONS.ANDROID.ACTIVITY_RECOGNITION);
return {
granted: status === RESULTS.GRANTED,
blocked: status === RESULTS.BLOCKED,
};
}
case "phoneCall": {
if (Platform.OS !== "android") {
return { granted: true, blocked: false };
}
const status = await check(PERMISSIONS.ANDROID.CALL_PHONE);
return {
granted: status === RESULTS.GRANTED,
blocked: status === RESULTS.BLOCKED,
};
}
case "batteryOptimizationDisabled": {
if (Platform.OS !== "android") {
return { granted: true, blocked: false };
}
const enabled = await isBatteryOptimizationEnabled();
return {
granted: !enabled,
blocked: false,
};
}
default:
return { granted: false, blocked: false };
}
} catch (error) {
console.error(`Error getting a11y meta for ${permission}:`, error);
return { granted: false, blocked: false };
}
};
const PermissionItem = ({
permission,
status,
blocked,
onRequestPermission,
onOpenSettings,
}) => {
const label = titlePermissions[permission];
const description = a11yDescriptions[permission]?.purpose;
const hintWhenEnabled =
"Permission accordée. Pour la retirer, utilisez les réglages du téléphone.";
const hintWhenDisabled = `Active ${label.toLowerCase()} : ${description}`;
const hintWhenBlocked =
a11yDescriptions[permission]?.settingsGuidance ??
"Permission bloquée. Ouvrez les paramètres du téléphone.";
let computedHint = hintWhenDisabled;
if (blocked) {
computedHint = hintWhenBlocked;
} else if (status) {
computedHint = hintWhenEnabled;
}
return (
<View style={styles.permissionItem}> <View style={styles.permissionItem}>
<TouchableOpacity <TouchableOpacity
accessibilityRole="switch"
accessibilityLabel={label}
accessibilityHint={computedHint}
accessibilityState={{ checked: !!status, disabled: !!blocked }}
disabled={blocked}
onPress={() => onRequestPermission(permission)} onPress={() => onRequestPermission(permission)}
style={styles.permissionButton} style={styles.permissionButton}
> >
<Text style={styles.permissionText}>{titlePermissions[permission]}</Text> <Text style={styles.permissionText}>{label}</Text>
<Ionicons <Ionicons
accessible={false}
importantForAccessibility="no"
name={status ? "checkmark-circle" : "close-circle"} name={status ? "checkmark-circle" : "close-circle"}
size={24} size={24}
color={status ? "green" : "red"} color={status ? "green" : "red"}
/> />
</TouchableOpacity> </TouchableOpacity>
{blocked ? (
<View style={styles.blockedRow}>
<Text style={styles.blockedText}>
Action requise : paramètres du téléphone.
</Text>
<Button
mode="outlined"
onPress={onOpenSettings}
accessibilityRole="button"
accessibilityLabel={`Ouvrir les paramètres pour ${label}`}
accessibilityHint={`Ouvre les paramètres du téléphone pour activer ${label.toLowerCase()}.`}
>
Ouvrir les paramètres
</Button>
</View> </View>
); ) : null}
</View>
);
};
export default function Permissions() { export default function Permissions() {
// Create permissions list based on platform // Create permissions list based on platform
@ -161,12 +331,26 @@ export default function Permissions() {
const permissionsList = getPermissionsList(); const permissionsList = getPermissionsList();
const permissionsState = usePermissionsState(permissionsList); const permissionsState = usePermissionsState(permissionsList);
const titleRef = useRef(null);
const lastAnnouncementRef = useRef({});
// We keep a minimal, best-effort blocked map for a11y/UX.
const [blockedMap, setBlockedMap] = React.useState({});
// Memoize the check permissions function // Memoize the check permissions function
const checkAllPermissions = useCallback(async () => { const checkAllPermissions = useCallback(async () => {
for (const permission of permissionsList) { for (const permission of permissionsList) {
const status = await checkPermissionStatus(permission); const status = await checkPermissionStatus(permission);
setPermissions[permission](status); setPermissions[permission](status);
} }
// Also refresh "blocked" state used for a11y guidance.
const nextBlocked = {};
for (const permission of permissionsList) {
const meta = await getPermissionA11yMeta(permission);
nextBlocked[permission] = !!meta.blocked;
}
setBlockedMap(nextBlocked);
}, [permissionsList]); }, [permissionsList]);
// Check all permissions when component mounts // Check all permissions when component mounts
@ -174,6 +358,10 @@ export default function Permissions() {
checkAllPermissions(); checkAllPermissions();
}, [checkAllPermissions]); }, [checkAllPermissions]);
useEffect(() => {
setA11yFocusAfterInteractions(titleRef);
}, []);
// Listen for app state changes to re-check permissions when user returns from settings // Listen for app state changes to re-check permissions when user returns from settings
useEffect(() => { useEffect(() => {
const handleAppStateChange = async (nextAppState) => { const handleAppStateChange = async (nextAppState) => {
@ -197,6 +385,7 @@ export default function Permissions() {
const handleRequestPermission = async (permission) => { const handleRequestPermission = async (permission) => {
try { try {
let granted = false; let granted = false;
const previous = !!permissionsState?.[permission];
if (permission === "locationBackground") { if (permission === "locationBackground") {
// Ensure foreground location is granted first // Ensure foreground location is granted first
@ -223,6 +412,40 @@ export default function Permissions() {
// we'll re-check again on AppState 'active' after returning from Settings. // we'll re-check again on AppState 'active' after returning from Settings.
const actualStatus = await checkPermissionStatus(permission); const actualStatus = await checkPermissionStatus(permission);
setPermissions[permission](actualStatus); setPermissions[permission](actualStatus);
const meta = await getPermissionA11yMeta(permission);
setBlockedMap((prevMap) => ({
...prevMap,
[permission]: !!meta.blocked,
}));
// Announce only on changes or first explicit failure.
const lastKey = `${permission}:${String(actualStatus)}:${String(
meta.blocked,
)}`;
if (lastAnnouncementRef.current[permission] !== lastKey) {
if (actualStatus && !previous) {
await announceForA11yIfScreenReaderEnabled(
`${titlePermissions[permission]} : permission accordée.`,
);
lastAnnouncementRef.current[permission] = lastKey;
} else if (!actualStatus && previous) {
await announceForA11yIfScreenReaderEnabled(
`${titlePermissions[permission]} : permission retirée.`,
);
lastAnnouncementRef.current[permission] = lastKey;
} else if (!actualStatus && meta.blocked) {
await announceForA11yIfScreenReaderEnabled(
`${titlePermissions[permission]} : permission bloquée. Ouvrez les paramètres du téléphone.`,
);
lastAnnouncementRef.current[permission] = lastKey;
} else if (!actualStatus && !previous) {
await announceForA11yIfScreenReaderEnabled(
`${titlePermissions[permission]} : permission non accordée.`,
);
lastAnnouncementRef.current[permission] = lastKey;
}
}
} catch (error) { } catch (error) {
console.error(`Error requesting ${permission} permission:`, error); console.error(`Error requesting ${permission} permission:`, error);
} }
@ -230,20 +453,27 @@ export default function Permissions() {
return ( return (
<> <>
<Title style={styles.title}>Permissions</Title> <Title ref={titleRef} accessibilityRole="header" style={styles.title}>
Permissions
</Title>
<View style={styles.container}> <View style={styles.container}>
{Object.entries(permissionsState).map(([permission, status]) => ( {Object.entries(permissionsState).map(([permission, status]) => (
<PermissionItem <PermissionItem
key={permission} key={permission}
permission={permission} permission={permission}
status={status} status={status}
blocked={!!blockedMap[permission]}
onRequestPermission={handleRequestPermission} onRequestPermission={handleRequestPermission}
onOpenSettings={openSettings}
/> />
))} ))}
<Button <Button
mode="contained" mode="contained"
onPress={openSettings} onPress={openSettings}
style={styles.settingsButton} style={styles.settingsButton}
accessibilityRole="button"
accessibilityLabel="Paramétrer les permissions"
accessibilityHint="Ouvre les paramètres du téléphone pour gérer les autorisations de l'application."
> >
Paramétrer les permissions Paramétrer les permissions
</Button> </Button>
@ -274,5 +504,12 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
flex: 1, flex: 1,
}, },
blockedRow: {
marginTop: 8,
},
blockedText: {
fontSize: 14,
marginBottom: 6,
},
settingsButton: {}, settingsButton: {},
}); });

View file

@ -20,13 +20,19 @@ function SentryOptOut() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Title style={styles.title}>Rapport d'erreurs</Title> <Title accessibilityRole="header" style={styles.title}>
Rapport d'erreurs
</Title>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.switchContainer}> <View style={styles.switchContainer}>
<Text style={styles.label}>Envoyer les rapports d'erreurs</Text> <Text style={styles.label}>Envoyer les rapports d'erreurs</Text>
<Switch <Switch
value={sentryEnabled} value={sentryEnabled}
onValueChange={handleToggle} onValueChange={handleToggle}
accessibilityRole="switch"
accessibilityLabel="Envoyer les rapports d'erreurs"
accessibilityHint="Active ou désactive l'envoi automatique des rapports d'erreurs."
accessibilityState={{ checked: !!sentryEnabled }}
style={styles.switch} style={styles.switch}
/> />
</View> </View>

View file

@ -24,7 +24,9 @@ function ThemeSwitcher() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Title style={styles.title}>Thème</Title> <Title accessibilityRole="header" style={styles.title}>
Thème
</Title>
<View style={styles.buttonContainer}> <View style={styles.buttonContainer}>
{themeOptions.map((option) => ( {themeOptions.map((option) => (
<Button <Button
@ -32,8 +34,18 @@ function ThemeSwitcher() {
mode={colorScheme === option.value ? "contained" : "outlined"} mode={colorScheme === option.value ? "contained" : "outlined"}
onPress={() => handleThemeChange(option.value)} onPress={() => handleThemeChange(option.value)}
style={styles.button} style={styles.button}
accessibilityRole="button"
accessibilityLabel={`Thème ${option.label}`}
accessibilityHint={`Applique le thème ${option.label.toLowerCase()}.`}
accessibilityState={{ selected: colorScheme === option.value }}
icon={({ size, color }) => ( icon={({ size, color }) => (
<Ionicons name={option.icon} size={size} color={color} /> <Ionicons
accessible={false}
importantForAccessibility="no"
name={option.icon}
size={size}
color={color}
/>
)} )}
> >
{option.label} {option.label}

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect, useRef } from "react";
import { View } from "react-native"; import { View } from "react-native";
@ -31,6 +31,9 @@ export default function AccountManagement({
const modalState = useState({ visible: openAccountModal || false }); const modalState = useState({ visible: openAccountModal || false });
const [, setModal] = modalState; const [, setModal] = modalState;
const openConnectButtonRef = useRef(null);
const openDestroyButtonRef = useRef(null);
const openModal = useCallback( const openModal = useCallback(
(options = {}) => { (options = {}) => {
setModal({ visible: true, ...options }); setModal({ visible: true, ...options });
@ -76,6 +79,7 @@ export default function AccountManagement({
</View> </View>
<View style={{ marginVertical: 10 }}> <View style={{ marginVertical: 10 }}>
<Button <Button
ref={openConnectButtonRef}
mode="contained" mode="contained"
style={{ marginVertical: 5 }} style={{ marginVertical: 5 }}
icon={() => ( icon={() => (
@ -113,8 +117,11 @@ export default function AccountManagement({
)} )}
<Button <Button
ref={openDestroyButtonRef}
mode="contained" mode="contained"
style={{ marginVertical: 5, backgroundColor: custom.appColors.red }} style={{ marginVertical: 5, backgroundColor: custom.appColors.red }}
accessibilityLabel="Supprimer le compte"
accessibilityHint="Action irréversible. Ouvre une confirmation de suppression du compte"
labelStyle={{ labelStyle={{
flex: 1, flex: 1,
}} }}
@ -138,6 +145,10 @@ export default function AccountManagement({
profileData={profileData} profileData={profileData}
waitingSmsType={waitingSmsType} waitingSmsType={waitingSmsType}
clearAuthWaitParams={clearAuthWaitParams} clearAuthWaitParams={clearAuthWaitParams}
triggerRefs={{
connect: openConnectButtonRef,
destroy: openDestroyButtonRef,
}}
/> />
</View> </View>
); );

View file

@ -1,23 +1,30 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { Portal, Modal } from "react-native-paper"; import { Portal, Modal } from "react-native-paper";
import { useStyles } from "./styles"; import { useStyles } from "./styles";
import { setA11yFocusAfterInteractions } from "~/lib/a11y";
import AccountManagementModalConnect from "./AccountManagementModalConnect"; import AccountManagementModalConnect from "./AccountManagementModalConnect";
import AccountManagementModalDestroy from "./AccountManagementModalDestroy"; import AccountManagementModalDestroy from "./AccountManagementModalDestroy";
import AccountManagementModalImpersonate from "./AccountManagementModalImpersonate"; import AccountManagementModalImpersonate from "./AccountManagementModalImpersonate";
import Text from "~/components/Text";
export default function AccountManagementModal({ export default function AccountManagementModal({
modalState, modalState,
profileData, profileData,
waitingSmsType, waitingSmsType,
clearAuthWaitParams, clearAuthWaitParams,
triggerRefs,
}) { }) {
const styles = useStyles(); const styles = useStyles();
const [modal, setModal] = modalState; const [modal, setModal] = modalState;
const { visible, component } = modal; const { visible, component } = modal;
const [authMethod, setAuthMethod] = useState(false); const [authMethod, setAuthMethod] = useState(false);
const titleRef = useRef(null);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
setModal({ setModal({
visible: false, visible: false,
@ -25,13 +32,40 @@ export default function AccountManagementModal({
setAuthMethod(false); setAuthMethod(false);
}, [setModal]); }, [setModal]);
useEffect(() => {
if (visible) {
setA11yFocusAfterInteractions(titleRef);
}
}, [visible]);
useEffect(() => {
if (visible) return;
const triggerRef = triggerRefs?.[component];
if (triggerRef?.current) {
setA11yFocusAfterInteractions(triggerRef);
}
// Intentionally include `component` so we restore to the correct trigger.
}, [visible, component, triggerRefs]);
return ( return (
<Portal> <Portal>
<Modal <Modal
visible={visible} visible={visible}
onDismiss={closeModal} onDismiss={closeModal}
contentContainerStyle={styles.bottomModalContentContainer} contentContainerStyle={styles.bottomModalContentContainer}
accessibilityViewIsModal
> >
{/* Invisible header to control initial focus and SR context */}
<Text
ref={titleRef}
accessibilityRole="header"
style={{ height: 0, width: 0, opacity: 0 }}
>
{component === "destroy"
? "Supprimer le compte"
: "Se connecter à un compte"}
</Text>
{visible && component === "connect" && ( {visible && component === "connect" && (
<AccountManagementModalConnect <AccountManagementModalConnect
closeModal={closeModal} closeModal={closeModal}

View file

@ -23,6 +23,8 @@ import isConnectedProfile from "./isConnectedProfile";
import { getDeviceUuid } from "~/auth/deviceUuid"; import { getDeviceUuid } from "~/auth/deviceUuid";
import { announceForA11yIfScreenReaderEnabled } from "~/lib/a11y";
export default function AccountManagementModalConnect({ export default function AccountManagementModalConnect({
closeModal, closeModal,
profileData, profileData,
@ -108,6 +110,9 @@ export default function AccountManagementModalConnect({
closeModal(); closeModal();
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
announceForA11yIfScreenReaderEnabled(
"Erreur lors de la confirmation de connexion",
);
} }
}, [ }, [
loginConfirmRequest, loginConfirmRequest,

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useRef } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { Button } from "react-native-paper"; import { Button } from "react-native-paper";
@ -12,6 +12,11 @@ import { useTheme } from "~/theme";
import { DESTROY_USER_MUTATION } from "./gql"; import { DESTROY_USER_MUTATION } from "./gql";
import { authActions } from "~/stores"; import { authActions } from "~/stores";
import {
announceForA11yIfScreenReaderEnabled,
setA11yFocusAfterInteractions,
} from "~/lib/a11y";
import { ajvResolver } from "@hookform/resolvers/ajv"; import { ajvResolver } from "@hookform/resolvers/ajv";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
@ -48,6 +53,9 @@ export default function AccountManagementModalDestroy({
const validConfirmSentence = username; const validConfirmSentence = username;
const titleRef = useRef(null);
const confirmInputRef = useRef(null);
const methods = useForm({ const methods = useForm({
mode: "onTouched", mode: "onTouched",
defaultValues: { defaultValues: {
@ -81,6 +89,16 @@ export default function AccountManagementModalDestroy({
await authActions.logout(); await authActions.logout();
}, [deleteUser]); }, [deleteUser]);
useEffect(() => {
setA11yFocusAfterInteractions(titleRef);
}, []);
useEffect(() => {
if (!errors?.confirmSentence?.message) return;
announceForA11yIfScreenReaderEnabled(errors.confirmSentence.message);
setA11yFocusAfterInteractions(confirmInputRef);
}, [errors?.confirmSentence?.message]);
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<View <View
@ -96,6 +114,8 @@ export default function AccountManagementModalDestroy({
}} }}
> >
<Text <Text
ref={titleRef}
accessibilityRole="header"
style={{ style={{
fontSize: 16, fontSize: 16,
fontWeight: "bold", fontWeight: "bold",
@ -145,6 +165,7 @@ export default function AccountManagementModalDestroy({
<View style={{ flex: 1, flexDirection: "column", marginTop: 10 }}> <View style={{ flex: 1, flexDirection: "column", marginTop: 10 }}>
<FieldInputText <FieldInputText
ref={confirmInputRef}
style={styles.textInput} style={styles.textInput}
mode="outlined" mode="outlined"
label="Message de confirmation" label="Message de confirmation"
@ -175,6 +196,8 @@ export default function AccountManagementModalDestroy({
contentStyle={{ contentStyle={{
height: 60, height: 60,
}} }}
accessibilityLabel="Supprimer le compte"
accessibilityHint="Action irréversible"
> >
SUPPRIMER SUPPRIMER
</Button> </Button>

View file

@ -1,9 +1,9 @@
import React, { useCallback } from "react"; import React, { useCallback, useEffect, useRef } from "react";
import { Button, Portal, Modal, IconButton, Avatar } from "react-native-paper"; import { Button, Portal, Modal, IconButton, Avatar } from "react-native-paper";
import { View, Image } from "react-native"; import { View, Image } from "react-native";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import ImagePicker from "react-native-image-crop-picker"; import ImagePicker from "react-native-image-crop-picker";
import { createStyles, useTheme } from "~/theme"; import { useTheme } from "~/theme";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import ImageResizer from "@bam.tech/react-native-image-resizer"; import ImageResizer from "@bam.tech/react-native-image-resizer";
import { import {
@ -17,6 +17,8 @@ import bgColorBySeed from "~/lib/style/bg-color-by-seed";
import Text from "~/components/Text"; import Text from "~/components/Text";
import { setA11yFocusAfterInteractions } from "~/lib/a11y";
import network from "~/network"; import network from "~/network";
import { useStyles } from "./styles"; import { useStyles } from "./styles";
@ -44,12 +46,14 @@ const delOneAvatar = async () => {
await network.oaFilesKy.delete("avatar", {}); await network.oaFilesKy.delete("avatar", {});
}; };
export default function AvatarModalEdit({ modalState, userId }) { export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
const [modal, setModal] = modalState; const [modal, setModal] = modalState;
const { colors, custom } = useTheme(); const { colors } = useTheme();
const styles = useStyles(); const styles = useStyles();
const { watch, setValue } = useFormContext(); const { watch, setValue } = useFormContext();
const titleRef = useRef(null);
const username = watch("username"); const username = watch("username");
const defaultImage = watch("image"); const defaultImage = watch("image");
const tempImage = watch("tempImage"); const tempImage = watch("tempImage");
@ -127,6 +131,12 @@ export default function AvatarModalEdit({ modalState, userId }) {
}); });
}, [setValue, setModal]); }, [setValue, setModal]);
useEffect(() => {
if (modal.visible) {
setA11yFocusAfterInteractions(titleRef);
}
}, [modal.visible]);
const saveImage = useCallback(async () => { const saveImage = useCallback(async () => {
const imageMode = image?.mode || "text"; const imageMode = image?.mode || "text";
if (imageMode === "image" && image.localImage) { if (imageMode === "image" && image.localImage) {
@ -150,8 +160,13 @@ export default function AvatarModalEdit({ modalState, userId }) {
visible={modal.visible} visible={modal.visible}
onDismiss={closeModal} onDismiss={closeModal}
contentContainerStyle={styles.bottomModalContentContainer} contentContainerStyle={styles.bottomModalContentContainer}
accessibilityViewIsModal
>
<Text
ref={titleRef}
accessibilityRole="header"
style={{ fontSize: 16, fontWeight: "bold" }}
> >
<Text style={{ fontSize: 16, fontWeight: "bold" }}>
Photo de profil Photo de profil
</Text> </Text>
@ -165,6 +180,9 @@ export default function AvatarModalEdit({ modalState, userId }) {
borderRadius: 120, borderRadius: 120,
padding: 20, padding: 20,
}} }}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
/> />
)} )}
{imageMode === "text" && ( {imageMode === "text" && (
@ -182,6 +200,9 @@ export default function AvatarModalEdit({ modalState, userId }) {
right: -45, right: -45,
top: -35, top: -35,
}} }}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
/> />
</View> </View>
@ -206,6 +227,8 @@ export default function AvatarModalEdit({ modalState, userId }) {
iconColor={colors.primary} iconColor={colors.primary}
size={32} size={32}
onPress={() => getPicture("text")} onPress={() => getPicture("text")}
accessibilityLabel="Utiliser un avatar texte"
accessibilityHint="Affiche une lettre comme photo de profil"
/> />
<Text>Texte</Text> <Text>Texte</Text>
</View> </View>
@ -222,6 +245,8 @@ export default function AvatarModalEdit({ modalState, userId }) {
iconColor={colors.primary} iconColor={colors.primary}
size={32} size={32}
onPress={() => getPicture("camera")} onPress={() => getPicture("camera")}
accessibilityLabel="Prendre une photo"
accessibilityHint="Ouvre l'appareil photo"
/> />
<Text>Photo</Text> <Text>Photo</Text>
</View> </View>
@ -238,6 +263,8 @@ export default function AvatarModalEdit({ modalState, userId }) {
iconColor={colors.primary} iconColor={colors.primary}
size={32} size={32}
onPress={() => getPicture("library")} onPress={() => getPicture("library")}
accessibilityLabel="Choisir une photo dans la galerie"
accessibilityHint="Ouvre la galerie de photos"
/> />
<Text>Galerie</Text> <Text>Galerie</Text>
</View> </View>
@ -250,7 +277,15 @@ export default function AvatarModalEdit({ modalState, userId }) {
paddingTop: 20, paddingTop: 20,
}} }}
> >
<Button onPress={() => closeModal()} mode="contained"> <Button
onPress={() => {
closeModal();
if (triggerRef?.current) {
setA11yFocusAfterInteractions(triggerRef);
}
}}
mode="contained"
>
Annuler Annuler
</Button> </Button>
<Button onPress={saveImage} mode="contained"> <Button onPress={saveImage} mode="contained">

View file

@ -1,11 +1,10 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useRef, useState } from "react";
import { View, Image, TouchableWithoutFeedback } from "react-native"; import { View, Image, TouchableWithoutFeedback } from "react-native";
import { IconButton, Avatar } from "react-native-paper"; import { Avatar } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "~/theme"; import { useTheme } from "~/theme";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
// import Text from "~/components/Text"; // import Text from "~/components/Text";
import ImageResizer from "@bam.tech/react-native-image-resizer";
import AvatarModalEdit from "./AvatarModalEdit"; import AvatarModalEdit from "./AvatarModalEdit";
@ -13,8 +12,10 @@ import env from "~/env";
import bgColorBySeed from "~/lib/style/bg-color-by-seed"; import bgColorBySeed from "~/lib/style/bg-color-by-seed";
export default function AvatarUploader({ data, userId }) { export default function AvatarUploader({ data, userId }) {
const { colors, custom } = useTheme(); const { colors } = useTheme();
const { watch, setValue } = useFormContext(); const { watch } = useFormContext();
const triggerRef = useRef(null);
const username = watch("username"); const username = watch("username");
const image = watch("image"); const image = watch("image");
@ -47,8 +48,18 @@ export default function AvatarUploader({ data, userId }) {
return ( return (
<View> <View>
<View style={{ flexDirection: "column", alignItems: "center" }}> <View style={{ flexDirection: "column", alignItems: "center" }}>
<TouchableWithoutFeedback onPress={edit}> <TouchableWithoutFeedback
<View style={{ flexDirection: "column", alignItems: "center" }}> accessibilityRole="button"
accessibilityLabel="Modifier la photo de profil"
accessibilityHint="Ouvre les options pour changer votre photo de profil"
onPress={edit}
>
<View
// Attach the ref to a native view; TouchableWithoutFeedback does not
// reliably support refs across platforms.
ref={triggerRef}
style={{ flexDirection: "column", alignItems: "center" }}
>
{imageMode === "image" && imageSrc && ( {imageMode === "image" && imageSrc && (
<Image <Image
source={imageSrc} source={imageSrc}
@ -58,6 +69,9 @@ export default function AvatarUploader({ data, userId }) {
borderRadius: 120, borderRadius: 120,
padding: 20, padding: 20,
}} }}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
/> />
)} )}
{imageMode === "text" && ( {imageMode === "text" && (
@ -75,11 +89,18 @@ export default function AvatarUploader({ data, userId }) {
right: -45, right: -45,
top: -35, top: -35,
}} }}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
/> />
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
</View> </View>
<AvatarModalEdit modalState={modalState} userId={userId} /> <AvatarModalEdit
modalState={modalState}
userId={userId}
triggerRef={triggerRef}
/>
</View> </View>
); );
} }

View file

@ -12,11 +12,13 @@ import { ajvSchemaOptions } from "~/lib/ajv";
import useCheckEmailRegistered from "~/hooks/queries/useCheckEmailRegistered"; import useCheckEmailRegistered from "~/hooks/queries/useCheckEmailRegistered";
import { useTheme } from "~/theme"; import { useTheme } from "~/theme";
import { useStyles } from "./styles"; import { useStyles } from "./styles";
import { useCallback } from "react"; import { useCallback, useEffect } from "react";
import { SEND_CONNECTION_EMAIL_MUTATION } from "./gql"; import { SEND_CONNECTION_EMAIL_MUTATION } from "./gql";
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { announceForA11yIfScreenReaderEnabled } from "~/lib/a11y";
const schema = { const schema = {
type: "object", type: "object",
properties: { properties: {
@ -78,6 +80,11 @@ export default function ConnectViaEmail() {
[checkEmailIsRegistered, clearErrors, sendConnectionEmail, setError], [checkEmailIsRegistered, clearErrors, sendConnectionEmail, setError],
); );
useEffect(() => {
if (!errors?.email?.message) return;
announceForA11yIfScreenReaderEnabled(errors.email.message);
}, [errors?.email?.message]);
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<View style={{ flex: 1, flexDirection: "column" }}> <View style={{ flex: 1, flexDirection: "column" }}>
@ -87,6 +94,7 @@ export default function ConnectViaEmail() {
label="Email" label="Email"
name="email" name="email"
error={errors.email} error={errors.email}
errorMessage={errors.email?.message}
mode="outlined" mode="outlined"
autoFocus autoFocus
/> />

View file

@ -30,6 +30,8 @@ import Identification from "./Identification";
import useCheckEmailRegistered from "~/hooks/queries/useCheckEmailRegistered"; import useCheckEmailRegistered from "~/hooks/queries/useCheckEmailRegistered";
import { announceForA11yIfScreenReaderEnabled } from "~/lib/a11y";
const schema = { const schema = {
type: "object", type: "object",
properties: { properties: {
@ -86,6 +88,17 @@ export default function Form({
formState: { isDirty }, formState: { isDirty },
} = methods; } = methods;
useEffect(() => {
const firstErrorMessage = methods.formState?.errors
? Object.values(methods.formState.errors)
.map((e) => e?.message)
.find(Boolean)
: null;
if (!firstErrorMessage) return;
announceForA11yIfScreenReaderEnabled(firstErrorMessage);
}, [methods.formState?.errors]);
useEffect(() => { useEffect(() => {
if (!getValues("username") && username) { if (!getValues("username") && username) {
setFieldValue(username); setFieldValue(username);

View file

@ -1,4 +1,4 @@
import React, { useCallback } from "react"; import React, { useCallback, useEffect } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { Button, TextInput } from "react-native-paper"; import { Button, TextInput } from "react-native-paper";
@ -18,6 +18,8 @@ import { useFormContext } from "react-hook-form";
import { RESEND_VERIFICATION_EMAIL_MUTATION } from "./gql"; import { RESEND_VERIFICATION_EMAIL_MUTATION } from "./gql";
import { announceForA11yIfScreenReaderEnabled } from "~/lib/a11y";
export default function Identification({ profileData }) { export default function Identification({ profileData }) {
const styles = useStyles(); const styles = useStyles();
const { colors } = useTheme(); const { colors } = useTheme();
@ -39,6 +41,11 @@ export default function Identification({ profileData }) {
}, },
}); });
}, [resendVerificationEmail, email]); }, [resendVerificationEmail, email]);
useEffect(() => {
if (!errors?.email?.message) return;
announceForA11yIfScreenReaderEnabled(errors.email.message);
}, [errors?.email?.message]);
return ( return (
<View> <View>
<View> <View>
@ -46,6 +53,8 @@ export default function Identification({ profileData }) {
style={styles.textInput} style={styles.textInput}
label="Nom d'utilisateur" label="Nom d'utilisateur"
name="username" name="username"
accessibilityLabel="Nom d'utilisateur"
accessibilityHint="Modifier votre nom d'utilisateur"
/> />
</View> </View>
<View> <View>
@ -67,6 +76,9 @@ export default function Identification({ profileData }) {
label="Email" label="Email"
name="email" name="email"
error={errors.email} error={errors.email}
accessibilityLabel="Email"
accessibilityHint="Modifier votre adresse email"
errorMessage={errors.email?.message}
/> />
{emailInput && errors.email && emailInput !== email && ( {emailInput && errors.email && emailInput !== email && (
<View style={{}}> <View style={{}}>

View file

@ -12,6 +12,9 @@ export default function ContributeButton() {
return ( return (
<View> <View>
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Contribuer au projet"
accessibilityHint="Ouvre l'écran pour contribuer au projet."
style={styles.button} style={styles.button}
onPress={() => navigation.navigate("Contribute")} onPress={() => navigation.navigate("Contribute")}
> >

View file

@ -5,7 +5,7 @@ import Text from "~/components/Text";
export default function HelpBlock({ children, style, labelStyle, ...props }) { export default function HelpBlock({ children, style, labelStyle, ...props }) {
return ( return (
<View style={[style]} {...props}> <View accessible accessibilityRole="text" style={[style]} {...props}>
<Text style={[labelStyle]}>{children}</Text> <Text style={[labelStyle]}>{children}</Text>
</View> </View>
); );

View file

@ -58,7 +58,9 @@ export default function NotificationsButton({ flex = 1 }) {
? `Notifications - ${newCount} nouvelles notifications` ? `Notifications - ${newCount} nouvelles notifications`
: "Notifications" : "Notifications"
} }
accessibilityHint="Ouvre l'écran des notifications."
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ selected: hasNewNotifications }}
onPress={() => navigation.navigate("Notifications")} onPress={() => navigation.navigate("Notifications")}
> >
<MaterialIcons <MaterialIcons

View file

@ -1,20 +1,23 @@
import React from "react"; import React, { forwardRef } from "react";
import { View, Platform } from "react-native"; import { View, Platform } from "react-native";
import { IconButton } from "react-native-paper"; import { IconButton } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import { createStyles } from "~/theme"; import { createStyles } from "~/theme";
export default function RadarButton({ const RadarButton = forwardRef(function RadarButton(
onPress, { onPress, isLoading = false, isExpanded = false, flex = 0.22 },
isLoading = false, ref,
flex = 0.22, ) {
}) {
const styles = useStyles(); const styles = useStyles();
return ( return (
<View style={[styles.container, { flex }]}> <View style={[styles.container, { flex }]}>
<IconButton <IconButton
ref={ref}
accessibilityLabel="Radar - Voir les utilisateurs Alerte-Secours prêts à porter secours aux alentours" accessibilityLabel="Radar - Voir les utilisateurs Alerte-Secours prêts à porter secours aux alentours"
accessibilityHint="Affiche les utilisateurs prêts à porter secours à proximité."
accessibilityRole="button"
accessibilityState={{ disabled: isLoading, expanded: isExpanded }}
mode="contained" mode="contained"
size={24} size={24}
style={styles.button} style={styles.button}
@ -31,7 +34,9 @@ export default function RadarButton({
/> />
</View> </View>
); );
} });
export default RadarButton;
const useStyles = createStyles(({ wp, hp, theme: { colors, custom } }) => ({ const useStyles = createStyles(({ wp, hp, theme: { colors, custom } }) => ({
container: { container: {

View file

@ -1,8 +1,9 @@
import React from "react"; import React, { useEffect, useRef } from "react";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { Modal, Portal, Button, ActivityIndicator } from "react-native-paper"; import { Modal, Portal, Button, ActivityIndicator } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import { createStyles, useTheme } from "~/theme"; import { createStyles, useTheme } from "~/theme";
import { setA11yFocusAfterInteractions } from "~/lib/a11y";
export default function RadarModal({ export default function RadarModal({
visible, visible,
@ -14,6 +15,13 @@ export default function RadarModal({
const { colors } = useTheme(); const { colors } = useTheme();
const styles = useStyles(); const styles = useStyles();
const titleRef = useRef(null);
useEffect(() => {
if (!visible) return;
setA11yFocusAfterInteractions(titleRef);
}, [visible]);
const renderContent = () => { const renderContent = () => {
if (isLoading) { if (isLoading) {
return ( return (
@ -78,7 +86,13 @@ export default function RadarModal({
color={colors.primary} color={colors.primary}
style={styles.modalIcon} style={styles.modalIcon}
/> />
<Text style={styles.modalTitle}>Utilisateurs aux alentours</Text> <Text
ref={titleRef}
accessibilityRole="header"
style={styles.modalTitle}
>
Utilisateurs aux alentours
</Text>
</View> </View>
<View style={styles.content}> <View style={styles.content}>
@ -89,6 +103,9 @@ export default function RadarModal({
mode="contained" mode="contained"
onPress={onDismiss} onPress={onDismiss}
style={styles.closeButton} style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel="Fermer"
accessibilityHint="Ferme la fenêtre radar et revient à l'écran d'alerte."
> >
Fermer Fermer
</Button> </Button>

View file

@ -65,6 +65,9 @@ export default function RegisterRelativesButton() {
return ( return (
<Animated.View style={{ opacity: fadeAnim }}> <Animated.View style={{ opacity: fadeAnim }}>
<TouchableOpacity <TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Enregistrez vos contacts d'urgence"
accessibilityHint="Ouvre l'écran pour enregistrer vos contacts d'urgence."
style={styles.button} style={styles.button}
onPress={() => navigation.navigate("Relatives")} onPress={() => navigation.navigate("Relatives")}
> >

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback, useEffect, useRef } from "react";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useNavigation, CommonActions } from "@react-navigation/native"; import { useNavigation, CommonActions } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
@ -17,6 +17,10 @@ import RadarButton from "./RadarButton";
import RadarModal from "./RadarModal"; import RadarModal from "./RadarModal";
import TopButtonsBar from "./TopButtonsBar"; import TopButtonsBar from "./TopButtonsBar";
import useRadarData from "~/hooks/useRadarData"; import useRadarData from "~/hooks/useRadarData";
import {
announceForA11yIfScreenReaderEnabled,
setA11yFocusAfterInteractions,
} from "~/lib/a11y";
export default function SendAlert() { export default function SendAlert() {
const navigation = useNavigation(); const navigation = useNavigation();
@ -26,6 +30,9 @@ export default function SendAlert() {
const [helpVisible, setHelpVisible] = useState(false); const [helpVisible, setHelpVisible] = useState(false);
const [radarModalVisible, setRadarModalVisible] = useState(false); const [radarModalVisible, setRadarModalVisible] = useState(false);
const radarButtonRef = useRef(null);
const radarAnnouncementsRef = useRef({ loading: false, resultKey: null });
const { const {
data: radarData, data: radarData,
isLoading: radarIsLoading, isLoading: radarIsLoading,
@ -35,9 +42,15 @@ export default function SendAlert() {
hasLocation, hasLocation,
} = useRadarData(); } = useRadarData();
function toggleHelp() { const toggleHelp = useCallback(() => {
setHelpVisible(!helpVisible); setHelpVisible((prev) => {
} const next = !prev;
announceForA11yIfScreenReaderEnabled(
next ? "Aide affichée." : "Aide masquée.",
);
return next;
});
}, []);
const handleRadarPress = useCallback(() => { const handleRadarPress = useCallback(() => {
if (!hasLocation) { if (!hasLocation) {
@ -51,8 +64,47 @@ export default function SendAlert() {
const handleRadarModalClose = useCallback(() => { const handleRadarModalClose = useCallback(() => {
setRadarModalVisible(false); setRadarModalVisible(false);
resetRadarData(); resetRadarData();
setA11yFocusAfterInteractions(radarButtonRef);
}, [resetRadarData]); }, [resetRadarData]);
useEffect(() => {
if (!radarModalVisible) {
radarAnnouncementsRef.current = { loading: false, resultKey: null };
return;
}
const state = radarAnnouncementsRef.current;
if (radarIsLoading && !state.loading) {
state.loading = true;
state.resultKey = null;
announceForA11yIfScreenReaderEnabled("Recherche en cours.");
return;
}
if (radarIsLoading) return;
state.loading = false;
if (radarError && state.resultKey !== "error") {
state.resultKey = "error";
announceForA11yIfScreenReaderEnabled("Erreur lors de la recherche.");
return;
}
const count = radarData?.count;
if (typeof count === "number") {
const key = `success:${count}`;
if (state.resultKey !== key) {
state.resultKey = key;
let message = `${count} utilisateurs disponibles à proximité.`;
if (count === 0) message = "Aucun utilisateur disponible à proximité.";
if (count === 1) message = "1 utilisateur disponible à proximité.";
announceForA11yIfScreenReaderEnabled(message);
}
}
}, [radarModalVisible, radarIsLoading, radarError, radarData?.count]);
const navigateTo = useCallback( const navigateTo = useCallback(
(navOpts) => (navOpts) =>
navigation.dispatch({ navigation.dispatch({
@ -102,16 +154,30 @@ export default function SendAlert() {
<TopButtonsBar> <TopButtonsBar>
<NotificationsButton flex={0.78} /> <NotificationsButton flex={0.78} />
<RadarButton <RadarButton
ref={radarButtonRef}
onPress={handleRadarPress} onPress={handleRadarPress}
isLoading={radarIsLoading} isLoading={radarIsLoading}
isExpanded={radarModalVisible}
flex={0.22} flex={0.22}
/> />
</TopButtonsBar> </TopButtonsBar>
<View style={styles.head}> <View style={styles.head}>
<Title style={styles.title}>Quelle est votre situation ?</Title> <Title style={styles.title} accessibilityRole="header">
Quelle est votre situation ?
</Title>
<IconButton <IconButton
accessibilityLabel={"aide"} accessibilityRole="button"
accessibilityLabel="Aide"
accessibilityHint={
helpVisible
? "Masque les explications."
: "Affiche des explications pour choisir votre situation."
}
accessibilityState={{
expanded: helpVisible,
selected: helpVisible,
}}
style={[styles.helpBtn]} style={[styles.helpBtn]}
onPress={toggleHelp} onPress={toggleHelp}
size={styles.helpBtn.fontSize} size={styles.helpBtn.fontSize}
@ -131,7 +197,10 @@ export default function SendAlert() {
<View style={styles.buttonsContainer}> <View style={styles.buttonsContainer}>
<Button <Button
testID="send-alert-cta-red"
accessibilityLabel={levelLabel.red} accessibilityLabel={levelLabel.red}
accessibilityHint="Ouvre la confirmation pour envoyer une alerte rouge."
accessibilityRole="button"
mode="contained" mode="contained"
style={[styles.button, styles.btnRed]} style={[styles.button, styles.btnRed]}
contentStyle={[styles.buttonContent]} contentStyle={[styles.buttonContent]}
@ -159,7 +228,10 @@ export default function SendAlert() {
)} )}
<Button <Button
testID="send-alert-cta-yellow"
accessibilityLabel={levelLabel.yellow} accessibilityLabel={levelLabel.yellow}
accessibilityHint="Ouvre la confirmation pour envoyer une alerte jaune."
accessibilityRole="button"
mode="contained" mode="contained"
style={[styles.button, styles.btnYellow]} style={[styles.button, styles.btnYellow]}
contentStyle={[styles.buttonContent]} contentStyle={[styles.buttonContent]}
@ -187,7 +259,10 @@ export default function SendAlert() {
)} )}
<Button <Button
testID="send-alert-cta-green"
accessibilityLabel={levelLabel.green} accessibilityLabel={levelLabel.green}
accessibilityHint="Ouvre la confirmation pour envoyer une alerte verte."
accessibilityRole="button"
mode="contained" mode="contained"
style={[styles.button, styles.btnGreen]} style={[styles.button, styles.btnGreen]}
contentStyle={[styles.buttonContent]} contentStyle={[styles.buttonContent]}
@ -215,6 +290,10 @@ export default function SendAlert() {
)} )}
<Button <Button
testID="send-alert-cta-unknown"
accessibilityRole="button"
accessibilityLabel={levelLabel.unkown}
accessibilityHint="Ouvre l'assistant pour choisir votre situation."
mode="contained" mode="contained"
style={[styles.button, styles.btnUnkown]} style={[styles.button, styles.btnUnkown]}
contentStyle={[styles.buttonContent]} contentStyle={[styles.buttonContent]}
@ -234,6 +313,10 @@ export default function SendAlert() {
</Button> </Button>
<Button <Button
testID="send-alert-cta-call"
accessibilityRole="button"
accessibilityLabel={levelLabel.call}
accessibilityHint="Ouvre la confirmation pour appeler les secours."
mode="contained" mode="contained"
style={[styles.button, styles.btnCall]} style={[styles.button, styles.btnCall]}
contentStyle={[styles.buttonContent]} contentStyle={[styles.buttonContent]}

View file

@ -58,7 +58,10 @@ export default function FieldConfirm({ style, autoConfirmEnabled, confirmed }) {
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
{autoConfirmEnabled && autoConfirmVisible && ( {autoConfirmEnabled && autoConfirmVisible && (
<Animatable.View ref={autoConfirmViewRef}> <Animatable.View ref={autoConfirmViewRef}>
<TouchableWithoutFeedback onPress={cancelAutoConfirm}> <TouchableWithoutFeedback
accessibilityRole="button"
onPress={cancelAutoConfirm}
>
<View style={styles.countDownContainer}> <View style={styles.countDownContainer}>
<Text style={styles.countDownLabel}> <Text style={styles.countDownLabel}>
{`Confirmation\nautomatique`} {`Confirmation\nautomatique`}

View file

@ -3214,6 +3214,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.15.4":
version: 7.28.4
resolution: "@babel/runtime@npm:7.28.4"
checksum: 10/6c9a70452322ea80b3c9b2a412bcf60771819213a67576c8cec41e88a95bb7bf01fc983754cda35dc19603eef52df22203ccbf7777b9d6316932f9fb77c25163
languageName: node
linkType: hard
"@babel/runtime@npm:^7.21.0": "@babel/runtime@npm:^7.21.0":
version: 7.23.9 version: 7.23.9
resolution: "@babel/runtime@npm:7.23.9" resolution: "@babel/runtime@npm:7.23.9"
@ -7053,6 +7060,7 @@ __metadata:
eslint-plugin-react: "npm:^7.32.2" eslint-plugin-react: "npm:^7.32.2"
eslint-plugin-react-hooks: "npm:^4.6.0" eslint-plugin-react-hooks: "npm:^4.6.0"
eslint-plugin-react-native: "npm:^4.0.0" eslint-plugin-react-native: "npm:^4.0.0"
eslint-plugin-react-native-a11y: "npm:^3.5.1"
eslint-plugin-sort-keys-fix: "npm:^1.1.2" eslint-plugin-sort-keys-fix: "npm:^1.1.2"
eslint-plugin-unused-imports: "npm:^3.0.0" eslint-plugin-unused-imports: "npm:^3.0.0"
eventemitter3: "npm:^5.0.1" eventemitter3: "npm:^5.0.1"
@ -10197,6 +10205,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eslint-plugin-react-native-a11y@npm:^3.5.1":
version: 3.5.1
resolution: "eslint-plugin-react-native-a11y@npm:3.5.1"
dependencies:
"@babel/runtime": "npm:^7.15.4"
ast-types-flow: "npm:^0.0.7"
jsx-ast-utils: "npm:^3.2.1"
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
checksum: 10/5f676d5d6658abc863137dd34b0394fc320539a04dd1445404c84e6b706d28d8e6f4cceb5189d66836bfbce132da77010c9214879fdd0b01f368d84baef37f0b
languageName: node
linkType: hard
"eslint-plugin-react-native-globals@npm:^0.1.1": "eslint-plugin-react-native-globals@npm:^0.1.1":
version: 0.1.2 version: 0.1.2
resolution: "eslint-plugin-react-native-globals@npm:0.1.2" resolution: "eslint-plugin-react-native-globals@npm:0.1.2"
@ -13727,6 +13748,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jsx-ast-utils@npm:^3.2.1":
version: 3.3.5
resolution: "jsx-ast-utils@npm:3.3.5"
dependencies:
array-includes: "npm:^3.1.6"
array.prototype.flat: "npm:^1.3.1"
object.assign: "npm:^4.1.4"
object.values: "npm:^1.1.6"
checksum: 10/b61d44613687dfe4cc8ad4b4fbf3711bf26c60b8d5ed1f494d723e0808415c59b24a7c0ed8ab10736a40ff84eef38cbbfb68b395e05d31117b44ffc59d31edfc
languageName: node
linkType: hard
"jwt-decode@npm:^3.1.2": "jwt-decode@npm:^3.1.2":
version: 3.1.2 version: 3.1.2
resolution: "jwt-decode@npm:3.1.2" resolution: "jwt-decode@npm:3.1.2"