Compare commits
No commits in common. "29d7747b51d333d9b592eab31c800fcdf4d808a5" and "0cf1139f9b3ce953aeb6a48274dfc58ca500dfdf" have entirely different histories.
29d7747b51
...
0cf1139f9b
91 changed files with 498 additions and 2769 deletions
|
|
@ -40,7 +40,7 @@ module.exports = {
|
||||||
emulator: {
|
emulator: {
|
||||||
type: 'android.emulator',
|
type: 'android.emulator',
|
||||||
device: {
|
device: {
|
||||||
avdName: process.env.ANDROID_EMULATOR_NAME || 'Medium_Phone_API_36.0'
|
avdName: process.env.ANDROID_EMULATOR_NAME || 'Pixel_6_API_30'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ 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",
|
||||||
],
|
],
|
||||||
|
|
@ -40,7 +39,7 @@ module.exports = {
|
||||||
typescript: {},
|
typescript: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ignorePatterns: ["build", "node_modules", "e2e", "**/*.bak.js"],
|
ignorePatterns: ["build", "node_modules", "e2e"],
|
||||||
rules: {
|
rules: {
|
||||||
"no-undef": [2],
|
"no-undef": [2],
|
||||||
"react/forbid-prop-types": [0],
|
"react/forbid-prop-types": [0],
|
||||||
|
|
@ -48,12 +47,6 @@ 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
|
||||||
|
|
|
||||||
3
.github/workflows/ci-cd-ios.yaml
vendored
3
.github/workflows/ci-cd-ios.yaml
vendored
|
|
@ -30,9 +30,6 @@ 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
1
.vscode/settings.json
vendored
|
|
@ -1 +0,0 @@
|
||||||
{}
|
|
||||||
30
DEVELOPER.md
30
DEVELOPER.md
|
|
@ -11,7 +11,6 @@ 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
|
||||||
|
|
@ -198,35 +197,6 @@ 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 don’t 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
|
||||||
|
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
# 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 -->
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
# 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"`.
|
|
||||||
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"setupFilesAfterEnv": ["./e2e/init.js"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
describe("App Initialization", () => {
|
describe("App Initialization", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await device.launchApp({ newInstance: true });
|
await device.launchApp();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
@ -10,4 +10,14 @@ 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
// Shared Jest/Detox init for e2e suites.
|
|
||||||
// Keep this file side-effect-only and dependency-free.
|
|
||||||
|
|
||||||
jest.setTimeout(120000);
|
|
||||||
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
// Detox template placeholder.
|
// describe('Example', () => {
|
||||||
// Keep a real test here so `yarn test` (Jest) doesn't fail with:
|
// beforeAll(async () => {
|
||||||
// "Your test suite must contain at least one test."
|
// await device.launchApp();
|
||||||
|
// });
|
||||||
|
|
||||||
describe("e2e starter placeholder", () => {
|
// beforeEach(async () => {
|
||||||
it("is a placeholder", () => {
|
// await device.reloadReactNative();
|
||||||
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();
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
|
||||||
32
index.js
32
index.js
|
|
@ -2,6 +2,9 @@
|
||||||
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";
|
||||||
|
|
@ -15,6 +18,9 @@ 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);
|
||||||
|
|
@ -23,3 +29,29 @@ 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);
|
||||||
|
// }
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,6 @@
|
||||||
"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",
|
||||||
|
|
@ -274,10 +273,10 @@
|
||||||
"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": "Medium_Phone_API_36.0"
|
"avdName": "Pixel_6_API_30"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.5.3"
|
"packageManager": "yarn@4.5.3"
|
||||||
}
|
}
|
||||||
|
|
@ -22,10 +22,7 @@ class Bubble extends React.PureComponent {
|
||||||
|
|
||||||
if (this.props.onPress) {
|
if (this.props.onPress) {
|
||||||
innerChildView = (
|
innerChildView = (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={this.props.onPress}>
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={this.props.onPress}
|
|
||||||
>
|
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,7 @@ 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
|
<TouchableOpacity style={styles.button} onPress={retryConnect}>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,7 @@ 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
|
<TouchableOpacity style={styles.button} onPress={retryConnect}>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ const CustomButton = ({
|
||||||
contentStyle,
|
contentStyle,
|
||||||
labelStyle,
|
labelStyle,
|
||||||
mode = "contained",
|
mode = "contained",
|
||||||
selected,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
@ -16,27 +15,9 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -3,74 +3,20 @@ 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
|
<View style={[styles.container, style]}>
|
||||||
style={[
|
<Text style={[styles.label, labelStyle]}>{label}</Text>
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -82,9 +28,6 @@ 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,
|
||||||
|
|
@ -94,10 +37,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,12 @@ const defaulStyle = {
|
||||||
fontFamily,
|
fontFamily,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppText({
|
export default function AppText({ style = {}, ...props }) {
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -7,37 +7,19 @@ 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(() => {
|
||||||
// Only auto-focus once per screen visit; re-focusing later can cause
|
if (autoFocus && textInputRef.current) {
|
||||||
// unwanted focus jumps for screen reader users.
|
|
||||||
if (autoFocus && textInputRef.current && !didAutoFocusRef.current) {
|
|
||||||
didAutoFocusRef.current = true;
|
|
||||||
textInputRef.current.focus();
|
textInputRef.current.focus();
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
@ -47,7 +29,7 @@ export default React.memo(function TextArea({
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
setKeyboardEnabled(false);
|
setKeyboardEnabled(false);
|
||||||
};
|
};
|
||||||
}, [autoFocus]),
|
}, [textInputRef, autoFocus]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onBlur = useCallback(() => {
|
const onBlur = useCallback(() => {
|
||||||
|
|
@ -56,15 +38,8 @@ export default React.memo(function TextArea({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!keyboardEnabled && autoFocus && !isFocused) {
|
||||||
!keyboardEnabled &&
|
|
||||||
autoFocus &&
|
|
||||||
!isFocused &&
|
|
||||||
textInputRef.current &&
|
|
||||||
!didAutoFocusRef.current
|
|
||||||
) {
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
didAutoFocusRef.current = true;
|
|
||||||
textInputRef.current?.focus();
|
textInputRef.current?.focus();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
@ -72,15 +47,12 @@ 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={setRefs}
|
ref={textInputRef}
|
||||||
textAlignVertical="center"
|
textAlignVertical="center"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
showSoftInputOnFocus={keyboardEnabled} // controlled by state
|
showSoftInputOnFocus={keyboardEnabled} // controlled by state
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ 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",
|
||||||
|
|
@ -140,7 +139,6 @@ 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",
|
||||||
|
|
@ -155,9 +153,6 @@ 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(() => {
|
||||||
|
|
@ -197,15 +192,8 @@ 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]);
|
||||||
|
|
||||||
|
|
@ -283,12 +271,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -308,12 +290,6 @@ 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]);
|
||||||
|
|
||||||
|
|
@ -353,26 +329,13 @@ 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 () => {
|
||||||
|
|
@ -426,16 +389,10 @@ 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}
|
||||||
|
|
@ -449,60 +406,29 @@ export default React.memo(function ChatInput({
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{mode === MODE.RECORDING && (
|
{mode === MODE.RECORDING && (
|
||||||
<View
|
<View style={styles.countdownContainer}>
|
||||||
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
|
<Text style={styles.countdownText}>
|
||||||
style={styles.countdownText}
|
|
||||||
accessible={false}
|
|
||||||
importantForAccessibility="no"
|
|
||||||
>
|
|
||||||
{seconds || RECORDING_TIMEOUT}
|
{seconds || RECORDING_TIMEOUT}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text style={styles.countdownSubtitle}>
|
||||||
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
|
hasText ? "envoyer le message" : "enregistrer un message audio"
|
||||||
? "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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useMemo } from "react";
|
import React 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,15 +36,6 @@ 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 (
|
||||||
|
|
@ -54,19 +45,7 @@ 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} />
|
||||||
|
|
@ -77,16 +56,12 @@ 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}>
|
||||||
|
|
@ -126,8 +101,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState, useMemo } 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, useSessionState } from "~/stores";
|
import { alertActions, aggregatedMessagesActions } 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";
|
||||||
|
|
||||||
|
|
@ -20,20 +18,12 @@ 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]);
|
||||||
|
|
@ -57,30 +47,11 @@ 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, sessionUserId]);
|
}, [messages, isAtBottom, scrollToBottom, lastMessageId]);
|
||||||
|
|
||||||
const messagesLength = messages.length;
|
const messagesLength = messages.length;
|
||||||
|
|
||||||
|
|
@ -105,6 +76,12 @@ 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);
|
||||||
|
|
@ -162,8 +139,6 @@ 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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,14 @@
|
||||||
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({
|
||||||
function FieldInputText(
|
name,
|
||||||
{ name, shouldDirty = true, error, errorMessage, ...inputProps },
|
shouldDirty = true,
|
||||||
ref,
|
error,
|
||||||
) {
|
...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 });
|
||||||
|
|
||||||
|
|
@ -36,17 +25,12 @@ 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);
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from "react";
|
import React, { useEffect, useState } 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,11 +24,6 @@ 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`}
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,6 @@ 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} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -56,9 +53,6 @@ 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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,6 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,6 @@ 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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -104,13 +104,7 @@ export default function ToogleZoomButtonGroup({ boundType, setBoundType }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={styles.boundTypeButtonGroup}>
|
<View style={styles.boundTypeButtonGroup}>
|
||||||
<ToggleButton.Group
|
<ToggleButton.Group onValueChange={zoomToggle} value={boundType}>
|
||||||
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}
|
||||||
|
|
|
||||||
|
|
@ -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,22 +9,23 @@ export default function MapLinksPopupIconButton({
|
||||||
}) {
|
}) {
|
||||||
const { colors, custom } = useTheme();
|
const { colors, custom } = useTheme();
|
||||||
return (
|
return (
|
||||||
<IconTouchTarget
|
<ToggleButton
|
||||||
accessibilityLabel="Ouvrir dans une application de navigation"
|
mode="contained"
|
||||||
accessibilityHint="Ouvre un choix d'applications pour naviguer vers l'emplacement."
|
|
||||||
onPress={() => setIsVisible(true)}
|
onPress={() => setIsVisible(true)}
|
||||||
style={({ pressed }) => ({
|
icon={() => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="arrow-top-right-bold-box-outline"
|
||||||
|
size={24}
|
||||||
|
color={colors.onSurface}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
borderRadius: 4,
|
color: colors.onSurface,
|
||||||
opacity: pressed ? 0.7 : 1,
|
}}
|
||||||
})}
|
|
||||||
{...extraProps}
|
{...extraProps}
|
||||||
>
|
/>
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="arrow-top-right-bold-box-outline"
|
|
||||||
size={24}
|
|
||||||
color={colors.onSurface}
|
|
||||||
/>
|
|
||||||
</IconTouchTarget>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
|
@ -17,10 +17,6 @@ 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,
|
||||||
|
|
@ -57,8 +53,6 @@ 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);
|
||||||
|
|
@ -122,34 +116,11 @@ 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));
|
||||||
|
|
@ -157,23 +128,6 @@ 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);
|
||||||
|
|
@ -289,10 +243,6 @@ const HeroMode = () => {
|
||||||
}
|
}
|
||||||
}, [hasAttempted, allGranted, handleNext]);
|
}, [hasAttempted, allGranted, handleNext]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setA11yFocusAfterInteractions(titleRef);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
const renderWarnings = () => {
|
const renderWarnings = () => {
|
||||||
|
|
@ -398,8 +348,6 @@ 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>
|
||||||
|
|
@ -453,8 +401,6 @@ 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>
|
||||||
|
|
@ -536,14 +482,8 @@ 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
|
<Title style={[styles.title, { color: theme.colors.primary }]}>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useRef } from "react";
|
import React 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,16 +6,10 @@ 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);
|
||||||
|
|
@ -38,15 +32,9 @@ 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
|
<Title style={[styles.title, { color: theme.colors.primary }]}>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useRef } from "react";
|
import React, { useCallback } 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,21 +7,15 @@ 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 }]}
|
||||||
|
|
@ -37,14 +31,8 @@ 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
|
<Title style={[styles.title, { color: theme.colors.primary }]}>
|
||||||
ref={titleRef}
|
|
||||||
accessibilityRole="header"
|
|
||||||
style={[styles.title, { color: theme.colors.primary }]}
|
|
||||||
>
|
|
||||||
Vous voilà prêt !
|
Vous voilà prêt !
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
import React, { useState, useCallback, useEffect } 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,10 +10,6 @@ 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";
|
||||||
|
|
@ -25,8 +21,6 @@ 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",
|
||||||
|
|
@ -54,24 +48,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -93,10 +69,6 @@ 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) {
|
||||||
|
|
@ -128,8 +100,6 @@ 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>
|
||||||
|
|
@ -146,20 +116,13 @@ const Welcome = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomButton
|
<CustomButton mode="contained" onPress={handleNext}>
|
||||||
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>
|
||||||
|
|
@ -197,14 +160,8 @@ 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
|
<Title style={[styles.title, { color: theme.colors.primary }]}>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,6 @@ 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 />
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export {
|
|
||||||
announceForA11y,
|
|
||||||
announceForA11yIfScreenReaderEnabled,
|
|
||||||
} from "./announce";
|
|
||||||
|
|
||||||
export { setA11yFocus, setA11yFocusAfterInteractions } from "./focus";
|
|
||||||
|
|
@ -6,7 +6,6 @@ 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;
|
||||||
|
|
@ -139,13 +138,6 @@ 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(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -273,10 +265,6 @@ 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",
|
||||||
|
|
@ -302,22 +290,6 @@ 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={[
|
||||||
|
|
@ -328,10 +300,6 @@ 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={{
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -4,9 +4,6 @@ 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;
|
||||||
|
|
@ -46,8 +43,6 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -20,10 +17,6 @@ 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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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";
|
||||||
|
|
@ -19,406 +20,304 @@ import { storeLocation } from "~/location/storage";
|
||||||
|
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
|
|
||||||
import {
|
// Common config: keep always-on tracking enabled, but default to an IDLE low-power profile.
|
||||||
BASE_GEOLOCATION_CONFIG,
|
// High-accuracy and "moving" mode are only enabled when an active alert is open.
|
||||||
TRACKING_PROFILES,
|
const baseConfig = {
|
||||||
} from "~/location/backgroundGeolocationConfig";
|
// https://github.com/transistorsoft/react-native-background-geolocation/wiki/Android-Headless-Mode
|
||||||
import {
|
enableHeadless: true,
|
||||||
ensureBackgroundGeolocationReady,
|
disableProviderChangeRecord: true,
|
||||||
setBackgroundGeolocationEventHandlers,
|
// disableMotionActivityUpdates: true,
|
||||||
} from "~/location/backgroundGeolocationService";
|
// Default to low-power (idle) profile; will be overridden when needed.
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
let trackLocationStartPromise = null;
|
const TRACKING_PROFILES = {
|
||||||
|
idle: {
|
||||||
|
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
|
||||||
|
distanceFilter: 200,
|
||||||
|
heartbeatInterval: 3600,
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
|
||||||
|
distanceFilter: TRACK_MOVE,
|
||||||
|
heartbeatInterval: 900,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function trackLocation() {
|
export default async function trackLocation() {
|
||||||
if (trackLocationStartPromise) return trackLocationStartPromise;
|
const locationLogger = createLogger({
|
||||||
|
module: BACKGROUND_SCOPES.GEOLOCATION,
|
||||||
|
feature: "tracking",
|
||||||
|
});
|
||||||
|
|
||||||
trackLocationStartPromise = (async () => {
|
let currentProfile = null;
|
||||||
const locationLogger = createLogger({
|
let authReady = false;
|
||||||
module: BACKGROUND_SCOPES.GEOLOCATION,
|
let stopAlertSubscription = null;
|
||||||
feature: "tracking",
|
let stopSessionSubscription = null;
|
||||||
});
|
|
||||||
|
|
||||||
let currentProfile = null;
|
const computeHasOwnOpenAlert = () => {
|
||||||
let authReady = false;
|
try {
|
||||||
let stopAlertSubscription = null;
|
const { userId } = getSessionState();
|
||||||
let stopSessionSubscription = null;
|
const { alertingList } = getAlertState();
|
||||||
|
if (!userId || !Array.isArray(alertingList)) return false;
|
||||||
// One-off startup refresh: when tracking is enabled at app launch, fetch a fresh fix once.
|
return alertingList.some(
|
||||||
// This follows Transistorsoft docs guidance to use getCurrentPosition rather than forcing
|
({ oneAlert }) =>
|
||||||
// the SDK into moving mode with changePace(true).
|
oneAlert?.state === "open" && oneAlert?.userId === userId,
|
||||||
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 = () => {
|
|
||||||
try {
|
|
||||||
const { userId } = getSessionState();
|
|
||||||
const { alertingList } = getAlertState();
|
|
||||||
if (!userId || !Array.isArray(alertingList)) return false;
|
|
||||||
return alertingList.some(
|
|
||||||
({ oneAlert }) =>
|
|
||||||
oneAlert?.state === "open" && oneAlert?.userId === userId,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
locationLogger.warn("Failed to compute active-alert state", {
|
|
||||||
error: e?.message,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyProfile = async (profileName) => {
|
|
||||||
if (!authReady) {
|
|
||||||
// We only apply profile once auth headers are configured.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentProfile === profileName) return;
|
|
||||||
|
|
||||||
const profile = TRACKING_PROFILES[profileName];
|
|
||||||
if (!profile) {
|
|
||||||
locationLogger.warn("Unknown tracking profile", { profileName });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
locationLogger.info("Applying tracking profile", {
|
|
||||||
profileName,
|
|
||||||
desiredAccuracy: profile.desiredAccuracy,
|
|
||||||
distanceFilter: profile.distanceFilter,
|
|
||||||
heartbeatInterval: profile.heartbeatInterval,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await BackgroundGeolocation.setConfig(profile);
|
|
||||||
|
|
||||||
// Motion state strategy:
|
|
||||||
// - ACTIVE: force moving to begin aggressive tracking immediately.
|
|
||||||
// - IDLE: do NOT force stationary. Let the SDK's motion detection manage
|
|
||||||
// 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;
|
|
||||||
} catch (error) {
|
|
||||||
locationLogger.error("Failed to apply tracking profile", {
|
|
||||||
profileName,
|
|
||||||
error: error?.message,
|
|
||||||
stack: error?.stack,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log the geolocation sync URL for debugging
|
|
||||||
locationLogger.info("Geolocation sync URL configuration", {
|
|
||||||
url: env.GEOLOC_SYNC_URL,
|
|
||||||
isStaging: env.IS_STAGING,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle auth function - no throttling or cooldown
|
|
||||||
async function handleAuth(userToken) {
|
|
||||||
// Defensive: ensure `.ready()` is resolved before any API call.
|
|
||||||
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
|
|
||||||
|
|
||||||
locationLogger.info("Handling auth token update", {
|
|
||||||
hasToken: !!userToken,
|
|
||||||
});
|
|
||||||
if (!userToken) {
|
|
||||||
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();
|
|
||||||
locationLogger.debug("Location tracking stopped");
|
|
||||||
|
|
||||||
// Cleanup subscriptions when logged out.
|
|
||||||
try {
|
|
||||||
stopAlertSubscription && stopAlertSubscription();
|
|
||||||
stopSessionSubscription && stopSessionSubscription();
|
|
||||||
} finally {
|
|
||||||
stopAlertSubscription = null;
|
|
||||||
stopSessionSubscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
authReady = false;
|
|
||||||
currentProfile = null;
|
|
||||||
|
|
||||||
if (authFixDebounceTimerId) {
|
|
||||||
clearTimeout(authFixDebounceTimerId);
|
|
||||||
authFixDebounceTimerId = null;
|
|
||||||
}
|
|
||||||
authFixInFlight = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// unsub();
|
|
||||||
locationLogger.debug("Updating background geolocation config");
|
|
||||||
await BackgroundGeolocation.setConfig({
|
|
||||||
url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${userToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
authReady = true;
|
|
||||||
|
|
||||||
// Log the authorization header that was set
|
|
||||||
locationLogger.debug(
|
|
||||||
"Set Authorization header for background geolocation",
|
|
||||||
{
|
|
||||||
headerSet: true,
|
|
||||||
tokenPrefix: userToken ? userToken.substring(0, 10) + "..." : null,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
locationLogger.warn("Failed to compute active-alert state", {
|
||||||
|
error: e?.message,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const state = await BackgroundGeolocation.getState();
|
const applyProfile = async (profileName) => {
|
||||||
try {
|
if (!authReady) {
|
||||||
const decodedToken = jwtDecode(userToken);
|
// We only apply profile once auth headers are configured.
|
||||||
locationLogger.debug("Decoded JWT token", { decodedToken });
|
return;
|
||||||
} catch (error) {
|
}
|
||||||
locationLogger.error("Failed to decode JWT token", {
|
if (currentProfile === profileName) return;
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.enabled) {
|
const profile = TRACKING_PROFILES[profileName];
|
||||||
locationLogger.info("Starting location tracking");
|
if (!profile) {
|
||||||
try {
|
locationLogger.warn("Unknown tracking profile", { profileName });
|
||||||
await BackgroundGeolocation.start();
|
return;
|
||||||
locationLogger.debug("Location tracking started successfully");
|
|
||||||
} catch (error) {
|
|
||||||
locationLogger.error("Failed to start location tracking", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
// Default profile is idle unless an active alert requires higher accuracy.
|
|
||||||
const shouldBeActive = computeHasOwnOpenAlert();
|
|
||||||
await applyProfile(shouldBeActive ? "active" : "idle");
|
|
||||||
|
|
||||||
// Subscribe to changes that may require switching profiles.
|
|
||||||
if (!stopSessionSubscription) {
|
|
||||||
stopSessionSubscription = subscribeSessionState(
|
|
||||||
(s) => s?.userId,
|
|
||||||
() => {
|
|
||||||
const active = computeHasOwnOpenAlert();
|
|
||||||
applyProfile(active ? "active" : "idle");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!stopAlertSubscription) {
|
|
||||||
stopAlertSubscription = subscribeAlertState(
|
|
||||||
(s) => s?.alertingList,
|
|
||||||
() => {
|
|
||||||
const active = computeHasOwnOpenAlert();
|
|
||||||
applyProfile(active ? "active" : "idle");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setBackgroundGeolocationEventHandlers({
|
locationLogger.info("Applying tracking profile", {
|
||||||
onLocation: async (location) => {
|
profileName,
|
||||||
locationLogger.debug("Location update received", {
|
desiredAccuracy: profile.desiredAccuracy,
|
||||||
coords: location.coords,
|
distanceFilter: profile.distanceFilter,
|
||||||
timestamp: location.timestamp,
|
heartbeatInterval: profile.heartbeatInterval,
|
||||||
activity: location.activity,
|
|
||||||
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 (
|
|
||||||
location.coords &&
|
|
||||||
location.coords.latitude &&
|
|
||||||
location.coords.longitude
|
|
||||||
) {
|
|
||||||
setLocationState(location.coords);
|
|
||||||
// Also store in AsyncStorage for last known location fallback
|
|
||||||
storeLocation(location.coords, location.timestamp);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLocationError: (error) => {
|
|
||||||
locationLogger.warn("Location error", {
|
|
||||||
error: error?.message,
|
|
||||||
code: error?.code,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onHttp: async (response) => {
|
|
||||||
// Log success/failure for visibility into token expiry, server errors, etc.
|
|
||||||
locationLogger.debug("HTTP response received", {
|
|
||||||
success: response?.success,
|
|
||||||
status: response?.status,
|
|
||||||
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");
|
await BackgroundGeolocation.setConfig(profile);
|
||||||
await ensureBackgroundGeolocationReady(BASE_GEOLOCATION_CONFIG);
|
|
||||||
|
|
||||||
// Only set the permission state if we already have the permission
|
// Key battery fix:
|
||||||
const state = await BackgroundGeolocation.getState();
|
// - IDLE profile forces stationary mode
|
||||||
locationLogger.debug("Background geolocation state", {
|
// - ACTIVE profile forces moving mode
|
||||||
enabled: state.enabled,
|
await BackgroundGeolocation.changePace(profileName === "active");
|
||||||
trackingMode: state.trackingMode,
|
|
||||||
isMoving: state.isMoving,
|
currentProfile = profileName;
|
||||||
schedulerEnabled: state.schedulerEnabled,
|
} catch (error) {
|
||||||
|
locationLogger.error("Failed to apply tracking profile", {
|
||||||
|
profileName,
|
||||||
|
error: error?.message,
|
||||||
|
stack: error?.stack,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (state.enabled) {
|
// Log the geolocation sync URL for debugging
|
||||||
locationLogger.info("Background location permission confirmed");
|
locationLogger.info("Geolocation sync URL configuration", {
|
||||||
permissionsActions.setLocationBackground(true);
|
url: env.GEOLOC_SYNC_URL,
|
||||||
} else {
|
isStaging: env.IS_STAGING,
|
||||||
locationLogger.warn(
|
});
|
||||||
"Background location not enabled in geolocation state",
|
|
||||||
);
|
// Handle auth function - no throttling or cooldown
|
||||||
|
async function handleAuth(userToken) {
|
||||||
|
locationLogger.info("Handling auth token update", {
|
||||||
|
hasToken: !!userToken,
|
||||||
|
});
|
||||||
|
if (!userToken) {
|
||||||
|
locationLogger.info("No auth token, stopping location tracking");
|
||||||
|
await BackgroundGeolocation.stop();
|
||||||
|
locationLogger.debug("Location tracking stopped");
|
||||||
|
|
||||||
|
// Cleanup subscriptions when logged out.
|
||||||
|
try {
|
||||||
|
stopAlertSubscription && stopAlertSubscription();
|
||||||
|
stopSessionSubscription && stopSessionSubscription();
|
||||||
|
} finally {
|
||||||
|
stopAlertSubscription = null;
|
||||||
|
stopSessionSubscription = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (LOCAL_DEV) {
|
authReady = false;
|
||||||
// // fixing issue on android emulator (which doesn't have accelerometer or gyroscope) by manually enabling location updates
|
currentProfile = null;
|
||||||
// setInterval(
|
return;
|
||||||
// () => {
|
}
|
||||||
// BackgroundGeolocation.changePace(true);
|
// unsub();
|
||||||
// },
|
locationLogger.debug("Updating background geolocation config");
|
||||||
// 30 * 60 * 1000,
|
await BackgroundGeolocation.setConfig({
|
||||||
// );
|
url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging
|
||||||
// }
|
headers: {
|
||||||
|
Authorization: `Bearer ${userToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
authReady = true;
|
||||||
|
|
||||||
|
// Log the authorization header that was set
|
||||||
|
locationLogger.debug(
|
||||||
|
"Set Authorization header for background geolocation",
|
||||||
|
{
|
||||||
|
headerSet: true,
|
||||||
|
tokenPrefix: userToken ? userToken.substring(0, 10) + "..." : null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = await BackgroundGeolocation.getState();
|
||||||
|
try {
|
||||||
|
const decodedToken = jwtDecode(userToken);
|
||||||
|
locationLogger.debug("Decoded JWT token", { decodedToken });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
locationLogger.error("Location tracking initialization failed", {
|
locationLogger.error("Failed to decode JWT token", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
|
||||||
code: error.code,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { userToken } = getAuthState();
|
|
||||||
locationLogger.debug("Setting up auth state subscription");
|
|
||||||
subscribeAuthState(({ userToken }) => userToken, handleAuth);
|
|
||||||
locationLogger.debug("Performing initial auth handling");
|
|
||||||
handleAuth(userToken);
|
|
||||||
|
|
||||||
// Initialize emulator mode only in dev/staging to avoid accidental production battery drain.
|
if (!state.enabled) {
|
||||||
if (__DEV__ || env.IS_STAGING) {
|
locationLogger.info("Starting location tracking");
|
||||||
initEmulatorMode();
|
try {
|
||||||
|
await BackgroundGeolocation.start();
|
||||||
|
locationLogger.debug("Location tracking started successfully");
|
||||||
|
} catch (error) {
|
||||||
|
locationLogger.error("Failed to start location tracking", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
return trackLocationStartPromise;
|
// Ensure we are NOT forcing "moving" mode by default.
|
||||||
|
// Default profile is idle unless an active alert requires higher accuracy.
|
||||||
|
const shouldBeActive = computeHasOwnOpenAlert();
|
||||||
|
await applyProfile(shouldBeActive ? "active" : "idle");
|
||||||
|
|
||||||
|
// Subscribe to changes that may require switching profiles.
|
||||||
|
if (!stopSessionSubscription) {
|
||||||
|
stopSessionSubscription = subscribeSessionState(
|
||||||
|
(s) => s?.userId,
|
||||||
|
() => {
|
||||||
|
const active = computeHasOwnOpenAlert();
|
||||||
|
applyProfile(active ? "active" : "idle");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!stopAlertSubscription) {
|
||||||
|
stopAlertSubscription = subscribeAlertState(
|
||||||
|
(s) => s?.alertingList,
|
||||||
|
() => {
|
||||||
|
const active = computeHasOwnOpenAlert();
|
||||||
|
applyProfile(active ? "active" : "idle");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackgroundGeolocation.onLocation(async (location) => {
|
||||||
|
locationLogger.debug("Location update received", {
|
||||||
|
coords: location.coords,
|
||||||
|
timestamp: location.timestamp,
|
||||||
|
activity: location.activity,
|
||||||
|
battery: location.battery,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
location.coords &&
|
||||||
|
location.coords.latitude &&
|
||||||
|
location.coords.longitude
|
||||||
|
) {
|
||||||
|
setLocationState(location.coords);
|
||||||
|
// Also store in AsyncStorage for last known location fallback
|
||||||
|
storeLocation(location.coords, location.timestamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
BackgroundGeolocation.onHttp(async (response) => {
|
||||||
|
// log status code and response
|
||||||
|
locationLogger.debug("HTTP response received", {
|
||||||
|
status: response?.status,
|
||||||
|
responseText: response?.responseText,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
locationLogger.info("Initializing background geolocation");
|
||||||
|
await BackgroundGeolocation.ready(baseConfig);
|
||||||
|
await BackgroundGeolocation.setConfig(baseConfig);
|
||||||
|
|
||||||
|
// Only set the permission state if we already have the permission
|
||||||
|
const state = await BackgroundGeolocation.getState();
|
||||||
|
locationLogger.debug("Background geolocation state", {
|
||||||
|
enabled: state.enabled,
|
||||||
|
trackingMode: state.trackingMode,
|
||||||
|
isMoving: state.isMoving,
|
||||||
|
schedulerEnabled: state.schedulerEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.enabled) {
|
||||||
|
locationLogger.info("Background location permission confirmed");
|
||||||
|
permissionsActions.setLocationBackground(true);
|
||||||
|
} else {
|
||||||
|
locationLogger.warn(
|
||||||
|
"Background location not enabled in geolocation state",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (LOCAL_DEV) {
|
||||||
|
// // fixing issue on android emulator (which doesn't have accelerometer or gyroscope) by manually enabling location updates
|
||||||
|
// setInterval(
|
||||||
|
// () => {
|
||||||
|
// BackgroundGeolocation.changePace(true);
|
||||||
|
// },
|
||||||
|
// 30 * 60 * 1000,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
locationLogger.error("Location tracking initialization failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
code: error.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { userToken } = getAuthState();
|
||||||
|
locationLogger.debug("Setting up auth state subscription");
|
||||||
|
subscribeAuthState(({ userToken }) => userToken, handleAuth);
|
||||||
|
locationLogger.debug("Performing initial auth handling");
|
||||||
|
handleAuth(userToken);
|
||||||
|
|
||||||
|
// Initialize emulator mode only in dev/staging to avoid accidental production battery drain.
|
||||||
|
if (__DEV__ || env.IS_STAGING) {
|
||||||
|
initEmulatorMode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ 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={() => {
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,7 @@ 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,
|
||||||
|
|
@ -101,9 +99,7 @@ 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,
|
||||||
|
|
@ -138,9 +134,7 @@ 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,
|
||||||
|
|
@ -173,9 +167,7 @@ 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={() => (
|
||||||
|
|
@ -201,9 +193,7 @@ 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={() => (
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,6 @@ 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",
|
||||||
|
|
@ -220,7 +219,6 @@ export default function About() {
|
||||||
|
|
||||||
{/* Contribute Button */}
|
{/* Contribute Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityRole="button"
|
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ export default function MapHeadRouting({
|
||||||
distance,
|
distance,
|
||||||
profileDefaultMode,
|
profileDefaultMode,
|
||||||
openStepper,
|
openStepper,
|
||||||
openStepperTriggerRef,
|
|
||||||
seeAllStepsTriggerRef,
|
|
||||||
calculatingState,
|
calculatingState,
|
||||||
}) {
|
}) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
|
@ -83,11 +81,7 @@ export default function MapHeadRouting({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={openStepperTriggerRef}
|
onPress={openStepper}
|
||||||
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,
|
||||||
|
|
@ -132,18 +126,16 @@ 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(seeAllStepsTriggerRef)}
|
onPress={openStepper}
|
||||||
icon={() => (
|
icon={() => (
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={"chevron-right"}
|
name={"chevron-right"}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { View, ScrollView, Text as RNText } from "react-native";
|
import { View, ScrollView } 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,7 +11,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -26,7 +25,6 @@ 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];
|
||||||
|
|
@ -40,8 +38,6 @@ 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,
|
||||||
|
|
@ -54,19 +50,6 @@ 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,
|
||||||
|
|
@ -77,11 +60,6 @@ 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
|
||||||
|
|
@ -94,11 +72,6 @@ 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
|
||||||
|
|
@ -111,11 +84,6 @@ 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
|
||||||
|
|
@ -190,25 +158,36 @@ export default function RoutingSteps({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<IconTouchTarget
|
<View
|
||||||
accessibilityLabel="Fermer la liste des étapes"
|
style={{
|
||||||
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,
|
}}
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<Button
|
||||||
name="close"
|
style={{
|
||||||
size={26}
|
flex: 1,
|
||||||
color={colors.onSurface}
|
borderRadius: 0,
|
||||||
|
alignSelf: "center",
|
||||||
|
left: 5,
|
||||||
|
}}
|
||||||
|
onPress={closeStepper}
|
||||||
|
icon={() => (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="close"
|
||||||
|
size={26}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</IconTouchTarget>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,6 @@ 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";
|
||||||
|
|
@ -72,7 +64,6 @@ 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,
|
||||||
|
|
@ -487,41 +478,24 @@ function AlertCurMap() {
|
||||||
|
|
||||||
const [stepperIsOpened, setStepperIsOpened] = useState(false);
|
const [stepperIsOpened, setStepperIsOpened] = useState(false);
|
||||||
|
|
||||||
const routingSheetTitleA11yRef = useRef(null);
|
const openStepper = useCallback(() => {
|
||||||
const a11yStepsEntryRef = useRef(null);
|
setStepperIsOpened(true);
|
||||||
const mapHeadOpenRef = useRef(null);
|
}, [setStepperIsOpened]);
|
||||||
const mapHeadSeeAllRef = useRef(null);
|
|
||||||
const lastStepsTriggerRef = useRef(null);
|
|
||||||
|
|
||||||
const openStepper = useCallback(
|
|
||||||
(triggerRef) => {
|
|
||||||
if (triggerRef) {
|
|
||||||
lastStepsTriggerRef.current = triggerRef;
|
|
||||||
}
|
|
||||||
setStepperIsOpened(true);
|
|
||||||
},
|
|
||||||
[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);
|
||||||
|
|
@ -552,7 +526,6 @@ function AlertCurMap() {
|
||||||
duration={duration}
|
duration={duration}
|
||||||
instructions={instructions}
|
instructions={instructions}
|
||||||
calculatingState={calculating}
|
calculatingState={calculating}
|
||||||
titleA11yRef={routingSheetTitleA11yRef}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -562,28 +535,6 @@ 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}
|
||||||
|
|
@ -639,8 +590,6 @@ function AlertCurMap() {
|
||||||
instructions={instructions}
|
instructions={instructions}
|
||||||
distance={distance}
|
distance={distance}
|
||||||
openStepper={openStepper}
|
openStepper={openStepper}
|
||||||
openStepperTriggerRef={mapHeadOpenRef}
|
|
||||||
seeAllStepsTriggerRef={mapHeadSeeAllRef}
|
|
||||||
calculatingState={calculating}
|
calculatingState={calculating}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@ 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,
|
||||||
|
|
@ -84,7 +83,6 @@ 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,
|
||||||
|
|
@ -114,7 +112,6 @@ export default function Contribute() {
|
||||||
|
|
||||||
{/* GitHub Sponsors Button */}
|
{/* GitHub Sponsors Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityRole="button"
|
|
||||||
style={[
|
style={[
|
||||||
styles.donationButton,
|
styles.donationButton,
|
||||||
styles.buttonContent,
|
styles.buttonContent,
|
||||||
|
|
@ -154,7 +151,6 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,6 @@ 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();
|
||||||
};
|
};
|
||||||
|
|
@ -78,8 +75,6 @@ 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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ export default function HelpSignal() {
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityRole="button"
|
|
||||||
style={[styles.linkContainer, styles.sectionLink]}
|
style={[styles.linkContainer, styles.sectionLink]}
|
||||||
onPress={openArticle}
|
onPress={openArticle}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,6 @@ 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}
|
||||||
|
|
@ -320,7 +319,6 @@ const NotificationItem = ({
|
||||||
|
|
||||||
{/* Delete button */}
|
{/* Delete button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={handleDelete}
|
onPress={handleDelete}
|
||||||
style={[
|
style={[
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
|
|
@ -354,7 +352,6 @@ const NotificationItem = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityRole="button"
|
|
||||||
style={itemStyle}
|
style={itemStyle}
|
||||||
onPress={handleNotificationPress}
|
onPress={handleNotificationPress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,6 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,7 @@ export default function ParamsEmergencyCall({ data }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title accessibilityRole="header" style={styles.title}>
|
<Title style={styles.title}>Préférences d'accessibilité</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
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,7 @@ export default function ParamsNotifications({ data }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title accessibilityRole="header" style={styles.title}>
|
<Title style={styles.title}>Notifications</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}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useCallback, useRef } from "react";
|
import React, { useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
|
@ -9,16 +9,11 @@ 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";
|
||||||
|
|
@ -81,49 +76,6 @@ 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 {
|
||||||
|
|
@ -146,10 +98,9 @@ const checkPermissionStatus = async (permission) => {
|
||||||
case "motion":
|
case "motion":
|
||||||
return await requestPermissionMotion.checkPermission();
|
return await requestPermissionMotion.checkPermission();
|
||||||
case "phoneCall":
|
case "phoneCall":
|
||||||
if (Platform.OS !== "android") return true;
|
// Note: Phone call permissions on iOS are determined at build time
|
||||||
return (
|
// and on Android they're requested at runtime
|
||||||
(await check(PERMISSIONS.ANDROID.CALL_PHONE)) === RESULTS.GRANTED
|
return true; // This might need adjustment based on your specific implementation
|
||||||
);
|
|
||||||
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
|
||||||
|
|
@ -170,143 +121,22 @@ const checkPermissionStatus = async (permission) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPermissionA11yMeta = async (permission) => {
|
const PermissionItem = ({ permission, status, onRequestPermission }) => (
|
||||||
try {
|
<View style={styles.permissionItem}>
|
||||||
switch (permission) {
|
<TouchableOpacity
|
||||||
case "fcm": {
|
onPress={() => onRequestPermission(permission)}
|
||||||
const { status, canAskAgain } =
|
style={styles.permissionButton}
|
||||||
await Notifications.getPermissionsAsync();
|
>
|
||||||
return {
|
<Text style={styles.permissionText}>{titlePermissions[permission]}</Text>
|
||||||
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 = ({
|
<Ionicons
|
||||||
permission,
|
name={status ? "checkmark-circle" : "close-circle"}
|
||||||
status,
|
size={24}
|
||||||
blocked,
|
color={status ? "green" : "red"}
|
||||||
onRequestPermission,
|
/>
|
||||||
onOpenSettings,
|
</TouchableOpacity>
|
||||||
}) => {
|
</View>
|
||||||
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}>
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="switch"
|
|
||||||
accessibilityLabel={label}
|
|
||||||
accessibilityHint={computedHint}
|
|
||||||
accessibilityState={{ checked: !!status, disabled: !!blocked }}
|
|
||||||
disabled={blocked}
|
|
||||||
onPress={() => onRequestPermission(permission)}
|
|
||||||
style={styles.permissionButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.permissionText}>{label}</Text>
|
|
||||||
|
|
||||||
<Ionicons
|
|
||||||
accessible={false}
|
|
||||||
importantForAccessibility="no"
|
|
||||||
name={status ? "checkmark-circle" : "close-circle"}
|
|
||||||
size={24}
|
|
||||||
color={status ? "green" : "red"}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Permissions() {
|
export default function Permissions() {
|
||||||
// Create permissions list based on platform
|
// Create permissions list based on platform
|
||||||
|
|
@ -331,26 +161,12 @@ 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
|
||||||
|
|
@ -358,10 +174,6 @@ 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) => {
|
||||||
|
|
@ -385,7 +197,6 @@ 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
|
||||||
|
|
@ -412,40 +223,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -453,27 +230,20 @@ export default function Permissions() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title ref={titleRef} accessibilityRole="header" style={styles.title}>
|
<Title style={styles.title}>Permissions</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>
|
||||||
|
|
@ -504,12 +274,5 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
blockedRow: {
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
blockedText: {
|
|
||||||
fontSize: 14,
|
|
||||||
marginBottom: 6,
|
|
||||||
},
|
|
||||||
settingsButton: {},
|
settingsButton: {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,13 @@ function SentryOptOut() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Title accessibilityRole="header" style={styles.title}>
|
<Title style={styles.title}>Rapport d'erreurs</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>
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,7 @@ function ThemeSwitcher() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Title accessibilityRole="header" style={styles.title}>
|
<Title style={styles.title}>Thème</Title>
|
||||||
Thème
|
|
||||||
</Title>
|
|
||||||
<View style={styles.buttonContainer}>
|
<View style={styles.buttonContainer}>
|
||||||
{themeOptions.map((option) => (
|
{themeOptions.map((option) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -34,18 +32,8 @@ 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
|
<Ionicons name={option.icon} size={size} color={color} />
|
||||||
accessible={false}
|
|
||||||
importantForAccessibility="no"
|
|
||||||
name={option.icon}
|
|
||||||
size={size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
|
@ -31,9 +31,6 @@ 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 });
|
||||||
|
|
@ -79,7 +76,6 @@ 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={() => (
|
||||||
|
|
@ -117,11 +113,8 @@ 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,
|
||||||
}}
|
}}
|
||||||
|
|
@ -145,10 +138,6 @@ export default function AccountManagement({
|
||||||
profileData={profileData}
|
profileData={profileData}
|
||||||
waitingSmsType={waitingSmsType}
|
waitingSmsType={waitingSmsType}
|
||||||
clearAuthWaitParams={clearAuthWaitParams}
|
clearAuthWaitParams={clearAuthWaitParams}
|
||||||
triggerRefs={{
|
|
||||||
connect: openConnectButtonRef,
|
|
||||||
destroy: openDestroyButtonRef,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,23 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, 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,
|
||||||
|
|
@ -32,40 +25,13 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ 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,
|
||||||
|
|
@ -110,9 +108,6 @@ export default function AccountManagementModalConnect({
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
announceForA11yIfScreenReaderEnabled(
|
|
||||||
"Erreur lors de la confirmation de connexion",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
loginConfirmRequest,
|
loginConfirmRequest,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useRef } from "react";
|
import React, { useCallback, useEffect, useState } 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,11 +12,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -53,9 +48,6 @@ 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: {
|
||||||
|
|
@ -89,16 +81,6 @@ 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
|
||||||
|
|
@ -114,8 +96,6 @@ export default function AccountManagementModalDestroy({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
ref={titleRef}
|
|
||||||
accessibilityRole="header"
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
|
|
@ -165,7 +145,6 @@ 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"
|
||||||
|
|
@ -196,8 +175,6 @@ export default function AccountManagementModalDestroy({
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
height: 60,
|
height: 60,
|
||||||
}}
|
}}
|
||||||
accessibilityLabel="Supprimer le compte"
|
|
||||||
accessibilityHint="Action irréversible"
|
|
||||||
>
|
>
|
||||||
SUPPRIMER
|
SUPPRIMER
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React, { useCallback, useEffect, useRef } from "react";
|
import React, { useCallback } 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 { useTheme } from "~/theme";
|
import { createStyles, 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,8 +17,6 @@ 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";
|
||||||
|
|
@ -46,14 +44,12 @@ const delOneAvatar = async () => {
|
||||||
await network.oaFilesKy.delete("avatar", {});
|
await network.oaFilesKy.delete("avatar", {});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
export default function AvatarModalEdit({ modalState, userId }) {
|
||||||
const [modal, setModal] = modalState;
|
const [modal, setModal] = modalState;
|
||||||
const { colors } = useTheme();
|
const { colors, custom } = 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");
|
||||||
|
|
@ -131,12 +127,6 @@ export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
||||||
});
|
});
|
||||||
}, [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) {
|
||||||
|
|
@ -160,13 +150,8 @@ export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
||||||
visible={modal.visible}
|
visible={modal.visible}
|
||||||
onDismiss={closeModal}
|
onDismiss={closeModal}
|
||||||
contentContainerStyle={styles.bottomModalContentContainer}
|
contentContainerStyle={styles.bottomModalContentContainer}
|
||||||
accessibilityViewIsModal
|
|
||||||
>
|
>
|
||||||
<Text
|
<Text style={{ fontSize: 16, fontWeight: "bold" }}>
|
||||||
ref={titleRef}
|
|
||||||
accessibilityRole="header"
|
|
||||||
style={{ fontSize: 16, fontWeight: "bold" }}
|
|
||||||
>
|
|
||||||
Photo de profil
|
Photo de profil
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
|
@ -180,9 +165,6 @@ export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
||||||
borderRadius: 120,
|
borderRadius: 120,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
}}
|
}}
|
||||||
accessible={false}
|
|
||||||
accessibilityElementsHidden
|
|
||||||
importantForAccessibility="no"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{imageMode === "text" && (
|
{imageMode === "text" && (
|
||||||
|
|
@ -200,9 +182,6 @@ export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
||||||
right: -45,
|
right: -45,
|
||||||
top: -35,
|
top: -35,
|
||||||
}}
|
}}
|
||||||
accessible={false}
|
|
||||||
accessibilityElementsHidden
|
|
||||||
importantForAccessibility="no"
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -227,8 +206,6 @@ export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
||||||
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>
|
||||||
|
|
@ -245,8 +222,6 @@ export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
||||||
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>
|
||||||
|
|
@ -263,8 +238,6 @@ export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
||||||
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>
|
||||||
|
|
@ -277,15 +250,7 @@ export default function AvatarModalEdit({ modalState, userId, triggerRef }) {
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button onPress={() => closeModal()} mode="contained">
|
||||||
onPress={() => {
|
|
||||||
closeModal();
|
|
||||||
if (triggerRef?.current) {
|
|
||||||
setA11yFocusAfterInteractions(triggerRef);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
mode="contained"
|
|
||||||
>
|
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button onPress={saveImage} mode="contained">
|
<Button onPress={saveImage} mode="contained">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import React, { useCallback, useRef, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { View, Image, TouchableWithoutFeedback } from "react-native";
|
import { View, Image, TouchableWithoutFeedback } from "react-native";
|
||||||
import { Avatar } from "react-native-paper";
|
import { IconButton, 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";
|
||||||
|
|
||||||
|
|
@ -12,10 +13,8 @@ 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 } = useTheme();
|
const { colors, custom } = useTheme();
|
||||||
const { watch } = useFormContext();
|
const { watch, setValue } = useFormContext();
|
||||||
|
|
||||||
const triggerRef = useRef(null);
|
|
||||||
|
|
||||||
const username = watch("username");
|
const username = watch("username");
|
||||||
const image = watch("image");
|
const image = watch("image");
|
||||||
|
|
@ -48,18 +47,8 @@ export default function AvatarUploader({ data, userId }) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View style={{ flexDirection: "column", alignItems: "center" }}>
|
<View style={{ flexDirection: "column", alignItems: "center" }}>
|
||||||
<TouchableWithoutFeedback
|
<TouchableWithoutFeedback onPress={edit}>
|
||||||
accessibilityRole="button"
|
<View style={{ flexDirection: "column", alignItems: "center" }}>
|
||||||
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}
|
||||||
|
|
@ -69,9 +58,6 @@ export default function AvatarUploader({ data, userId }) {
|
||||||
borderRadius: 120,
|
borderRadius: 120,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
}}
|
}}
|
||||||
accessible={false}
|
|
||||||
accessibilityElementsHidden
|
|
||||||
importantForAccessibility="no"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{imageMode === "text" && (
|
{imageMode === "text" && (
|
||||||
|
|
@ -89,18 +75,11 @@ 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
|
<AvatarModalEdit modalState={modalState} userId={userId} />
|
||||||
modalState={modalState}
|
|
||||||
userId={userId}
|
|
||||||
triggerRef={triggerRef}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,11 @@ 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, useEffect } from "react";
|
import { useCallback } 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: {
|
||||||
|
|
@ -80,11 +78,6 @@ 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" }}>
|
||||||
|
|
@ -94,7 +87,6 @@ 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
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ 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: {
|
||||||
|
|
@ -88,17 +86,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect } from "react";
|
import React, { useCallback } 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,8 +18,6 @@ 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();
|
||||||
|
|
@ -41,11 +39,6 @@ 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>
|
||||||
|
|
@ -53,8 +46,6 @@ 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>
|
||||||
|
|
@ -76,9 +67,6 @@ 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={{}}>
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@ 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")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 accessible accessibilityRole="text" style={[style]} {...props}>
|
<View style={[style]} {...props}>
|
||||||
<Text style={[labelStyle]}>{children}</Text>
|
<Text style={[labelStyle]}>{children}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,20 @@
|
||||||
import React, { forwardRef } from "react";
|
import React 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";
|
||||||
|
|
||||||
const RadarButton = forwardRef(function RadarButton(
|
export default function RadarButton({
|
||||||
{ onPress, isLoading = false, isExpanded = false, flex = 0.22 },
|
onPress,
|
||||||
ref,
|
isLoading = false,
|
||||||
) {
|
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}
|
||||||
|
|
@ -34,9 +31,7 @@ const RadarButton = forwardRef(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: {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React, { useEffect, useRef } from "react";
|
import React 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,
|
||||||
|
|
@ -15,13 +14,6 @@ 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 (
|
||||||
|
|
@ -86,13 +78,7 @@ export default function RadarModal({
|
||||||
color={colors.primary}
|
color={colors.primary}
|
||||||
style={styles.modalIcon}
|
style={styles.modalIcon}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text style={styles.modalTitle}>Utilisateurs aux alentours</Text>
|
||||||
ref={titleRef}
|
|
||||||
accessibilityRole="header"
|
|
||||||
style={styles.modalTitle}
|
|
||||||
>
|
|
||||||
Utilisateurs aux alentours
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
|
|
@ -103,9 +89,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,6 @@ 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")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
import React, { useState, useCallback } 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,10 +17,6 @@ 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();
|
||||||
|
|
@ -30,9 +26,6 @@ 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,
|
||||||
|
|
@ -42,15 +35,9 @@ export default function SendAlert() {
|
||||||
hasLocation,
|
hasLocation,
|
||||||
} = useRadarData();
|
} = useRadarData();
|
||||||
|
|
||||||
const toggleHelp = useCallback(() => {
|
function toggleHelp() {
|
||||||
setHelpVisible((prev) => {
|
setHelpVisible(!helpVisible);
|
||||||
const next = !prev;
|
}
|
||||||
announceForA11yIfScreenReaderEnabled(
|
|
||||||
next ? "Aide affichée." : "Aide masquée.",
|
|
||||||
);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRadarPress = useCallback(() => {
|
const handleRadarPress = useCallback(() => {
|
||||||
if (!hasLocation) {
|
if (!hasLocation) {
|
||||||
|
|
@ -64,47 +51,8 @@ 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({
|
||||||
|
|
@ -154,30 +102,16 @@ 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} accessibilityRole="header">
|
<Title style={styles.title}>Quelle est votre situation ?</Title>
|
||||||
Quelle est votre situation ?
|
|
||||||
</Title>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
accessibilityRole="button"
|
accessibilityLabel={"aide"}
|
||||||
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}
|
||||||
|
|
@ -197,10 +131,7 @@ 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]}
|
||||||
|
|
@ -228,10 +159,7 @@ 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]}
|
||||||
|
|
@ -259,10 +187,7 @@ 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]}
|
||||||
|
|
@ -290,10 +215,6 @@ 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]}
|
||||||
|
|
@ -313,10 +234,6 @@ 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]}
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,7 @@ 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
|
<TouchableWithoutFeedback onPress={cancelAutoConfirm}>
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={cancelAutoConfirm}
|
|
||||||
>
|
|
||||||
<View style={styles.countDownContainer}>
|
<View style={styles.countDownContainer}>
|
||||||
<Text style={styles.countDownLabel}>
|
<Text style={styles.countDownLabel}>
|
||||||
{`Confirmation\nautomatique`}
|
{`Confirmation\nautomatique`}
|
||||||
|
|
|
||||||
33
yarn.lock
33
yarn.lock
|
|
@ -3214,13 +3214,6 @@ __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"
|
||||||
|
|
@ -7060,7 +7053,6 @@ __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"
|
||||||
|
|
@ -10205,19 +10197,6 @@ __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"
|
||||||
|
|
@ -13748,18 +13727,6 @@ __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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue