Quellcode durchsuchen

Initialisation de WatchOsm : architecture, UML et app Wear OS 5

- Architecture proposée en couches documentée dans
  documentation/architecture.md : NotificationListenerService captant les
  notifications OsmAnd relayées vers la montre, parseur défensif
  (EN/FR), repository StateFlow, écrans Compose for Wear OS 5.
- 6 diagrammes PlantUML (cas d'utilisation, composants, classes, séquence,
  états, déploiement), validés avec plantuml -checkonly.
- Module wear/ complet : 11 tests unitaires du parseur verts,
  assembleDebug (APK) et lint verts.
- Chaîne de build : Gradle 8.11.1 + AGP 8.9.2 + Kotlin 2.1.20 + SDK 35,
  wear Compose 1.6.2 épinglé (1.7.x exigerait AGP 9.1 / compileSdk 37).
aston vor 1 Monat
Ursprung
Commit
cbab86cbf0
39 geänderte Dateien mit 2531 neuen und 26 gelöschten Zeilen
  1. 21 24
      .gitignore
  2. 66 0
      AGENTS.md
  3. 50 2
      README.md
  4. 6 0
      build.gradle.kts
  5. 233 0
      documentation/architecture.md
  6. 127 0
      documentation/puml/classes.puml
  7. 51 0
      documentation/puml/composants.puml
  8. 39 0
      documentation/puml/deploiement.puml
  9. 25 0
      documentation/puml/etats_navigation.puml
  10. 43 0
      documentation/puml/sequence_navigation.puml
  11. 37 0
      documentation/puml/usecases.puml
  12. 5 0
      gradle.properties
  13. 31 0
      gradle/libs.versions.toml
  14. BIN
      gradle/wrapper/gradle-wrapper.jar
  15. 7 0
      gradle/wrapper/gradle-wrapper.properties
  16. 252 0
      gradlew
  17. 94 0
      gradlew.bat
  18. 24 0
      settings.gradle.kts
  19. 69 0
      wear/build.gradle.kts
  20. 1 0
      wear/proguard-rules.pro
  21. 48 0
      wear/src/main/AndroidManifest.xml
  22. 26 0
      wear/src/main/java/fr/barteam/watchosm/WatchOsmApplication.kt
  23. 52 0
      wear/src/main/java/fr/barteam/watchosm/data/NavigationStateRepository.kt
  24. 55 0
      wear/src/main/java/fr/barteam/watchosm/data/OsmAndNotificationListener.kt
  25. 312 0
      wear/src/main/java/fr/barteam/watchosm/data/OsmAndNotificationParser.kt
  26. 22 0
      wear/src/main/java/fr/barteam/watchosm/domain/Formatters.kt
  27. 41 0
      wear/src/main/java/fr/barteam/watchosm/domain/Maneuver.kt
  28. 45 0
      wear/src/main/java/fr/barteam/watchosm/domain/NavigationState.kt
  29. 70 0
      wear/src/main/java/fr/barteam/watchosm/ui/AppNavHost.kt
  30. 22 0
      wear/src/main/java/fr/barteam/watchosm/ui/MainActivity.kt
  31. 32 0
      wear/src/main/java/fr/barteam/watchosm/ui/NavigationViewModel.kt
  32. 18 0
      wear/src/main/java/fr/barteam/watchosm/ui/NotificationAccess.kt
  33. 27 0
      wear/src/main/java/fr/barteam/watchosm/ui/Theme.kt
  34. 127 0
      wear/src/main/java/fr/barteam/watchosm/ui/components/ManeuverArrow.kt
  35. 101 0
      wear/src/main/java/fr/barteam/watchosm/ui/screens/HomeScreen.kt
  36. 177 0
      wear/src/main/java/fr/barteam/watchosm/ui/screens/NavigationScreen.kt
  37. 26 0
      wear/src/main/res/values/strings.xml
  38. 7 0
      wear/src/main/res/values/themes.xml
  39. 142 0
      wear/src/test/java/fr/barteam/watchosm/data/OsmAndNotificationParserTest.kt

+ 21 - 24
.gitignore

@@ -1,34 +1,31 @@
-# ---> Android
-# Built application files
+# Gradle
+.gradle/
+build/
+local.properties
+
+# Android Studio / IntelliJ
+.idea/
+*.iml
+captures/
+.externalNativeBuild/
+.cxx/
+.navigation/
+
+# APK/signing (jamais de keystore versionné)
 *.apk
-*.ap_
+*.aab
+*.keystore
+*.jks
 
-# Files for the Dalvik VM
+# Fichiers générés (Dalvik / anciens outils)
 *.dex
-
-# Java class files
 *.class
-
-# Generated files
 bin/
 gen/
-
-# Gradle files
-.gradle/
-build/
-
-# Local configuration file (sdk path, etc)
-local.properties
-
-# Proguard folder generated by Eclipse
 proguard/
 
-# Log Files
+# Logs
 *.log
 
-# Android Studio Navigation editor temp files
-.navigation/
-
-# Android Studio captures folder
-captures/
-
+# OS
+.DS_Store

+ 66 - 0
AGENTS.md

@@ -0,0 +1,66 @@
+# AGENTS.md — WatchOsm
+
+Application Wear OS 5 qui suit la navigation initiée par OsmAnd+ (Android).
+Documentation d'architecture : `documentation/architecture.md` + diagrammes
+PlantUML dans `documentation/puml/`.
+
+## Langues
+
+- Commentaires, messages de commit, documentation : **français**.
+- Identifiants de code en anglais (conventions Kotlin/Android classiques).
+
+## Build et vérification — chaîne validée le 07/10/2026
+
+Toutes les commandes suivantes sont **vertes** sur cette machine :
+
+```bash
+./gradlew :wear:assembleDebug        # APK debug
+./gradlew :wear:testDebugUnitTest    # 11 tests unitaires (parseur)
+./gradlew :wear:lint                 # lint Android
+java -jar /tmp/opencode/plantuml.jar -checkonly documentation/puml/*.puml
+```
+
+- **Toolchain** : Gradle wrapper **8.11.1**, AGP **8.9.2**, Kotlin **2.1.20**
+  (+ plugin `org.jetbrains.kotlin.plugin.compose` ; ne PAS ajouter
+  `composeOptions.kotlinCompilerExtensionVersion`, compilateur Compose intégré
+  au plugin Kotlin 2.x).
+- **Android SDK** : installé dans `~/Android/Sdk` (cmdline-tools,
+  `platforms;android-35`, `build-tools;35.0.0`). `local.properties` (non
+  versionné) pointe dessus. Si le SDK manque : voir l'historique git, les
+  étapes sdkmanager sont dans le commit d'initialisation.
+- **JDK complet requis** : le JDK système est une JRE sans `javac`, ce qui
+  fait échouer Gradle 8.11 sur les tâches Java. `~/.gradle/gradle.properties`
+  (hors dépôt) définit
+  `org.gradle.java.home=/home/aston/tools/jdk-21.0.12.1+1`. Si ce chemin
+  disparaît, télécharger Temurin 21 via
+  `https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse`.
+- **Épinglages à ne pas bouger sans raisons** :
+  - AGP < 8.9.2 → son lint plante (UAST vs Kotlin 2.1,
+    `KaCallableMemberCall`). Ne pas revenir à 8.7.x.
+  - Wear Compose **1.6.2** : la 1.7.x exige AGP 9.1 + compileSdk 37.
+  - core-ktx / lifecycle / compose-bom : versions inférieures aux dernières
+    car les suivantes demandent compileSdk ≥ 36. Monter compileSdk/AGP est un
+    chantier à part entière (AGP 9 change le DSL).
+- `lint { disable += "RememberInComposition" }` dans `wear/build.gradle.kts` :
+  détecteur en crash avec cette paire AGP/Kotlin, laisser tel quel.
+
+## Diagrammes UML
+
+- Sources **uniquement** en PlantUML (`.puml`) dans `documentation/puml/`.
+- PlantUML n'est pas installé ; pour valider la syntaxe localement :
+  `java -jar /tmp/opencode/plantuml.jar -checkonly documentation/puml/*.puml`
+  (le jar, téléchargé dans `/tmp/opencode/`, n'est pas versionné ; le retirer
+  si absent, il se re-télécharge depuis
+  `https://github.com/plantuml/plantuml/releases`).
+- Toute évolution d'architecture met à jour les diagrammes dans le même commit.
+
+## Structure
+
+- Module unique `wear/` (application montre autonome). Le module `phone/`
+  (compagnon Android : listener renforcé + API AIDL OsmAnd + Data Layer) est
+  prévu en phase 2, pas encore créé.
+- Package : `fr.barteam.watchosm`.
+- Le pont vers OsmAnd repose sur le **bridging des notifications** vers la
+  montre ; le parseur de texte (`OsmAndNotificationParser`) est le seul point
+  fragile face aux mises à jour d'OsmAnd — toute régression de format se
+  corrige là, avec un test dans `OsmAndNotificationParserTest`.

+ 50 - 2
README.md

@@ -1,3 +1,51 @@
-# watchosm
+# WatchOsm
 
-Wear os application for guidance based on android osmand application
+Montre Wear OS 5 qui affiche en temps réel la navigation initiée par
+**OsmAnd+ 5.4.9** sur le téléphone (Android 16).
+
+WatchOsm n'est pas un GPS ni un calculateur de route : c'est un **miroir de
+navigation**. Il écoute la notification de navigation permanente publiée par
+OsmAnd sur le téléphone, la relayée vers la montre par le système Android, en
+extraire la consigne de manœuvre (flèche, distance, rue), l'ETA et la distance
+restante, puis les affiche sur des écrans conçus pour le poignet.
+
+## Fonctionnalités (v1)
+
+- Écran d'accueil : état du service d'écoute, état de la navigation, raccourci.
+- Écran navigation : flèche de manœuvre, distance à la prochaine manœuvre,
+  rue, distance restante, temps restant, heure d'arrivée, vitesse.
+- Détection automatique du début/fin/pause de navigation.
+- 100 % hors-ligne : aucun compte, aucun réseau requis.
+
+## Architecture
+
+Voir [`documentation/architecture.md`](documentation/architecture.md) et les
+diagrammes PlantUML dans [`documentation/puml/`](documentation/puml/).
+
+## Construction
+
+```bash
+./gradlew :wear:assembleDebug        # APK
+./gradlew :wear:testDebugUnitTest    # tests unitaires du parseur
+./gradlew :wear:lint                 # analyse statique
+```
+
+Prérequis (voir `AGENTS.md` pour l'état de la chaîne sur cette machine) :
+
+- SDK Android 35 : `sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"`
+  puis `sdk.dir` dans `local.properties` ;
+- un **JDK complet** (le JDK par défaut de cette machine est une JRE sans
+  `javac`), pointé par `org.gradle.java.home` dans `~/.gradle/gradle.properties`.
+
+## Installation
+
+```bash
+adb install -r wear/build/outputs/apk/debug/wear-debug.apk
+```
+
+Puis autoriser WatchOsm dans *Réglages > Notifications > Accès aux
+notifications* de la montre.
+
+## Licence
+
+GNU GPL v2 (voir `LICENSE`).

+ 6 - 0
build.gradle.kts

@@ -0,0 +1,6 @@
+// AGP 8.9.2 + Gradle 8.11.1 : la ligne 8.7.x avait un lint incompatibilible avec Kotlin 2.1 (UAST).
+plugins {
+    alias(libs.plugins.android.application) apply false
+    alias(libs.plugins.kotlin.android) apply false
+    alias(libs.plugins.kotlin.compose) apply false
+}

+ 233 - 0
documentation/architecture.md

@@ -0,0 +1,233 @@
+# WatchOsm — Proposition d'architecture
+
+Application **Wear OS 5** qui suit la navigation initiée par **OsmAnd+ 5.4.9**
+sous **Android 16**.
+
+| | |
+|---|---|
+| Version du document | 1.0 |
+| Cible | Montre Wear OS 5 (minSdk 34), autonome (standalone) |
+| Source de navigation | OsmAnd+ 5.4.9 sur le téléphone Android 16 |
+| Diagrammes | [`puml/`](puml/) — sources PlantUML versionnées |
+
+---
+
+## 1. Contexte et objectif
+
+OsmAnd+ assure le calcul d'itinéraire, le guidage et la voix sur le téléphone.
+En usage vélo, moto ou randonnée, le téléphone reste dans la poche : WatchOsm
+**reprend sur le poignet** l'information que la montre ne montre pas
+aujourd'hui — la prochaine manœuvre et les grandeurs de trajet — sans
+remplacer OsmAnd ni dupliquer son moteur de navigation.
+
+**Objectifs v1**
+
+1. Détecter automatiquement le début, la pause et la fin d'une navigation
+   OsmAnd, sans manipulation supplémentaire.
+2. Afficher la prochaine manœuvre (flèche, distance, rue) en lisibilité
+   maximale (fond noir, contraste élevé, écran maintenu allumé).
+3. Afficher distance restante, temps restant, heure d'arrivée et vitesse.
+4. Rester 100 % hors-ligne : aucun compte, aucun réseau, aucune collecte.
+
+**Non-objectifs v1** (volontairement exclus)
+
+- Cartographie ou calcul de route sur la montre (OsmAnd reste le cerveau).
+- Enregistrement de trace, tuiles, complications (phase 3).
+- Contrôle d'OsmAnd depuis la montre (pause/stop) : nécessite l'API AIDL
+  OsmAnd côté téléphone (phase 2).
+
+---
+
+## 2. Options d'intégration étudiées
+
+OsmAnd ne publie **aucune API officielle de flux de navigation** vers une
+montre. Quatre ponts ont été évalués :
+
+| | Mécanisme | Avantages | Inconvénients | Décision |
+|---|---|---|---|---|
+| **A** | `NotificationListenerService` **sur la montre**, alimenté par les notifications OsmAnd relayées (bridging Wear OS) | Zéro app téléphone, zéro réseau, aucun couplet à maintenir, fonctionne avec OsmAnd+ tel quel | Dépend du format du texte de notification (non contractuel) ; permission manuelle ; bridging limité aux extras textuels | **Retenu pour v1** |
+| **B** | App compagnon **téléphone** : listener local + `Wearable.DataLayer` vers la montre | Source plus fiable (notifications complètes côté téléphone) | Deux modules à maintenir, installation à deux endroits | Phase 2 |
+| **C** | API **AIDL OsmAnd** (`net.osmand.aidlapi`) via app compagnon | Contrôle direct : `pauseNavigation`, `stopNavigation`, callbacks | Fonctionne uniquement côté téléphone, ne fournit pas le flux de consignes complet | Phase 2 (contrôle) |
+| **D** | Attendre l'app de montre officielle OsmAnd (PR `osmandapp/OsmAnd#26227`, *draft* en sept. 2026) | Rien à développer | Indéterminé, non publié, pas de calendrier | Suivi, hors périmètre |
+
+**Option A retenue** : elle donne une application Wear OS autonome, testable
+sans téléphone OsmAnd modifié, et l'heuristique de parsing est isolée dans
+une classe unique si le format OsmAnd évolue.
+
+### Rappel de fonctionnement du pont
+
+OsmAnd publie une **notification permanente (ongoing)** pendant la navigation,
+contenant la consigne de manœuvre, l'ETA, la distance restante et la vitesse.
+Sur Wear OS, les notifications du téléphone sont **relayées automatiquement**
+(bridging Bluetooth) dans le `NotificationManager` local de la montre, où un
+`NotificationListenerService` peut les intercepter — y compris leurs extras
+textuels (`EXTRA_TITLE`, `EXTRA_TEXT`, `EXTRA_BIG_TEXT`…). La catégorie
+`Notification.CATEGORY_NAVIGATION`, quand OsmAnd la pose, sert de signalement
+fort dans le parseur.
+
+---
+
+## 3. Architecture retenue (v1)
+
+Application **monomodule** en trois couches strictes, sans framework de DI
+ni de base de données : il n'y a qu'un flux d'état à transporter.
+
+```
+┌────────────────────────── couche présentation ──────────────────────────┐
+│  MainActivity → AppNavHost (SwipeDismissableNavHost)                    │
+│      ├─ HomeScreen        (état de l'écoute, raccourci navigation)      │
+│      └─ NavigationScreen  (manœuvre, métriques, keep-screen-on)          │
+│             ↑ StateFlow<NavigationState>                                │
+│  NavigationViewModel (relais de durée de vie)                           │
+└──────────────────────────────────────────────────────────────────────────┘
+┌──────────────────────────────── couche données ─────────────────────────┐
+│  OsmAndNotificationListener (NotificationListenerService)               │
+│      → OsmAndNotificationParser   (texte → NavigationState, défensif)   │
+│      → NavigationStateRepository  (source de vérité, StateFlow)         │
+└──────────────────────────────────────────────────────────────────────────┘
+┌────────────────────────────── couche domaine ───────────────────────────┐
+│  NavigationState · TurnInstruction · Maneuver (enum) · Formatters       │
+│  (Kotlin pur, testable sans Android)                                    │
+└──────────────────────────────────────────────────────────────────────────┘
+```
+
+### Flux de données
+
+`OsmAnd (téléphone)` → *notification ongoing* → *bridging Wear OS* →
+`NotificationManager montre` → `OsmAndNotificationListener` →
+`OsmAndNotificationParser` → `NavigationStateRepository (StateFlow)` →
+`NavigationViewModel` → écrans Compose.
+
+Règles clés :
+
+- **Le service ne fait jamais d'UI** ; il transforme un évènement système en
+  état. L'UI ne connaît ni les notifications ni OsmAnd.
+- **Le repository est unique** (contenu dans `AppContainer` de
+  `WatchOsmApplication`) : il est partagé entre le service (production) et
+  les ViewModels (consommation).
+- **La clé de notification active** (`activeNotificationKey`) empêche une
+  notification annexe d'OsmAnd (enregistrement de trace, alerte) d'effacer
+  l'état de navigation lorsqu'elle est retirée.
+- **Aucune persistance** : l'état vit en mémoire. La navigation reprend au
+  prochain affichage de la notification, ce qui arrive à chaque poste.
+
+### Garde-fous du parseur
+
+Le texte d'OsmAnd n'est pas un contrat. Le parseur (`OsmAndNotificationParser`)
+applique, dans l'ordre :
+
+1. normalisation **caractère à caractère** (bas de casse, accents ASCII) pour
+   conserver les indices entre chaîne normalisée et chaîne affichée ;
+2. dictionnaire de motifs EN/FR **du plus spécifique au plus général**
+   (« keep left » avant « left », rond-point avant directions) ;
+3. extraction des distances avec interdiction explicite de lire « km/h » comme
+   une distance, et distinction distance-de-manœuvre / distance-restante ;
+4. **acceptation sous garantie** : un résultat n'est accepté que s'il contient
+   un marqueur de navigation, un marqueur de pause, ou si la notification
+   porte la catégorie `navigation` — sinon une notification d'enregistrement
+   de trace (« 12,5 km ») serait prise pour une navigation active.
+
+### Navigation entre écrans
+
+Deux écrans seulement, en `SwipeDismissableNavHost` (le retour latéral système
+fonctionne nativement) :
+
+- le passage à `isActive = true` ouvre automatiquement l'écran navigation ;
+- la fin de navigation laisse l'état « Navigation terminée » affiché 4 s,
+  puis revient à l'accueil ;
+- l'accueil ouvre les réglages système d'accès aux notifications quand
+  l'autorisation manque — sans lui, WatchOsm n'a aucune donnée.
+
+---
+
+## 4. Modèle de données
+
+```kotlin
+NavigationState(
+    isActive, isPaused,
+    instruction: TurnInstruction?(maneuver: Maneuver, distanceMeters, streetName, rawText),
+    remainingDistanceMeters, remainingTimeMinutes, arrivalTimeLabel,
+    speedKmh, speedLimitKmh, destination, updatedAtMillis
+)
+```
+
+- `Maneuver` : enum de 11 valeurs couvrant le vocabulaire OsmAnd (EN/FR).
+- Tout champ est **nullable** : OsmAnd omet régulièrement des informations
+  selon les réglages ; l'UI a une réponse pour chaque absence (tiret, ligne
+  masquée) et ne plante jamais sur un champ manquant.
+- `Formatters` produit les formes françaises (« 5,4 km », « 1 h 05 »).
+
+---
+
+## 5. Structure du projet
+
+```
+WatchOsm/
+├── build.gradle.kts / settings.gradle.kts / gradle/libs.versions.toml
+├── documentation/
+│   ├── architecture.md            ← ce document
+│   └── puml/
+│       ├── usecases.puml          cas d'utilisation
+│       ├── composants.puml        composants
+│       ├── classes.puml           classes
+│       ├── sequence_navigation.puml séquence notification → écran
+│       ├── etats_navigation.puml  machine à états
+│       └── deploiement.puml       déploiement téléphone + montre
+└── wear/                          module unique v1
+    └── src/main/java/fr/barteam/watchosm/
+        ├── WatchOsmApplication.kt        conteneur (AppContainer)
+        ├── domain/                       NavigationState, Maneuver, Formatters
+        ├── data/                         listener, parseur, repository
+        └── ui/                           MainActivity, AppNavHost, ViewModel,
+                                           écrans, ManeuverArrow, thème
+```
+
+---
+
+## 6. Contraintes, risques et atténuation
+
+| Risque | Impact | Atténuation |
+|---|---|---|
+| OsmAnd change le format de sa notification | Consignes non reconnues | Parseur isolé + tests unitaires sur formats connus + filet catégorie `navigation` |
+| **Le `NotificationListenerService` de la montre ne reçoit pas les notifications relayées** (comportement variable selon la version Wear OS et le fabricant) | Aucune donnée | Vérification hardware à faire en premier ; solution de repli = phase 2 (app compagnon téléphone + `Wearable.DataLayer`) |
+| Bridging Wear OS tronque les extras | Données partielles | Champs nullables, rendu dégradé, jamais de crash |
+| Permission d'écoute non accordée | Aucune donnée | Écran d'accueil explicite + bouton d'ouverture des réglages système |
+| Écran qui s'endort pendant le guidage | Inutilisable à vélo/moto | `FLAG_KEEP_SCREEN_ON` sur l'écran navigation tant que `isActive` |
+| Notification d'enregistrement prise pour une navigation | Faux positif | Garde-fou d'acceptation (marqueur ou catégorie) + clé de notification active |
+| Batterie de la montre | Autonomie | Aucun GPS, aucun réseau, aucun réveil périodique : seul un `NotificationListenerService` est lié |
+
+## 7. Tests et vérification
+
+- **Tests unitaires JVM du parseur** (`wear/src/test/.../OsmAndNotificationParserTest`)
+  : 11 cas — formats FR/EN, rond-point, demi-tour, vitesse ≠ distance,
+  notification d'enregistrement rejetée, pause, catégorie navigation, arrivée,
+  légère-gauche, durée composée. Commande : `./gradlew :wear:testDebugUnitTest`.
+- **Validation des diagrammes** : `java -jar plantuml.jar -checkonly documentation/puml/*.puml`.
+- **Lint** : `./gradlew :wear:lint`.
+- Vérification manuelle sur montre : démarrer une navigation OsmAnd, observer
+  l'ouverture automatique de l'écran, la fin et le retour à l'accueil.
+
+## 8. Évolutions planifiées
+
+| Phase | Contenu |
+|---|---|
+| **2** | Module `phone/` : notification listener local (source plus riche) + API AIDL OsmAnd (`pauseNavigation`, `stopNavigation`, `resumeNavigation`) + pont `Wearable.DataLayer` (MessageClient) vers la montre |
+| **3** | Tile de navigation (aperçu permanent), complication de manœuvre, support Always-On Display / mode ambiant natif, profilage batterie |
+| **4** | Suivi de l'app de montre officielle OsmAnd (PR `osmandapp/OsmAnd#26227`) : bascule sur le protocole natif si publié |
+
+---
+
+## 9. Diagrammes
+
+| Diagramme | Fichier | Objectif |
+|---|---|---|
+| Cas d'utilisation | [`puml/usecases.puml`](puml/usecases.puml) | Périmètre fonctionnel et acteurs |
+| Composants | [`puml/composants.puml`](puml/composants.puml) | Découpage en couches et flux |
+| Classes | [`puml/classes.puml`](puml/classes.puml) | Structure statique |
+| Séquence | [`puml/sequence_navigation.puml`](puml/sequence_navigation.puml) | Début/pause/fin d'une navigation |
+| États | [`puml/etats_navigation.puml`](puml/etats_navigation.puml) | Cycle de vie de `NavigationState` |
+| Déploiement | [`puml/deploiement.puml`](puml/deploiement.puml) | Téléphone, relais Bluetooth, montre |
+
+Les diagrammes sont versionnés en **sources PlantUML** ; la rendu PNG/SVG
+s'obtient avec `java -jar plantuml.jar -tsvg documentation/puml/*.puml`
+(PlantUML n'étant pas installé sur la machine de développement actuelle).

+ 127 - 0
documentation/puml/classes.puml

@@ -0,0 +1,127 @@
+@startuml classes
+skinparam defaultFontSize 13
+skinparam classAttributeIconSize 0
+skinparam linetype ortho
+
+title Diagramme de classes — WatchOsm v1
+
+package "domain" as domain #ffffff {
+    enum Maneuver {
+        +DEPART
+        +CONTINUE
+        +SLIGHT_LEFT
+        +LEFT
+        +SHARP_LEFT
+        +UTURN
+        +SHARP_RIGHT
+        +RIGHT
+        +SLIGHT_RIGHT
+        +ROUNDABOUT
+        +ARRIVE
+    }
+
+    class TurnInstruction {
+        +maneuver : Maneuver
+        +distanceMeters : Integer?
+        +streetName : String?
+        +rawText : String
+    }
+
+    class NavigationState {
+        +isActive : Boolean
+        +isPaused : Boolean
+        +instruction : TurnInstruction?
+        +remainingDistanceMeters : Integer?
+        +remainingTimeMinutes : Integer?
+        +arrivalTimeLabel : String?
+        +speedKmh : Integer?
+        +speedLimitKmh : Integer?
+        +destination : String?
+        +updatedAtMillis : Long
+        +{static} IDLE : NavigationState
+    }
+
+    class Formatters {
+        +{static} distance(meters) : String
+        +{static} duration(minutes) : String
+    }
+}
+
+package "data" as data #ffffff {
+    class OsmAndNotificationPayload {
+        +title : CharSequence?
+        +text : CharSequence?
+        +bigText : CharSequence?
+        +subText : CharSequence?
+        +infoText : CharSequence?
+        +category : String?
+    }
+
+    class OsmAndNotificationParser {
+        +parse(payload) : NavigationState?
+        -findManeuver(corpus) : ManeuverMatch?
+        -maneuverDistance(...) : Integer?
+        -extractStreet(...) : String?
+        -normalize(text) : String
+        +{static} OSMAND_PACKAGES : Set<String>
+        +{static} CATEGORY_NAVIGATION : String
+    }
+
+    class NavigationStateRepository {
+        -state : MutableStateFlow<NavigationState>
+        +state : StateFlow<NavigationState>
+        +onOsmAndNotification(key, payload) : Boolean
+        +onOsmAndNotificationRemoved(key)
+        +onListenerDisconnected()
+    }
+
+    class OsmAndNotificationListener {
+        +onNotificationPosted(sbn)
+        +onNotificationRemoved(sbn, rankingMap, reason)
+        +onListenerDisconnected()
+    }
+}
+
+package "application" as app #ffffff {
+    class AppContainer {
+        +navigationStateRepository : NavigationStateRepository
+    }
+
+    class WatchOsmApplication {
+        +container : AppContainer
+    }
+}
+
+package "ui" as ui #ffffff {
+    class NavigationViewModel {
+        +state : StateFlow<NavigationState>
+        +{static} Factory : ViewModelProvider.Factory
+    }
+
+    class AppNavHost {
+        +AppNavHost(viewModel)
+    }
+
+    class HomeScreen
+    class NavigationScreen
+    class MainActivity
+}
+
+TurnInstruction "1" *-- "1" Maneuver
+NavigationState "1" *-- "0..1" TurnInstruction
+
+OsmAndNotificationParser ..> OsmAndNotificationPayload : <<use>>
+NavigationStateRepository "1" --> "1" OsmAndNotificationParser
+OsmAndNotificationListener --> NavigationStateRepository : alimente
+NavigationStateRepository ..> NavigationState : <<create>>
+
+WatchOsmApplication "1" *-- "1" AppContainer
+AppContainer "1" *-- "1" NavigationStateRepository
+
+NavigationViewModel "1" --> "1" NavigationStateRepository : observe
+AppNavHost --> NavigationViewModel
+AppNavHost --> HomeScreen
+AppNavHost --> NavigationScreen
+MainActivity --> AppNavHost
+
+@enduml

+ 51 - 0
documentation/puml/composants.puml

@@ -0,0 +1,51 @@
+@startuml composants
+skinparam componentStyle rectangle
+skinparam defaultFontSize 13
+skinparam packageStyle frame
+
+title Diagramme de composants — WatchOsm v1 (module wear autonome)
+
+package "Téléphone — Android 16" as phone #f5f5f5 {
+    component "OsmAnd+ 5.4.9" as osmand
+    component "Notification de navigation\n(contenu non contractuel)" as notif
+    osmand --> notif
+}
+
+package "Montre — Wear OS 5" as watch #ffffff {
+
+    package "Couche présentation" as ui {
+        component "MainActivity\n(Compose for Wear OS)" as activity
+        component "AppNavHost\n(SwipeDismissableNavHost)" as host
+        component "HomeScreen" as home
+        component "NavigationScreen" as nav
+        component "ManeuverArrow\n(Canvas)" as arrow
+        component "NavigationViewModel" as vm
+    }
+
+    package "Couche données" as data {
+        component "OsmAndNotificationListener\n(NotificationListenerService)" as listener
+        component "OsmAndNotificationParser" as parser
+        component "NavigationStateRepository" as repo
+    }
+
+    component "WatchOsmApplication\n(AppContainer)" as app
+
+    host --> home
+    host --> nav
+    nav --> arrow
+    host --> vm : StateFlow<NavigationState>
+    vm --> repo : lecture du flux
+    listener --> parser : payload textuel
+    parser --> repo : NavigationState?
+    app "1" *-- "*" listener : accès
+    app "1" *-- "1" repo : partagé avec les ViewModels
+}
+
+package "Système Android (montre)" as system #f0f0f0 {
+    component "NotificationManager\n(relais des notifications\ndu téléphone)" as nm
+}
+
+notif ..> nm : relais Bluetooth
+nm --> listener : onNotificationPosted / onNotificationRemoved
+
+@enduml

+ 39 - 0
documentation/puml/deploiement.puml

@@ -0,0 +1,39 @@
+@startuml deploiement
+skinparam defaultFontSize 13
+skinparam nodeFontSize 13
+skinparam artifactFontSize 12
+skinparam componentFontSize 12
+
+title Diagramme de déploiement — WatchOsm v1 (miroir de navigation OsmAnd)
+
+node "Téléphone — Android 16\n(enregistrement du trajet,\ncalcul et guidage)" as phone #f5f5f5 {
+    artifact "OsmAnd+ 5.4.9\n(APK installé)" as osmand
+    artifact "Notification de navigation\nongoing\n(catégorie « navigation »)" as notif
+}
+
+node "Montre — Wear OS 5\n(sur le poignet)" as watch #ffffff {
+    artifact "WatchOsm v0.1.0\n(watch-debug.apk)" as apk {
+        component "MainActivity\n+ AppNavHost\n(Compose for Wear OS)" as ui
+        component "OsmAndNotificationListener\n(service lié au système)" as listener
+        component "NavigationStateRepository\n(flux en mémoire)" as repo
+    }
+    artifact "Système Android Wear OS\nNotificationManager" as nm
+}
+
+cloud "Relais Bluetooth\nWear OS (bridging)" as bt #e8f5e9
+
+osmand --> notif : publie / retire
+notif --> bt : relayée par le téléphone\n(standard Wear OS)
+bt --> nm : notification locale\nsur la montre
+nm --> listener : onNotificationPosted /\nonNotificationRemoved
+listener --> repo : état parsé
+repo --> ui : StateFlow<NavigationState>
+
+note right of apk
+  Aucune donnée réseau, aucun compte,
+  aucun accès GPS : la montre n'interprète
+  que la notification déjà produite par
+  OsmAnd sur le téléphone.
+end note
+
+@enduml

+ 25 - 0
documentation/puml/etats_navigation.puml

@@ -0,0 +1,25 @@
+@startuml etats
+skinparam defaultFontSize 13
+
+title Diagramme d'états — cycle de vie de l'état de navigation (NavigationState)
+
+[*] --> Inactif : démarrage de l'application\n(state = IDLE)
+
+Inactif --> Actif : notification OsmAnd acceptée\n(catégorie « navigation »\nou marqueur de manœuvre détecté)
+Inactif --> Actif : notification « Navigation en pause »\nsans manœuvre
+
+Actif --> Actif : nouvelle consigne OsmAnd\n(mise à jour manœuvre,\ndistance, ETA, vitesse)
+Actif --> EnPause : texte « en pause » / « paused »\ndétecté
+
+EnPause --> Actif : navigation reprise\n(texte sans marqueur de pause)
+EnPause --> EnPause : mise à jour de l'ETA
+
+Actif --> Fini : notification retirée\nou service d'écoute déconnecté
+EnPause --> Fini : notification retirée\nou service d'écoute déconnecté
+
+Fini --> Inactif : retour automatique\nà l'accueil après 4 s\n(_state = IDLE)
+
+Fini --> Actif : nouvelle navigation\ndémarrée pendant l'affichage
+
+Inactif --> [*]
+@enduml

+ 43 - 0
documentation/puml/sequence_navigation.puml

@@ -0,0 +1,43 @@
+@startuml sequence
+skinparam defaultFontSize 13
+skinparam sequenceFontSize 13
+
+title Diagramme de séquence — Détection automatique d'une navigation OsmAnd
+
+actor "Utilisateur\nOsmAnd+" as user
+entity "OsmAnd+\n(téléphone)" as osmand
+entity "System\nrelais notif" as sys
+entity "OsmAndNotificationListener\n(montre)" as listener
+entity "OsmAndNotificationParser" as parser
+entity "NavigationStateRepository" as repo
+entity "NavigationViewModel" as vm
+entity "UI\nNavigationScreen" as ui
+
+== Démarrage navigation ==
+user -> osmand : Démarrer la navigation (Osmand+ 5.4.9)
+osmand -> sys : Publie notification « Navigation » (catégorie navigation)
+sys -> listener : onNotificationPosted(sbn)
+listener -> parser : parse(payload texte)
+parser --> listener : NavigationState(isActive=true, instruction, métriques)
+listener -> repo : onOsmAndNotification(key, parsed)
+repo -> vm : _state se met à jour (StateFlow)
+vm -> ui : collectAsStateWithLifecycle -> affiche flèche, distances, ETA, vitesse
+ui -> ui : FLAG_KEEP_SCREEN_ON activé
+
+== Pause navigation ==
+osmand -> sys : Notification « Navigation en pause »
+sys -> listener : onNotificationPosted(sbn)
+listener -> parser : parse -> isPaused = true
+parser --> listener : NavigationState(isActive=true, isPaused=true)
+listener -> repo : onOsmAndNotification(key, parsed)
+vm -> ui : rafraîchit (message « Navigation en pause », temps maintenu)
+
+== Fin navigation ==
+osmand -> sys : Retire la notification de navigation
+sys -> listener : onNotificationRemoved(sbn)
+listener -> repo : onOsmAndNotificationRemoved(key)
+repo -> vm : _state = NavigationState.IDLE
+vm -> ui : attend 4s, alors repasse à HomeScreen (swipe-dismiss)
+ui -> ui : FLAG_KEEP_SCREEN_ON retiré
+
+@enduml

+ 37 - 0
documentation/puml/usecases.puml

@@ -0,0 +1,37 @@
+@startuml usecases
+left to right direction
+skinparam defaultFontSize 14
+skinparam usecaseFontSize 13
+
+title Diagramme de cas d'utilisation — WatchOsm v1
+
+actor "Utilisateur\n(montre au poignet)" as user
+actor "OsmAnd+ 5.4.9\n(téléphone, Android 16)" as osmand #e8e8e8
+
+rectangle "WatchOsm — application Wear OS 5" {
+    usecase "Activer l'accès de WatchOsm\naux notifications" as UC_CONFIG
+    usecase "Suivre la navigation\nen cours (écran dédié)" as UC_FOLLOW
+    usecase "Voir la prochaine manœuvre\n(flèche, distance, rue)" as UC_TURN
+    usecase "Voir la distance restante,\nl'ETA et la vitesse" as UC_TRIP
+    usecase "Recevoir le début de navigation\nautomatiquement" as UC_START
+    usecase "Recevoir la fin ou la pause\nde navigation" as UC_END
+    usecase "Garder l'écran allumé\npendant la navigation" as UC_WAKE
+    usecase "Revenir à l'écran d'accueil\net vérifier l'état de l'écoute" as UC_HOME
+}
+
+user --> UC_CONFIG
+user --> UC_FOLLOW
+user --> UC_TURN
+user --> UC_TRIP
+user --> UC_HOME
+
+UC_FOLLOW .> UC_TURN : <<include>>
+UC_FOLLOW .> UC_TRIP : <<include>>
+UC_CONFIG .> UC_START : <<extend>>
+
+osmand --> UC_START : publie la notification
+osmand --> UC_END : retire / met en pause\nla notification
+UC_START --> UC_FOLLOW
+UC_END --> UC_HOME
+UC_FOLLOW .> UC_WAKE : <<include>>
+@enduml

+ 5 - 0
gradle.properties

@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official

+ 31 - 0
gradle/libs.versions.toml

@@ -0,0 +1,31 @@
+[versions]
+agp = "8.9.2"
+kotlin = "2.1.20"
+coroutines = "1.10.1"
+coreKtx = "1.15.0"
+lifecycle = "2.9.0"
+activityCompose = "1.10.1"
+composeBom = "2025.05.00"
+wearCompose = "1.6.2"
+junit = "4.13.2"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.8.9" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" }
+androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
+androidx-wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 252 - 0
gradlew

@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 94 - 0
gradlew.bat

@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 24 - 0
settings.gradle.kts

@@ -0,0 +1,24 @@
+pluginManagement {
+    repositories {
+        google {
+            content {
+                includeGroupByRegex("com\\.android.*")
+                includeGroupByRegex("com\\.google.*")
+                includeGroupByRegex("androidx.*")
+            }
+        }
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "WatchOsm"
+include(":wear")

+ 69 - 0
wear/build.gradle.kts

@@ -0,0 +1,69 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+    alias(libs.plugins.kotlin.compose)
+}
+
+android {
+    namespace = "fr.barteam.watchosm"
+    compileSdk = 35
+
+    defaultConfig {
+        applicationId = "fr.barteam.watchosm"
+        minSdk = 34
+        targetSdk = 35
+        versionCode = 1
+        versionName = "0.1.0"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro",
+            )
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    buildFeatures {
+        compose = true
+    }
+
+    lint {
+        // Le détecteur RememberInComposition plante (IncompatibleClassChangeError)
+        // avec la paire AGP 8.7.3 / Kotlin 2.1.20 : incompatibilité UAST connue.
+        disable += "RememberInComposition"
+    }
+}
+
+kotlin {
+    compilerOptions {
+        jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+    }
+}
+
+dependencies {
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.lifecycle.viewmodel.compose)
+    implementation(libs.androidx.lifecycle.runtime.compose)
+    implementation(libs.androidx.navigation.compose)
+    implementation(libs.androidx.activity.compose)
+    implementation(libs.kotlinx.coroutines.android)
+
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.androidx.compose.ui.tooling.preview)
+    implementation(libs.androidx.wear.compose.material)
+    implementation(libs.androidx.wear.compose.foundation)
+    implementation(libs.androidx.wear.compose.navigation)
+
+    debugImplementation(libs.androidx.compose.ui.tooling)
+
+    testImplementation(libs.junit)
+}

+ 1 - 0
wear/proguard-rules.pro

@@ -0,0 +1 @@
+# Règles ProGuard/R8 — minification désactivée en v1 ; conservé pour la release.

+ 48 - 0
wear/src/main/AndroidManifest.xml

@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <application
+        android:name=".WatchOsmApplication"
+        android:label="@string/app_name"
+        android:theme="@style/Theme.WatchOsm"
+        tools:ignore="MissingApplicationIcon">
+
+        <uses-library
+            android:name="com.google.android.wearable"
+            android:required="false" />
+
+        <meta-data
+            android:name="com.google.android.wearable.standalone"
+            android:value="true" />
+
+        <activity
+            android:name=".ui.MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="com.google.android.wearable.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <!--
+            Source de données unique de v1 : la notification de navigation OsmAnd,
+            relayée par le téléphone. L'accès doit être activé manuellement par
+            l'utilisateur dans les réglages de la montre.
+        -->
+        <service
+            android:name=".data.OsmAndNotificationListener"
+            android:exported="true"
+            android:label="@string/notification_listener_label"
+            android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
+            <intent-filter>
+                <action android:name="android.service.notification.NotificationListenerService" />
+            </intent-filter>
+        </service>
+    </application>
+</manifest>

+ 26 - 0
wear/src/main/java/fr/barteam/watchosm/WatchOsmApplication.kt

@@ -0,0 +1,26 @@
+package fr.barteam.watchosm
+
+import android.app.Application
+import fr.barteam.watchosm.data.NavigationStateRepository
+
+/**
+ * Conteneur de dépendances minimal (pas de DI externe : deux objets ne
+ * justifient pas Hilt). Le repository est partagé entre le service d'écoute
+ * (production d'état) et les ViewModels (consommation d'état).
+ */
+class AppContainer {
+    val navigationStateRepository: NavigationStateRepository by lazy {
+        NavigationStateRepository()
+    }
+}
+
+class WatchOsmApplication : Application() {
+
+    lateinit var container: AppContainer
+        private set
+
+    override fun onCreate() {
+        super.onCreate()
+        container = AppContainer()
+    }
+}

+ 52 - 0
wear/src/main/java/fr/barteam/watchosm/data/NavigationStateRepository.kt

@@ -0,0 +1,52 @@
+package fr.barteam.watchosm.data
+
+import android.os.SystemClock
+import fr.barteam.watchosm.domain.NavigationState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Détient l'état de navigation unique de l'application et en garantit le
+ * thread-safety : les notifications arrivent sur le thread binder du système,
+ * les écrans consomment le flux depuis le thread principal.
+ *
+ * La clé de notification ([activeNotificationKey]) évite qu'une notification
+ * annexe d'OsmAnd (enregistrement de trace, alerte) efface l'état de
+ * navigation en étant retirée.
+ */
+class NavigationStateRepository(
+    private val parser: OsmAndNotificationParser = OsmAndNotificationParser(),
+    private val clock: () -> Long = SystemClock::elapsedRealtime,
+) {
+
+    private val _state = MutableStateFlow(NavigationState.IDLE)
+    val state: StateFlow<NavigationState> = _state.asStateFlow()
+
+    private var activeNotificationKey: String? = null
+
+    /**
+     * @param key clé système de la notification (StatusBarNotification.key).
+     * @return true si la notification contenait une navigation exploitable.
+     */
+    fun onOsmAndNotification(key: String, payload: OsmAndNotificationPayload): Boolean {
+        val parsed = parser.parse(payload) ?: return false
+        activeNotificationKey = key
+        _state.value = parsed.copy(updatedAtMillis = clock())
+        return true
+    }
+
+    /** Une notification OsmAnd a disparu : on n'efface que si c'était la nôtre. */
+    fun onOsmAndNotificationRemoved(key: String) {
+        if (key == activeNotificationKey) {
+            activeNotificationKey = null
+            _state.value = NavigationState.IDLE
+        }
+    }
+
+    /** Service d'écoute déconnecté : l'état courant n'est plus alimenté. */
+    fun onListenerDisconnected() {
+        activeNotificationKey = null
+        _state.value = NavigationState.IDLE
+    }
+}

+ 55 - 0
wear/src/main/java/fr/barteam/watchosm/data/OsmAndNotificationListener.kt

@@ -0,0 +1,55 @@
+package fr.barteam.watchosm.data
+
+import android.app.Notification
+import android.service.notification.NotificationListenerService
+import android.service.notification.StatusBarNotification
+import fr.barteam.watchosm.WatchOsmApplication
+
+/**
+ * Source de données de v1 : écoute les notifications publiées par OsmAnd et
+ * en reflète l'état dans [NavigationStateRepository].
+ *
+ * Sur Wear OS, les notifications du téléphone sont relayées (bridged) vers la
+ * montre et apparaissent dans le NotificationManager local : ce service les
+ * reçoit donc sans rien demander au téléphone. L'utilisateur doit seulement
+ * activer l'accès de WatchOsm dans les réglages de la montre.
+ *
+ * Le service ne notifie rien et n'a pas d'UI propre : il transforme un flux
+ * de notifications en flux d'état, ce qui rend l'UI indépendante de la durée
+ * de vie des notifications.
+ */
+class OsmAndNotificationListener : NotificationListenerService() {
+
+    private val repository: NavigationStateRepository
+        get() = (application as WatchOsmApplication).container.navigationStateRepository
+
+    override fun onNotificationPosted(sbn: StatusBarNotification) {
+        if (sbn.packageName !in OsmAndNotificationParser.OSMAND_PACKAGES) return
+        val notification = sbn.notification ?: return
+        val extras = notification.extras
+        repository.onOsmAndNotification(
+            key = sbn.key,
+            payload = OsmAndNotificationPayload(
+                title = extras.getCharSequence(Notification.EXTRA_TITLE),
+                text = extras.getCharSequence(Notification.EXTRA_TEXT),
+                bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT),
+                subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT),
+                infoText = extras.getCharSequence(Notification.EXTRA_INFO_TEXT),
+                category = notification.category,
+            ),
+        )
+    }
+
+    override fun onNotificationRemoved(
+        sbn: StatusBarNotification,
+        rankingMap: RankingMap?,
+        reason: Int,
+    ) {
+        if (sbn.packageName !in OsmAndNotificationParser.OSMAND_PACKAGES) return
+        repository.onOsmAndNotificationRemoved(sbn.key)
+    }
+
+    override fun onListenerDisconnected() {
+        repository.onListenerDisconnected()
+    }
+}

+ 312 - 0
wear/src/main/java/fr/barteam/watchosm/data/OsmAndNotificationParser.kt

@@ -0,0 +1,312 @@
+package fr.barteam.watchosm.data
+
+import fr.barteam.watchosm.domain.Maneuver
+import fr.barteam.watchosm.domain.NavigationState
+import fr.barteam.watchosm.domain.TurnInstruction
+import java.util.Locale
+
+/** Règle d'appariement : un type de manœuvre et ses motifs EN/FR. */
+private data class ManeuverRule(val maneuver: Maneuver, val patterns: List<Regex>)
+
+/**
+ * Extrait l'état de navigation OsmAnd à partir des extras d'une notification.
+ *
+ * Les champs sont déjà détachés du type Android pour rester testables en JVM
+ * pure (voir les tests unitaires du parseur).
+ */
+data class OsmAndNotificationPayload(
+    val title: CharSequence?,
+    val text: CharSequence?,
+    val bigText: CharSequence?,
+    val subText: CharSequence?,
+    val infoText: CharSequence?,
+    val category: String?,
+)
+
+/**
+ * Parseur défensif du contenu de navigation d'OsmAnd.
+ *
+ * OsmAnd ne publie aucun contrat de format pour le texte de sa notification de
+ * navigation permanente : le contenu varie avec la langue, le profil et les
+ * réglages de voice prompts. Le parseur travaille donc ainsi :
+ *
+ *  1. corpus = concaténation des extras textuels, normalisé (bas de casse,
+ *     accents ASCII, espaces réduits) — la normalisation est caractère à
+ *     caractère afin que les indices restent valables sur l'original ;
+ *  2. détection des marqueurs (pause, manœuvre) par dictionnaire de motifs
+ *     EN/FR, ordonnés du plus spécifique au plus général (un « keep left »
+ *     doit être classé avant un simple « left ») ;
+ *  3. extraction des distances/heures par expressions régulières, avec un
+ *     garde-fou contre les unités de vitesse (« km/h » n'est pas une distance) ;
+ *  4. acceptation du résultat seulement si un marqueur de navigation a été
+ *     trouvé, ou si la notification porte la catégorie navigation. Sans cette
+ *     garde, une notification d'enregistrement de trace OsmAnd (« 12,5 km »)
+ *     serait prise pour une navigation active.
+ *
+ * Chaque heuristique est isolée dans une méthode privée : face à un format
+ * OsmAnd non reconnu, on ajuste un motif sans toucher au reste de la chaîne.
+ */
+class OsmAndNotificationParser {
+
+    fun parse(payload: OsmAndNotificationPayload): NavigationState? {
+        val parts = listOfNotNull(
+            payload.title,
+            payload.text,
+            payload.bigText,
+            payload.subText,
+            payload.infoText,
+        ).map { it.toString() }
+        if (parts.isEmpty()) return null
+
+        val original = parts.joinToString(SEPARATOR)
+        val corpus = normalize(original)
+
+        val isCategoryNavigation = payload.category == CATEGORY_NAVIGATION
+        val isPaused = PAUSED_PATTERNS.any { it.containsMatchIn(corpus) }
+        val match = findManeuver(corpus)
+
+        // Garde-fou d'acceptation : voir la doc de classe.
+        if (match == null && !isPaused && !isCategoryNavigation) return null
+
+        val distances = DISTANCE_PATTERN.findAll(original)
+            .map { DistanceToken(it.range, toMeters(it.groupValues[1], it.groupValues[2])) }
+            .toList()
+
+        val maneuverDistance = if (match != null) {
+            maneuverDistance(distances, match.range)
+        } else {
+            null
+        }
+
+        val speeds = extractSpeeds(corpus)
+
+        return NavigationState(
+            isActive = true,
+            isPaused = isPaused,
+            instruction = match?.let {
+                TurnInstruction(
+                    maneuver = it.maneuver,
+                    distanceMeters = maneuverDistance,
+                    streetName = extractStreet(original, it),
+                    rawText = original,
+                )
+            },
+            remainingDistanceMeters = remainingDistance(distances, maneuverDistance),
+            remainingTimeMinutes = extractRemainingMinutes(corpus),
+            arrivalTimeLabel = extractArrivalLabel(original),
+            speedKmh = speeds.firstOrNull(),
+            speedLimitKmh = speeds.getOrNull(1),
+        )
+    }
+
+    // ---------------------------------------------------------------- marqueurs
+
+    private data class ManeuverMatch(val maneuver: Maneuver, val range: IntRange)
+
+    private fun findManeuver(corpus: String): ManeuverMatch? {
+        for (rule in MANEUVER_RULES) {
+            for (pattern in rule.patterns) {
+                val found = pattern.find(corpus)
+                if (found != null) return ManeuverMatch(rule.maneuver, found.range)
+            }
+        }
+        return null
+    }
+
+    // --------------------------------------------------------------- distances
+
+    private data class DistanceToken(val range: IntRange, val meters: Int)
+
+    /**
+     * Distance avant la manœuvre : dernier jeton situé avant le mot-clé de la
+     * manœuvre (« In 300 m turn right »), sinon premier jeton du corpus
+     * (« Turn right in 300 m »), sinon null.
+     */
+    private fun maneuverDistance(distances: List<DistanceToken>, maneuverRange: IntRange): Int? {
+        if (distances.isEmpty()) return null
+        val before = distances.filter { it.range.last < maneuverRange.first }
+        return before.lastOrNull()?.meters ?: distances.first().meters
+    }
+
+    /**
+     * Distance restante sur le trajet : tous les jetons sauf celui de la
+     * manœuvre, en prenant le plus grand (« 300 m » de manœuvre ne doit pas
+     * écraser « 5 km » de trajet). Null s'il n'y a qu'un seul jeton.
+     */
+    private fun remainingDistance(distances: List<DistanceToken>, maneuverDistance: Int?): Int? {
+        val others = distances.filter { it.meters != maneuverDistance }
+        return others.maxOfOrNull { it.meters }
+    }
+
+    private fun toMeters(value: String, unit: String): Int {
+        val number = value.replace(',', '.').toDoubleOrNull() ?: return 0
+        return when (unit.lowercase(Locale.ROOT)) {
+            "km" -> (number * 1000).toInt()
+            "mi" -> (number * 1609.34).toInt()
+            else -> number.toInt()
+        }
+    }
+
+    // ------------------------------------------------------------------ temps
+
+    private fun extractRemainingMinutes(corpus: String): Int? {
+        val hoursAndMinutes = HOURS_MINUTES_PATTERN.find(corpus)
+        if (hoursAndMinutes != null) {
+            val hours = hoursAndMinutes.groupValues[1].toInt()
+            val minutes = hoursAndMinutes.groupValues[2].toInt()
+            return hours * 60 + minutes
+        }
+        return MINUTES_PATTERN.find(corpus)?.groupValues?.get(1)?.toInt()
+    }
+
+    private fun extractArrivalLabel(original: String): String? =
+        ARRIVAL_PATTERN.find(original)?.groupValues?.get(1)
+
+    private fun extractSpeeds(corpus: String): List<Int> =
+        SPEED_PATTERN.findAll(corpus)
+            .mapNotNull { it.groupValues[1].replace(',', '.').toDoubleOrNull() }
+            .map { it.toInt() }
+            .toList()
+
+    // ------------------------------------------------------------------- rue
+
+    private val streetSeparators = charArrayOf('·', '\n', '|', ';', '(', ',')
+
+    /**
+     * Rue après le mot-clé de manœuvre : sous-chaîne d'origine (accents
+     * conservés) jusqu'au séparateur, débarrassée des prépositions de liaison
+     * (« onto », « sur », …). La normalisation est caractère à caractère, donc
+     * les indices calculés dessus restent valides sur l'original.
+     */
+    private fun extractStreet(original: String, match: ManeuverMatch): String? {
+        // On saute le reste du mot-clé : un motif comme « \barriv » ne couvre
+        // pas tout le mot et laisserait un fragment (« e à destination »).
+        var from = match.range.last + 1
+        while (from < original.length && !original[from].isWhitespace()) from++
+        if (from >= original.length) return null
+        var candidate = original.substring(from)
+        val cut = candidate.indexOfAny(streetSeparators)
+        if (cut >= 0) candidate = candidate.substring(0, cut)
+        candidate = candidate.trim()
+
+        // Élimine les prépositions de liaison (au plus trois d'affilée).
+        repeat(LINKING_WORDS_MAX) {
+            val first = candidate.split(' ', '\t').firstOrNull() ?: return@repeat
+            if (first.lowercase(Locale.FRANCE) in LINKING_WORDS) {
+                candidate = candidate.substring(first.length).trim()
+            } else {
+                return@repeat
+            }
+        }
+
+        if (candidate.isBlank()) return null
+        return candidate.take(MAX_STREET_LENGTH).trimEnd()
+    }
+
+    // ----------------------------------------------------------- normalisation
+
+    /**
+     * Bas de casse + ASCII, un caractère pour un caractère (les espaces sont
+     * conservés y compris en double) : les indices calculés sur le corpus
+     * restent donc directement utilisables sur la chaîne d'origine.
+     */
+    private fun normalize(text: String): String {
+        val builder = StringBuilder(text.length)
+        for (char in text) {
+            val lower = char.lowercaseChar()
+            val folded = ACCENT_FOLD[lower] ?: lower
+            builder.append(if (folded.isWhitespace()) ' ' else folded)
+        }
+        return builder.toString()
+    }
+
+    companion object {
+        /** Identifiants de paquets OsmAnd (payant, gratuit, ancien). */
+        val OSMAND_PACKAGES = setOf("net.osmand.plus", "net.osmand.free", "net.osmand")
+
+        const val CATEGORY_NAVIGATION = "navigation"
+
+        /** Séparateur entre les segments textuels de la notification. */
+        private const val SEPARATOR = "\n"
+
+        private const val MAX_STREET_LENGTH = 60
+        private const val LINKING_WORDS_MAX = 3
+
+        private val LINKING_WORDS = setOf(
+            // EN
+            "onto", "on", "to", "in", "for", "via", "the", "and",
+            // FR
+            "sur", "vers", "dans", "par", "au", "aux", "la", "le", "les", "et", "à",
+        )
+
+        // Motifs appliqués sur le corpus normalisé (bas de casse, sans accents).
+
+        private val PAUSED_PATTERNS = listOf(
+            Regex("""\bpaus(e|ed|ing)\b"""),
+            Regex("""en pause"""),
+            Regex("""navigation suspendue"""),
+        )
+
+        private val MANEUVER_RULES = maneuverRules(
+            Maneuver.UTURN to listOf("demi-tour", """u-?turn""", "turn around"),
+            Maneuver.ARRIVE to listOf("""\barriv""", "destination reached"),
+            Maneuver.ROUNDABOUT to listOf(
+                "roundabout", """rond[-\s]?point""", "carrefour amenage", "traffic circle",
+            ),
+            Maneuver.SHARP_LEFT to listOf(
+                "sharp left", "hard left", "virement a gauche", "virag\\w* a gauche",
+                "fortement a gauche", "gauche forte",
+            ),
+            Maneuver.SHARP_RIGHT to listOf(
+                "sharp right", "hard right", "virement a droite", "virag\\w* a droite",
+                "fortement a droite", "droite forte",
+            ),
+            Maneuver.SLIGHT_LEFT to listOf(
+                "slight left", "keep left", "bear left",
+                """leg\w* a gauche""", "gardez a gauche", "gauche leg",
+            ),
+            Maneuver.SLIGHT_RIGHT to listOf(
+                "slight right", "keep right", "bear right",
+                """leg\w* a droite""", "gardez a droite", "droite leg",
+            ),
+            Maneuver.LEFT to listOf("turn left", "a gauche", "left onto", "left into"),
+            Maneuver.RIGHT to listOf("turn right", "a droite", "right onto", "right into"),
+            Maneuver.CONTINUE to listOf(
+                "continue", "tout droit", "straight", "ahead", "poursuivez", "restez",
+            ),
+            Maneuver.DEPART to listOf("""\bhead\b""", "partez", "depart"),
+        )
+
+        /** L'ordre d'appel est l'ordre de spécificité : voir [MANEUVER_RULES]. */
+        private fun maneuverRules(
+            vararg rules: Pair<Maneuver, List<String>>,
+        ): List<ManeuverRule> = rules.map { (maneuver, patterns) ->
+            ManeuverRule(maneuver, patterns.map { Regex(it) })
+        }
+
+        // Expressions appliquées sur la chaîne d'origine pour l'affichage
+        // (rue et heure conservent leurs accents).
+
+        /**
+         * « km/h » est exclu explicitement : sans ce garde-fou, « 83 km/h »
+         * serait lu comme une distance de 83 km.
+         */
+        private val DISTANCE_PATTERN = Regex("""(\d+(?:[.,]\d+)?)\s*(km|m|mi)\b(?!\s*/)""")
+
+        private val HOURS_MINUTES_PATTERN = Regex("""(\d{1,2})\s*(?:h|hr)(?:eure)?s?\s*(\d{1,2})\s*min""")
+        private val MINUTES_PATTERN = Regex("""(\d{1,3})\s*min\b""")
+        private val ARRIVAL_PATTERN = Regex("""\b(\d{1,2}:\d{2})\b""")
+        private val SPEED_PATTERN = Regex("""(\d{1,3}(?:[.,]\d+)?)\s*km\s*/\s*h""")
+
+        /** Remplacement accent -> ASCII, un caractère pour un caractère. */
+        private val ACCENT_FOLD: Map<Char, Char> = buildMap {
+            put('à', 'a'); put('á', 'a'); put('â', 'a'); put('ä', 'a'); put('ã', 'a'); put('å', 'a')
+            put('è', 'e'); put('é', 'e'); put('ê', 'e'); put('ë', 'e')
+            put('ì', 'i'); put('í', 'i'); put('î', 'i'); put('ï', 'i')
+            put('ò', 'o'); put('ó', 'o'); put('ô', 'o'); put('ö', 'o'); put('õ', 'o')
+            put('ù', 'u'); put('ú', 'u'); put('û', 'u'); put('ü', 'u')
+            put('ç', 'c'); put('ñ', 'n'); put('ý', 'y'); put('ÿ', 'y')
+            put('œ', 'o'); put('æ', 'a'); put('ß', 's')
+        }
+    }
+}

+ 22 - 0
wear/src/main/java/fr/barteam/watchosm/domain/Formatters.kt

@@ -0,0 +1,22 @@
+package fr.barteam.watchosm.domain
+
+import java.util.Locale
+
+/** Formatage des grandeurs pour l'affichage poignet (conventions françaises). */
+object Formatters {
+
+    /** « 500 m », « 2 km », « 5,4 km ». */
+    fun distance(meters: Int): String {
+        if (meters < 1000) return "$meters m"
+        val km = meters / 1000
+        val rest = meters % 1000
+        if (rest == 0) return "$km km"
+        return String.format(Locale.FRANCE, "%d,%d km", km, rest / 100)
+    }
+
+    /** « 12 min », « 1 h 05 ». */
+    fun duration(minutes: Int): String {
+        if (minutes < 60) return "$minutes min"
+        return String.format(Locale.FRANCE, "%d h %02d", minutes / 60, minutes % 60)
+    }
+}

+ 41 - 0
wear/src/main/java/fr/barteam/watchosm/domain/Maneuver.kt

@@ -0,0 +1,41 @@
+package fr.barteam.watchosm.domain
+
+/**
+ * Nature de la manœuvre à venir, telle que déduite du texte de la notification
+ * OsmAnd. Les valeurs couvrent le vocabulaire de consignes OsmAnd (EN + FR) ;
+ * le rapprochement texte -> enum se fait dans [fr.barteam.watchosm.data.OsmAndNotificationParser].
+ */
+enum class Maneuver {
+    /** Départ : début de navigation. */
+    DEPART,
+
+    /** Poursuite tout droit (intersection ou changement de dénomination de rue). */
+    CONTINUE,
+
+    /** Légère gauche / keep left. */
+    SLIGHT_LEFT,
+
+    /** Tourner à gauche. */
+    LEFT,
+
+    /** Virement à gauche (sharp / hard left). */
+    SHARP_LEFT,
+
+    /** Demi-tour. */
+    UTURN,
+
+    /** Virement à droite (sharp / hard right). */
+    SHARP_RIGHT,
+
+    /** Tourner à droite. */
+    RIGHT,
+
+    /** Légère droite / keep right. */
+    SLIGHT_RIGHT,
+
+    /** Rond-point. */
+    ROUNDABOUT,
+
+    /** Arrivée à destination. */
+    ARRIVE,
+}

+ 45 - 0
wear/src/main/java/fr/barteam/watchosm/domain/NavigationState.kt

@@ -0,0 +1,45 @@
+package fr.barteam.watchosm.domain
+
+/**
+ * Une consigne de navigation isolée, extraite de la notification OsmAnd courante.
+ *
+ * @property distanceMeters distance avant la manœuvre en mètres, null si le
+ *   texte de la notification ne la mentionne pas.
+ * @property streetName rue/street concernée si le texte la mentionne, sinon null.
+ * @property rawText texte source dont la consigne a été déduite (diagnostic).
+ */
+data class TurnInstruction(
+    val maneuver: Maneuver,
+    val distanceMeters: Int?,
+    val streetName: String? = null,
+    val rawText: String = "",
+)
+
+/**
+ * Instantané de la navigation OsmAnd refleté par la montre.
+ *
+ * Toute information absente de la notification est null : OsmAnd publie des
+ * contenus variables selon le profil et les réglages de voice prompts, le
+ * modèle ne doit donc jamais supposer qu'un champ est présent.
+ */
+data class NavigationState(
+    /** true tant que la notification de navigation OsmAnd existe. */
+    val isActive: Boolean = false,
+    /** true si OsmAnd signale une navigation en pause. */
+    val isPaused: Boolean = false,
+    val instruction: TurnInstruction? = null,
+    val remainingDistanceMeters: Int? = null,
+    val remainingTimeMinutes: Int? = null,
+    /** Heure d'arrivée formatée par OsmAnd (ex. « 18:42 »), telle quelle. */
+    val arrivalTimeLabel: String? = null,
+    val speedKmh: Int? = null,
+    val speedLimitKmh: Int? = null,
+    val destination: String? = null,
+    /** Horodatage de dernière mise à jour (SystemClock.elapsedRealtime). */
+    val updatedAtMillis: Long = 0L,
+) {
+    companion object {
+        /** État sans navigation : utilisé au démarrage et à l'arrêt de navigation. */
+        val IDLE = NavigationState()
+    }
+}

+ 70 - 0
wear/src/main/java/fr/barteam/watchosm/ui/AppNavHost.kt

@@ -0,0 +1,70 @@
+package fr.barteam.watchosm.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.compose.composable
+import androidx.wear.compose.navigation.SwipeDismissableNavHost
+import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
+import fr.barteam.watchosm.ui.screens.HomeScreen
+import fr.barteam.watchosm.ui.screens.NavigationScreen
+import kotlinx.coroutines.delay
+
+private const val ROUTE_HOME = "home"
+private const val ROUTE_NAVIGATION = "navigation"
+
+/** Durée d'affichage de l'état « Navigation terminée » avant retour. */
+private const val FINISHED_DISPLAY_MILLIS = 4_000L
+
+/**
+ * graphe de navigation de l'application (deux écrans seulement : sur montre,
+ * une navigation profonde est un obstacle, pas une aide).
+ *
+ * Transitions pilotées par l'état OsmAnd :
+ *  - début de navigation -> ouverture automatique de l'écran navigation ;
+ *  - fin de navigation -> retour à l'accueil après un court délai.
+ */
+@Composable
+fun AppNavHost(
+    viewModel: NavigationViewModel = viewModel(factory = NavigationViewModel.Factory),
+) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    val navController = rememberSwipeDismissableNavController()
+    val context = LocalContext.current
+    var wasActive by remember { mutableStateOf(false) }
+
+    LaunchedEffect(state.isActive) {
+        val route = navController.currentBackStackEntry?.destination?.route
+        if (state.isActive) {
+            wasActive = true
+            if (route != ROUTE_NAVIGATION) navController.navigate(ROUTE_NAVIGATION)
+        } else if (wasActive && route == ROUTE_NAVIGATION) {
+            wasActive = false
+            delay(FINISHED_DISPLAY_MILLIS)
+            navController.popBackStack(ROUTE_HOME, inclusive = false)
+        }
+    }
+
+    SwipeDismissableNavHost(
+        navController = navController,
+        startDestination = ROUTE_HOME,
+    ) {
+        composable(ROUTE_HOME) {
+            HomeScreen(
+                state = state,
+                listenerEnabled = isNotificationAccessEnabled(context),
+                onOpenNotificationSettings = { openNotificationAccessSettings(context) },
+                onOpenNavigation = { navController.navigate(ROUTE_NAVIGATION) },
+            )
+        }
+        composable(ROUTE_NAVIGATION) {
+            NavigationScreen(state = state)
+        }
+    }
+}

+ 22 - 0
wear/src/main/java/fr/barteam/watchosm/ui/MainActivity.kt

@@ -0,0 +1,22 @@
+package fr.barteam.watchosm.ui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+
+/**
+ * Point d'entrée unique : une activité, deux écrans. Pas de fragments ni de
+ * navigation multi-activité (recommandation Wear OS pour les profils d'écran
+ * linéaires et peu profonds).
+ */
+class MainActivity : ComponentActivity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContent {
+            WatchOsmTheme {
+                AppNavHost()
+            }
+        }
+    }
+}

+ 32 - 0
wear/src/main/java/fr/barteam/watchosm/ui/NavigationViewModel.kt

@@ -0,0 +1,32 @@
+package fr.barteam.watchosm.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import fr.barteam.watchosm.WatchOsmApplication
+import fr.barteam.watchosm.data.NavigationStateRepository
+import fr.barteam.watchosm.domain.NavigationState
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * Expose l'état de navigation aux écrans. Aucune logique métier ici : le
+ * repository est la source de vérité, le ViewModel ne fait que relayer le
+ * flux de sorte qu'il survive aux rotations/redémarrages d'activité.
+ */
+class NavigationViewModel(
+    private val repository: NavigationStateRepository,
+) : ViewModel() {
+
+    val state: StateFlow<NavigationState> = repository.state
+
+    companion object {
+        val Factory: ViewModelProvider.Factory = viewModelFactory {
+            initializer {
+                val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
+                    as WatchOsmApplication
+                NavigationViewModel(application.container.navigationStateRepository)
+            }
+        }
+    }
+}

+ 18 - 0
wear/src/main/java/fr/barteam/watchosm/ui/NotificationAccess.kt

@@ -0,0 +1,18 @@
+package fr.barteam.watchosm.ui
+
+import android.content.Context
+import android.content.Intent
+import android.provider.Settings
+import androidx.core.app.NotificationManagerCompat
+
+/** true si l'utilisateur a accordé à WatchOsm l'accès aux notifications. */
+fun isNotificationAccessEnabled(context: Context): Boolean =
+    NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName)
+
+/** Ouvre l'écran système de gestion de l'accès aux notifications. */
+fun openNotificationAccessSettings(context: Context) {
+    context.startActivity(
+        Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
+            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+    )
+}

+ 27 - 0
wear/src/main/java/fr/barteam/watchosm/ui/Theme.kt

@@ -0,0 +1,27 @@
+package fr.barteam.watchosm.ui
+
+import androidx.compose.runtime.Composable
+import androidx.wear.compose.material.Colors
+import androidx.wear.compose.material.MaterialTheme
+
+private val WatchOsmColors = Colors(
+    primary = androidx.compose.ui.graphics.Color(0xFF80CBC4),
+    secondary = androidx.compose.ui.graphics.Color(0xFFFFB74D),
+    background = androidx.compose.ui.graphics.Color.Black,
+    surface = androidx.compose.ui.graphics.Color(0xFF212121),
+    error = androidx.compose.ui.graphics.Color(0xFFEF9A9A),
+    onPrimary = androidx.compose.ui.graphics.Color.Black,
+    onSecondary = androidx.compose.ui.graphics.Color.Black,
+    onBackground = androidx.compose.ui.graphics.Color.White,
+    onError = androidx.compose.ui.graphics.Color.Black,
+)
+
+/**
+ * Thème unique de l'application : fond noir (écran OLED de montre, mode
+ * Always-On Display friendly) et couleurs contrastées pour la lisibilité en
+ * plein soleil.
+ */
+@Composable
+fun WatchOsmTheme(content: @Composable () -> Unit) {
+    MaterialTheme(colors = WatchOsmColors, content = content)
+}

+ 127 - 0
wear/src/main/java/fr/barteam/watchosm/ui/components/ManeuverArrow.kt

@@ -0,0 +1,127 @@
+package fr.barteam.watchosm.ui.components
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.MaterialTheme
+import fr.barteam.watchosm.domain.Maneuver
+
+/**
+ * Flèche de manœuvre vectorielle, dessinée en Canvas : aucune dépendance aux
+ * polices d'icônes et rendu net à toutes les densités d'écran de montre.
+ *
+ * La forme de base est une flèche vers le haut, tournée selon l'angle de la
+ * manœuvre ; les cas particuliers (rond-point, arrivée) sont dessinés à la
+ * main car une rotation ne les décrit pas.
+ */
+@Composable
+fun ManeuverArrow(
+    maneuver: Maneuver,
+    modifier: Modifier = Modifier,
+    arrowSize: Dp = 72.dp,
+    color: Color = MaterialTheme.colors.primary,
+    backgroundColor: Color = MaterialTheme.colors.background,
+) {
+    Canvas(modifier = modifier) {
+        val side = arrowSize.toPx()
+        when (maneuver) {
+            Maneuver.ROUNDABOUT -> drawRoundabout(side, color, backgroundColor)
+            Maneuver.ARRIVE -> drawArrival(side, color, backgroundColor)
+            else -> rotate(rotationDegrees(maneuver)) {
+                drawPath(
+                    path = arrowPath(side, side),
+                    color = color,
+                )
+            }
+        }
+    }
+}
+
+private fun rotationDegrees(maneuver: Maneuver): Float = when (maneuver) {
+    Maneuver.SLIGHT_LEFT -> -30f
+    Maneuver.LEFT -> -90f
+    Maneuver.SHARP_LEFT -> -135f
+    Maneuver.UTURN -> 180f
+    Maneuver.SHARP_RIGHT -> 135f
+    Maneuver.RIGHT -> 90f
+    Maneuver.SLIGHT_RIGHT -> 30f
+    else -> 0f
+}
+
+private fun arrowPath(width: Float, height: Float): Path {
+    val center = width / 2f
+    return Path().apply {
+        moveTo(center, 0.06f * height)
+        lineTo(0.16f * width, 0.44f * height)
+        lineTo(0.37f * width, 0.44f * height)
+        lineTo(0.37f * width, 0.94f * height)
+        lineTo(0.63f * width, 0.94f * height)
+        lineTo(0.63f * width, 0.44f * height)
+        lineTo(0.84f * width, 0.44f * height)
+        close()
+    }
+}
+
+/** Rond-point : anneau + entrée par le bas + sortie vers la droite. */
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRoundabout(
+    side: Float,
+    color: Color,
+    backgroundColor: Color,
+) {
+    val width = side
+    val height = side
+    val center = Offset(width / 2f, height / 2f)
+    val strokeWidth = 0.10f * width
+    val radius = 0.30f * width
+
+    drawCircle(
+        color = color,
+        radius = radius,
+        center = center,
+        style = Stroke(width = strokeWidth),
+    )
+    // Entrée : la route qui rejoint le carrefour par le bas.
+    drawLine(
+        color = color,
+        start = Offset(center.x, height),
+        end = Offset(center.x, center.y + radius),
+        strokeWidth = strokeWidth,
+    )
+    // Sortie : la 2e sortie, vers la droite, avec sa flèche.
+    val exitStart = Offset(center.x + radius, center.y)
+    val exitEnd = Offset(0.97f * width, center.y)
+    drawLine(color = color, start = exitStart, end = exitEnd, strokeWidth = strokeWidth)
+    drawPath(
+        path = Path().apply {
+            moveTo(exitEnd.x, exitEnd.y)
+            lineTo(exitEnd.x - 0.16f * width, exitEnd.y - 0.14f * height)
+            lineTo(exitEnd.x - 0.16f * width, exitEnd.y + 0.14f * height)
+            close()
+        },
+        color = color,
+    )
+    // Le trou du rond-point, pour que l'anneau reste lisible sur fond sombre.
+    drawCircle(
+        color = backgroundColor,
+        radius = radius - strokeWidth / 2f,
+        center = center,
+    )
+}
+
+/** Arrivée : cible concentrique. */
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawArrival(
+    side: Float,
+    color: Color,
+    backgroundColor: Color,
+) {
+    val center = Offset(side / 2f, side / 2f)
+    drawCircle(color = color, radius = 0.34f * side, center = center)
+    drawCircle(color = backgroundColor, radius = 0.17f * side, center = center)
+}

+ 101 - 0
wear/src/main/java/fr/barteam/watchosm/ui/screens/HomeScreen.kt

@@ -0,0 +1,101 @@
+package fr.barteam.watchosm.ui.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.Button
+import androidx.wear.compose.material.MaterialTheme
+import androidx.wear.compose.material.Scaffold
+import androidx.wear.compose.material.ScalingLazyColumn
+import androidx.wear.compose.material.Text
+import fr.barteam.watchosm.R
+import fr.barteam.watchosm.domain.NavigationState
+
+/**
+ * Écran d'accueil : état du service d'écoute et état de la navigation.
+ * Sert aussi d'écran de configuration lorsque l'accès aux notifications est
+ * refusé — sans cet accès, WatchOsm n'a aucune donnée à afficher.
+ */
+@Composable
+fun HomeScreen(
+    state: NavigationState,
+    listenerEnabled: Boolean,
+    onOpenNotificationSettings: () -> Unit,
+    onOpenNavigation: () -> Unit,
+) {
+    Scaffold {
+        ScalingLazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            horizontalAlignment = Alignment.CenterHorizontally,
+        ) {
+            item {
+                Text(
+                    text = stringResource(R.string.home_title),
+                    style = MaterialTheme.typography.title2,
+                )
+            }
+            item {
+                if (listenerEnabled) {
+                    Text(
+                        text = stringResource(R.string.home_listener_granted),
+                        style = MaterialTheme.typography.body1,
+                        color = MaterialTheme.colors.primary,
+                        textAlign = TextAlign.Center,
+                    )
+                } else {
+                    Column(
+                        horizontalAlignment = Alignment.CenterHorizontally,
+                        verticalArrangement = Arrangement.spacedBy(8.dp),
+                    ) {
+                        Text(
+                            text = stringResource(R.string.home_listener_denied),
+                            style = MaterialTheme.typography.body1,
+                            color = MaterialTheme.colors.secondary,
+                            textAlign = TextAlign.Center,
+                        )
+                        Button(
+                            onClick = onOpenNotificationSettings,
+                            modifier = Modifier.fillMaxWidth(),
+                        ) {
+                            Text(stringResource(R.string.home_listener_open_settings))
+                        }
+                    }
+                }
+            }
+            item {
+                if (state.isActive) {
+                    Button(
+                        onClick = onOpenNavigation,
+                        modifier = Modifier.fillMaxWidth(),
+                    ) {
+                        Text(stringResource(R.string.home_open_navigation))
+                    }
+                } else {
+                    Text(
+                        text = stringResource(R.string.home_nav_idle),
+                        style = MaterialTheme.typography.body1,
+                        textAlign = TextAlign.Center,
+                    )
+                }
+            }
+            if (!state.isActive) {
+                item {
+                    Text(
+                        text = stringResource(R.string.home_nav_hint),
+                        style = MaterialTheme.typography.caption1,
+                        textAlign = TextAlign.Center,
+                        modifier = Modifier.padding(horizontal = 16.dp),
+                    )
+                }
+            }
+        }
+    }
+}

+ 177 - 0
wear/src/main/java/fr/barteam/watchosm/ui/screens/NavigationScreen.kt

@@ -0,0 +1,177 @@
+package fr.barteam.watchosm.ui.screens
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.view.WindowManager
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.MaterialTheme
+import androidx.wear.compose.material.Scaffold
+import androidx.wear.compose.material.Text
+import fr.barteam.watchosm.R
+import fr.barteam.watchosm.domain.Formatters
+import fr.barteam.watchosm.domain.Maneuver
+import fr.barteam.watchosm.domain.NavigationState
+import fr.barteam.watchosm.ui.components.ManeuverArrow
+
+/**
+ * Écran de navigation : la consigne de manœuvre en dominante, puis les
+ * grandeurs de trajet en légende.
+ *
+ * L'écran garde l'allumage tant que la navigation est active (sinon la montre
+ * s'endort en quelques secondes et la consigne devient inutilisable à vélo ou
+ * en voiture). À la fin de la navigation, l'état « terminée » reste affiché
+ * quelques secondes avant le retour automatique à l'accueil.
+ */
+@Composable
+fun NavigationScreen(state: NavigationState) {
+    KeepScreenOnWhileActive(active = state.isActive)
+
+    Scaffold {
+        Column(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(horizontal = 16.dp, vertical = 32.dp),
+            horizontalAlignment = Alignment.CenterHorizontally,
+            verticalArrangement = Arrangement.Center,
+        ) {
+            if (state.isActive) {
+                ActiveNavigationContent(state)
+            } else {
+                Text(
+                    text = stringResource(R.string.nav_finished),
+                    style = MaterialTheme.typography.title2,
+                    textAlign = TextAlign.Center,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun ActiveNavigationContent(state: NavigationState) {
+    val instruction = state.instruction
+
+    val arrowDescription = stringResource(R.string.cd_maneuver_arrow)
+    ManeuverArrow(
+        maneuver = instruction?.maneuver ?: Maneuver.CONTINUE,
+        arrowSize = 64.dp,
+        modifier = Modifier
+            .semantics { contentDescription = arrowDescription }
+            .padding(bottom = 4.dp),
+    )
+
+    Text(
+        text = instruction?.distanceMeters
+            ?.let { Formatters.distance(it) }
+            ?: "—",
+        style = MaterialTheme.typography.display1,
+        color = MaterialTheme.colors.primary,
+    )
+
+    if (!instruction?.streetName.isNullOrBlank()) {
+        Text(
+            text = instruction?.streetName.orEmpty(),
+            style = MaterialTheme.typography.body1,
+            textAlign = TextAlign.Center,
+            modifier = Modifier.padding(top = 2.dp, bottom = 4.dp),
+        )
+    }
+
+    if (state.isPaused) {
+        Text(
+            text = stringResource(R.string.nav_paused),
+            style = MaterialTheme.typography.body1,
+            color = MaterialTheme.colors.secondary,
+            textAlign = TextAlign.Center,
+        )
+    }
+
+    Separator(modifier = Modifier.padding(vertical = 6.dp))
+
+    val remaining = listOfNotNull(
+        state.remainingDistanceMeters?.let {
+            stringResource(R.string.nav_remaining_distance, Formatters.distance(it))
+        },
+        state.remainingTimeMinutes?.let {
+            stringResource(R.string.nav_remaining_time, Formatters.duration(it))
+        },
+    ).joinToString(" · ")
+    if (remaining.isNotBlank()) {
+        Text(
+            text = remaining,
+            style = MaterialTheme.typography.body2,
+            textAlign = TextAlign.Center,
+        )
+    }
+
+    val details = listOfNotNull(
+        state.arrivalTimeLabel?.let { stringResource(R.string.nav_arrival, it) },
+        state.speedKmh?.let { stringResource(R.string.nav_speed, it) },
+        state.speedLimitKmh?.let { stringResource(R.string.nav_speed_limit, it) },
+    ).joinToString(" · ")
+    if (details.isNotBlank()) {
+        Text(
+            text = details,
+            style = MaterialTheme.typography.caption1,
+            color = MaterialTheme.colors.secondary,
+            textAlign = TextAlign.Center,
+            modifier = Modifier.padding(top = 2.dp),
+        )
+    }
+}
+
+/**
+ * Séparateur fin : Wear Compose ne fournit plus de Divider depuis la 1.6,
+ * un fond de 1 dp fait le même travail sans dépendre d'une API mouvante.
+ */
+@Composable
+private fun Separator(modifier: Modifier = Modifier) {
+    Box(
+        modifier = modifier
+            .fillMaxWidth()
+            .height(1.dp)
+            .background(MaterialTheme.colors.onBackground.copy(alpha = 0.4f)),
+    )
+}
+
+/**
+ * Tant que la navigation est active, interdit à l'écran de s'endormir.
+ * L'activité referme proprement le drapeau quand l'écran disparaît.
+ */
+@Composable
+private fun KeepScreenOnWhileActive(active: Boolean) {
+    val context = LocalContext.current
+    DisposableEffect(active) {
+        val activity = context.findActivity()
+        if (active) {
+            activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+        }
+        onDispose {
+            activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+        }
+    }
+}
+
+private tailrec fun Context.findActivity(): Activity? = when (this) {
+    is Activity -> this
+    is ContextWrapper -> baseContext.findActivity()
+    else -> null
+}

+ 26 - 0
wear/src/main/res/values/strings.xml

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">WatchOsm</string>
+    <string name="notification_listener_label">Écoute OsmAnd</string>
+
+    <!-- Écran d'accueil -->
+    <string name="home_title">WatchOsm</string>
+    <string name="home_listener_granted">Écoute des notifications : active</string>
+    <string name="home_listener_denied">Écoute des notifications : désactivée</string>
+    <string name="home_listener_open_settings">Ouvrir les réglages</string>
+    <string name="home_nav_idle">Aucune navigation en cours</string>
+    <string name="home_open_navigation">Voir la navigation</string>
+    <string name="home_nav_hint">Démarrez une navigation dans OsmAnd+ sur votre téléphone : elle apparaîtra ici automatiquement.</string>
+
+    <!-- Écran navigation -->
+    <string name="nav_remaining_distance">%1$s restantes</string>
+    <string name="nav_remaining_time">%1$s restantes</string>
+    <string name="nav_arrival">Arrivée %1$s</string>
+    <string name="nav_speed">%1$d km/h</string>
+    <string name="nav_speed_limit">Limite %1$d km/h</string>
+    <string name="nav_paused">Navigation en pause</string>
+    <string name="nav_finished">Navigation terminée</string>
+
+    <!-- Descriptions d'accessibilité -->
+    <string name="cd_maneuver_arrow">Flèche de manœuvre</string>
+</resources>

+ 7 - 0
wear/src/main/res/values/themes.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- Thème minimal : toute l'interface est en Compose, pas de vues XML. -->
+    <style name="Theme.WatchOsm" parent="android:Theme.DeviceDefault">
+        <item name="android:windowBackground">@android:color/black</item>
+    </style>
+</resources>

+ 142 - 0
wear/src/test/java/fr/barteam/watchosm/data/OsmAndNotificationParserTest.kt

@@ -0,0 +1,142 @@
+package fr.barteam.watchosm.data
+
+import fr.barteam.watchosm.domain.Maneuver
+import fr.barteam.watchosm.domain.NavigationState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Le parseur est la seule partie du pont OsmAnd dont le comportement dépend
+ * d'un format non contractuel : ces tests fixent les formats déjà rencontrés
+ * et protègent les garde-fous (vitesse prise pour une distance, notification
+ * d'enregistrement prise pour une navigation).
+ */
+class OsmAndNotificationParserTest {
+
+    private val parser = OsmAndNotificationParser()
+
+    private fun payload(
+        title: String? = null,
+        text: String? = null,
+        bigText: String? = null,
+        category: String? = null,
+    ) = OsmAndNotificationPayload(
+        title = title,
+        text = text,
+        bigText = bigText,
+        subText = null,
+        infoText = null,
+        category = category,
+    )
+
+    /** Parse une notification censée être une navigation, ou échoue. */
+    private fun navigation(payload: OsmAndNotificationPayload): NavigationState =
+        requireNotNull(parser.parse(payload)) { "notification non reconnue comme navigation" }
+
+    @Test
+    fun `consigne francaise complete`() {
+        val state = navigation(
+            payload(
+                title = "Dans 300 m tournez à droite sur la rue des Fleurs",
+                text = "5,4 km · 18 min · 18:42",
+            ),
+        )
+
+        assertTrue(state.isActive)
+        assertEquals(Maneuver.RIGHT, state.instruction?.maneuver)
+        assertEquals(300, state.instruction?.distanceMeters)
+        assertEquals("rue des Fleurs", state.instruction?.streetName)
+        assertEquals(5400, state.remainingDistanceMeters)
+        assertEquals(18, state.remainingTimeMinutes)
+        assertEquals("18:42", state.arrivalTimeLabel)
+    }
+
+    @Test
+    fun `consigne anglaise avec rue`() {
+        val state = navigation(
+            payload(title = "In 200 m turn left onto Main Street", text = "5.4 km · 12 min"),
+        )
+
+        assertEquals(Maneuver.LEFT, state.instruction?.maneuver)
+        assertEquals(200, state.instruction?.distanceMeters)
+        assertEquals("Main Street", state.instruction?.streetName)
+        assertEquals(5400, state.remainingDistanceMeters)
+    }
+
+    @Test
+    fun `rond-point francais`() {
+        val state = navigation(
+            payload(title = "Au rond-point, prenez la 2e sortie", text = "800 m · 4 min"),
+        )
+
+        assertEquals(Maneuver.ROUNDABOUT, state.instruction?.maneuver)
+        assertEquals(800, state.instruction?.distanceMeters)
+    }
+
+    @Test
+    fun `demi-tour anglais`() {
+        val state = navigation(payload(title = "In 50 m make a U-turn"))
+
+        assertEquals(Maneuver.UTURN, state.instruction?.maneuver)
+        assertEquals(50, state.instruction?.distanceMeters)
+    }
+
+    @Test
+    fun `vitesse n est pas une distance`() {
+        val state = navigation(payload(title = "Continue tout droit", text = "56 km/h"))
+
+        assertEquals(Maneuver.CONTINUE, state.instruction?.maneuver)
+        assertEquals(56, state.speedKmh)
+        assertNull(state.remainingDistanceMeters)
+    }
+
+    @Test
+    fun `notification d enregistrement ignoree`() {
+        val state = parser.parse(
+            payload(title = "Enregistrement du trajet", text = "12,5 km · 00:12:34"),
+        )
+
+        assertNull(state)
+    }
+
+    @Test
+    fun `navigation en pause reconnue sans manœuvre`() {
+        val state = navigation(payload(title = "Navigation en pause"))
+
+        assertTrue(state.isActive)
+        assertTrue(state.isPaused)
+        assertNull(state.instruction)
+    }
+
+    @Test
+    fun `categorie navigation acceptee meme sans marqueur`() {
+        val state = navigation(payload(title = "OsmAnd", category = "navigation"))
+
+        assertTrue(state.isActive)
+        assertNull(state.instruction)
+    }
+
+    @Test
+    fun `arrivee francaise`() {
+        val state = navigation(payload(title = "Vous êtes arrivé à destination"))
+
+        assertEquals(Maneuver.ARRIVE, state.instruction?.maneuver)
+    }
+
+    @Test
+    fun `legere gauche francaise classe en slight et pas en left`() {
+        val state = navigation(payload(title = "Légèrement à gauche sur l'avenue Victor"))
+
+        assertEquals(Maneuver.SLIGHT_LEFT, state.instruction?.maneuver)
+        assertEquals("l'avenue Victor", state.instruction?.streetName)
+    }
+
+    @Test
+    fun `heures et minutes composees`() {
+        val state = navigation(payload(title = "Continue tout droit", text = "1 h 25 min"))
+
+        assertEquals(85, state.remainingTimeMinutes)
+    }
+}