# APP.md — Portage mobile (iOS / Android) de la carte des aires de pique-nique

Ce document est le cahier des charges pour recréer l'application web de ce dépôt
(`index.php`) sous forme d'application mobile native iOS + Android, avec un
**backend REST** dédié pour servir les données (aujourd'hui produites par
`lieux.php` et stockées en JSON-sur-disque dans `data/`).

Il est exhaustif : chaque fonctionnalité de l'app web y est listée avec son
équivalent mobile et les règles métier exactes à reproduire.

---

## 1. Stack technique retenue

**React Native + Expo (TypeScript).**

| Domaine | Choix | Notes |
|---|---|---|
| Framework | React Native via **Expo (SDK managed)** | Build & OTA via EAS |
| Langage | TypeScript (strict) | |
| Carte | **react-native-maps** | Google Maps (Android) / Apple Maps (iOS), ou tuiles OSM via `UrlTile` |
| Clustering | `react-native-map-clustering` (ou clustering intégré react-native-maps) | Remplace `leaflet.markercluster` |
| Calculs géo | **`@turf/turf`** | Réutilisé tel quel depuis le web (distance, pointToLineDistance, along, midpoint, bearing, destination) |
| Données réseau | `fetch` + **TanStack Query** (`@tanstack/react-query`) | Cache, retry, invalidation des notes |
| Navigation / deep links | **expo-router** | Remplace les permalinks `?param=` de l'URL web |
| État local persistant | `expo-secure-store` / `@react-native-async-storage/async-storage` ou **MMKV** | Remplace `localStorage` et l'état dans l'URL |
| Géolocalisation | `expo-location` | Remplace `navigator.geolocation` |
| Liens externes | `expo-linking` / `Linking` | Ouvre Google Maps, Street View, fiche pique-nique.info |
| Icônes | Emojis (identiques au web) ou `@expo/vector-icons` | La toolbar web utilise des emojis |
| Build / distribution | **EAS Build** + **EAS Update** (OTA) | |

> **Pourquoi pas Leaflet ?** En natif, on remplace Leaflet/MarkerCluster par
> `react-native-maps` + clustering natif. **Toute la logique métier non-UI**
> (filtrage équipements, filtrage par distance à l'itinéraire, filtre temporel,
> calculs Turf, parsing des itinéraires OSRM) est portable quasi à l'identique.

---

## 2. Architecture générale

```
┌─────────────────────────┐         ┌──────────────────────────────┐
│  App mobile (Expo / RN)  │  HTTPS  │  Backend REST (à créer)      │
│                          │ ──────► │                              │
│  - Carte + clusters      │  JSON   │  GET  /api/lieux             │
│  - Filtres, recherche    │         │  GET  /api/lieux/{id}        │
│  - Itinéraires           │         │  GET  /api/lieux/{id}/notes  │
│  - Notation              │         │  POST /api/lieux/{id}/notes  │
└──────────┬───────────────┘         │  GET  /api/equipements       │
           │                         │  (proxys géocodage/routing)  │
           │  Services tiers         └──────────────────────────────┘
           ▼  (directs ou proxifiés)            │
   Photon / Nominatim / OSRM / tuiles OSM       │ lit/écrit
                                                ▼
                                    data/ (lieux, coords, notes)
```

Le backend remplace les 3 points d'I/O actuels de l'app web :
1. Le **dump inline** de tous les lieux par `index.php` → `GET /api/lieux`.
2. **`note.php`** (lecture/écriture des notes) → endpoints `/notes`.
3. La logique de **normalisation** côté PHP (équipements, override coords,
   moyenne des notes, exclusion `Logo Pinterest`) → désormais côté backend.

---

## 3. Modèle de données (source de vérité)

### 3.1 Lieu (format "liste", tel que produit aujourd'hui par `index.php`)

```jsonc
{
  "id": "1",
  "nom": "Jardins de la Corderie Royale",
  "lat": 45.941205,            // float ; override data/coords/{id}.json appliqué si présent
  "lon": -0.954939,            // float
  "photos": ["https://.../corderie.jpg", "..."],
  "equipements": ["Acces handicape", "Camping-car", "Parking", "..."], // actifs uniquement, triés A→Z, "Logo Pinterest" exclu
  "note_avg": 4.0,             // null si aucun avis
  "note_count": 1              // 0 si aucun avis
}
```

### 3.2 Lieu (format "détail", tel que stocké dans `data/lieux/{id}.json`)

```jsonc
{
  "id": "1",
  "nom": "...",
  "description": "Quatre tables sont installées...",  // absent du format liste
  "latitude": "45.941205",     // string brute du scraper ; override coords appliqué
  "longitude": "-0.954939",
  "photos": ["..."],
  "equipements": {             // map nom → bool (inclut les inactifs)
    "Table & banc": true, "Wc": true, "Parking": true, "Poubelle": true,
    "Barbecue": false, "Camping-car": true, "Point eau": true,
    "Aire de jeux": true, "Acces handicape": true
  }
}
```

### 3.3 Liste de référence des équipements (CONTRAT — à respecter à l'identique)

Ordre et emojis identiques à la toolbar web (`$equipements_importants` de `index.php`) :

| Clé (`alt` du scraper) | Emoji |
|---|---|
| `Table & banc` | 🪑 |
| `Wc` | 🚻 |
| `Parking` | 🅿️ |
| `Poubelle` | 🗑️ |
| `Barbecue` | 🔥 |
| `Camping-car` | 🚐 |
| `Point eau` | 🚰 |
| `Aire de jeux` | 🎠 |
| `Acces handicape` | ♿ |

Règles :
- Ces 9 clés sont **garanties** présentes sur chaque lieu (forcées à `false` si absentes).
- Le format liste ne renvoie que les équipements **actifs**, **triés alphabétiquement**.
- `Logo Pinterest` (et autres `alt` parasites comme `Icone Carte GPS`, `photo de l aire`) sont **exclus**.

### 3.4 Note

```jsonc
// data/notes/{id}.json (tableau d'avis)
[ { "date": "2026-05-10T07:04:28+00:00", "note": 4 } ]
```
- `average` = moyenne des `note`, arrondie à 2 décimales, `null` si vide.
- `count` = nombre d'avis.

---

## 4. Contrat de l'API REST (backend à créer)

Base : `https://api.example.com`. Toutes les réponses en `application/json; charset=utf-8`.

### 4.1 `GET /api/lieux`

Liste des aires. Le web charge **tous** les lieux (~6045) d'un coup ; sur mobile,
prévoir **deux modes** :

- **Téléchargement complet** (pour le mode hors-ligne / dataset entier) :
  `GET /api/lieux` → tableau complet au format §3.1.
- **Filtrage par zone (recommandé à l'usage courant)** :
  `GET /api/lieux?bbox=minLon,minLat,maxLon,maxLat` → uniquement les lieux dans la fenêtre.

Paramètres optionnels (filtrage côté serveur pour alléger le mobile) :

| Param | Type | Effet |
|---|---|---|
| `bbox` | `minLon,minLat,maxLon,maxLat` | Limite à l'emprise géographique |
| `eq` | répétable, ex. `?eq=Wc&eq=Parking` | Ne garde que les lieux ayant **TOUS** ces équipements (AND) |
| `note_min` | 1–5 | Filtre par note moyenne min |
| `note_max` | 1–5 | Filtre par note moyenne max |
| `updated_since` | ISO 8601 | Sync incrémentale pour le cache offline |

> Le filtrage par équipements/note **peut** rester côté client (comme le web)
> si l'app télécharge tout le dataset. Les params serveur sont une optimisation.

Réponse :
```jsonc
{
  "count": 6045,
  "lieux": [ /* objets §3.1 */ ]
}
```

### 4.2 `GET /api/lieux/{id}`

Détail complet d'une aire (format §3.2 enrichi des notes) :
```jsonc
{
  "id": "1", "nom": "...", "description": "...",
  "lat": 45.941205, "lon": -0.954939,
  "photos": ["..."],
  "equipements": ["Parking", "Wc", "..."],  // actifs, triés
  "note_avg": 4.0, "note_count": 1
}
```

### 4.3 `GET /api/lieux/{id}/notes`

Équivalent de `note.php?id=` (GET). Réponse :
```jsonc
{ "id": "1", "count": 1, "average": 4.0 }
```

### 4.4 `POST /api/lieux/{id}/notes`

Équivalent de `note.php` (POST). Corps : `{ "note": 4 }` (entier 1–5).
- `400` si `note` hors [1,5] ou `id` non numérique.
- Ajoute un avis horodaté (`date` ISO 8601), puis renvoie la moyenne **mise à jour** :
```jsonc
{ "id": "1", "count": 2, "average": 4.5 }
```
> Côté mobile : après POST, mettre à jour `note_avg`/`note_count` du lieu en cache.

### 4.5 `GET /api/equipements`

Renvoie la liste de référence §3.3 (clé + emoji + ordre), pour piloter la toolbar
sans la coder en dur dans l'app :
```jsonc
[ { "key": "Table & banc", "emoji": "🪑" }, { "key": "Wc", "emoji": "🚻" }, ... ]
```

### 4.6 (Optionnel mais recommandé) Proxys services tiers

Le web appelle Photon / Nominatim / OSRM **directement** depuis le navigateur.
Sur mobile, deux options :
- **A (simple)** : appels directs depuis l'app (mêmes URLs qu'aujourd'hui).
- **B (recommandé)** : proxifier via le backend pour respecter les quotas des
  instances publiques, ajouter du cache et masquer la dépendance :
  - `GET /api/geocode?q=...` → Photon (autocomplete) / Nominatim (résolution exacte)
  - `GET /api/route?profile=car|bike&coords=lon,lat;lon,lat;...` → OSRM

---

## 5. Inventaire EXHAUSTIF des fonctionnalités (web → mobile)

### 5.1 Carte

| # | Fonctionnalité web | Spéc mobile |
|---|---|---|
| C1 | Carte plein écran, tuiles **OpenStreetMap** | `react-native-maps`. Tuiles OSM via `UrlTile` `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png` (ou map provider natif). Attribution OSM visible. |
| C2 | Vue initiale **France entière** : centre `[46.5, 2]`, zoom `6` | Région initiale équivalente (calculer `latitudeDelta`/`longitudeDelta`). |
| C3 | **Clustering** des markers (markercluster) | Clustering natif. Garder un comportement « zoom-to-show » pour ouvrir un lieu précis (cf. C7). |
| C4 | Permalink état carte : `?lat&lon&z` mis à jour à chaque `moveend` | Persister centre+zoom (MMKV/AsyncStorage) et restaurer au lancement. Exposer aussi via deep link (§7). |
| C5 | Marker = une aire ; au tap → fiche | Callout / bottom-sheet (voir C6). |
| C6 | **Popup** au clic marker contenant : titre (lien fiche pique-nique.info), 1ʳᵉ photo (150px), liste équipements actifs, lien **Street View**, bloc « étape » (distance/durée depuis départ d'itinéraire), bloc **notation** | Bottom-sheet ou Callout détaillé. Détails de chaque bloc ci-dessous (5.5, 5.6). |
| C7 | Restauration du lieu ouvert au chargement : `?lieu={id}` → zoom sur le cluster + ouvre la popup | Deep link `/(map)?lieu={id}` → centrer, dézoomer le cluster, ouvrir la fiche. |
| C8 | Bouton **géolocalisation** 📍 : recentre (zoom 14) + marker « 📍 Vous êtes ici » | `expo-location` (demande permission). Gérer le refus (alerte). |

### 5.2 Recherche (barre du haut)

| # | Fonctionnalité web | Spéc mobile |
|---|---|---|
| S1 | Champ recherche « Rechercher un lieu ou une ville… » | Champ en haut de carte. |
| S2 | **Autocomplete** combiné : (a) lieux locaux dont le `nom` contient la saisie (préfixe 🍽️, max 5), (b) villes via **Photon** komoot filtrées `country=France` (préfixe 🏙️, max 5) | Debounce 300 ms, min 2 caractères. Match local **insensible aux accents** (normaliser NFD + suppression diacritiques + lowercase). |
| S3 | Sélection d'une suggestion → recentre carte (zoom 14) + popup au point avec bouton « 🧭 Itinéraire depuis ici » | Le bouton pré-remplit le champ **Départ** de l'itinéraire et ouvre le panneau. |
| S4 | Validation (sans suggestion) : match local d'abord (zoom 15), sinon fallback **Nominatim** (`countrycodes=fr`), recentre (zoom 13) + popup ; sinon alerte « Ville non trouvée » | Idem. Gérer erreur réseau (alerte). |
| S5 | Bouton **×** pour vider le champ | Présent sur **tous** les champs de recherche (barre + Départ + Arrivée). |

Détail Photon (à reproduire) :
`https://photon.komoot.io/api?q={q}&lang=fr&limit=10&lat=46.5&lon=2&zoom=6`
→ filtrer `properties.country === 'France'`, garder 5, label = `name, city, state, country` (dédupliqué).

### 5.3 Filtres équipements (toolbar)

| # | Fonctionnalité web | Spéc mobile |
|---|---|---|
| E1 | 9 boutons équipements (emojis §3.3) | Barre verticale d'icônes (toolbar). État actif visuellement marqué (fond bleu). |
| E2 | Filtrage **AND** : un lieu n'est affiché que s'il possède **tous** les équipements sélectionnés | `filtres.every(eq => lieu.equipements.includes(eq))`. |
| E3 | État des filtres dans l'URL (`?eq=` répété) | Persister + deep link. Recalcule les markers à chaque toggle. |

### 5.4 Filtre par note (bouton ⭐)

| # | Fonctionnalité web | Spéc mobile |
|---|---|---|
| N1 | Bouton ⭐ ouvre une **modale** « note min / note max » (sélecteurs 1–5, défaut min 3 / max 5) | Modal natif / bottom-sheet. |
| N2 | Validation → n'affiche que les lieux dont `note_avg` ∈ [min, max] ; les lieux **sans avis** (`note_avg == null`) sont **exclus** | Reproduire exactement. `min`/`max` réordonnés si inversés. |
| N3 | Re-tap sur ⭐ quand un filtre est actif → **retire** le filtre (pas de modale) | Bouton marqué actif tant que le filtre est posé. |

### 5.5 Notation (dans la fiche d'un lieu)

| # | Fonctionnalité web | Spéc mobile |
|---|---|---|
| R1 | À l'ouverture de la fiche : `GET note.php?id=` → affiche moyenne en **étoiles** (`★`/`☆`, arrondi) + `X/5 (N avis)` ou « pas encore d'avis » | `GET /api/lieux/{id}/notes`. Rendu étoiles identique (round de la moyenne). |
| R2 | Rangée « Votre note » : 5 étoiles cliquables, survol → highlight progressif | Sur mobile : tap (pas de hover) ; feedback visuel au press. |
| R3 | Clic sur une étoile → `POST note.php {id, note}` → ré-affiche la moyenne mise à jour | `POST /api/lieux/{id}/notes {note}`. Invalider le cache du lieu + mettre à jour `note_avg`/`note_count`. |

### 5.6 Itinéraire (panneau bas-gauche)

Panneau **repliable** (état persisté ; web = `localStorage 'itineraire-collapsed'`).

Champs et contrôles :

| # | Élément web | Spéc mobile |
|---|---|---|
| I1 | Champ **Départ** + autocomplete Photon + bouton × | Idem S2/S5 (suggestions villes uniquement). |
| I2 | Bouton **swap ⇅** (échange Départ/Arrivée) | Idem. |
| I3 | Champ **Arrivée** + autocomplete Photon + bouton × | Idem. |
| I4 | **Mode** voiture 🚗 / vélo 🚲 (radio) | Segmented control. Changer de mode **recalcule** l'itinéraire si Départ+Arrivée renseignés. |
| I5 | Lien « **Indiquer une heure de départ** » → révèle 2 champs heure : « Heure de départ » + « Position à » | Voir filtre temporel I11. |
| I6 | **Slider distance** 5→50 km (pas 5, défaut 10) : « Aires à moins de X km » | Slider. Le libellé change ; suffixe « de ma position à HH:MM » quand le filtre temporel est actif. |
| I7 | Bouton **🧭 Itinéraire** | Calcule (voir algo ci-dessous). État loading sur le bouton. |
| I8 | **Liste des itinéraires** sous le formulaire : chaque ligne = `X km` + durée ; tap → sélectionne (les autres tracés s'estompent, opacité 0.25) | Reproduire. Sélection met aussi à jour le filtre des markers et le marker temporel. |
| I9 | Lien « **Voir dans maps** » → ouvre Google Maps (deep link directions, `travelmode` = `driving`/`bicycling` selon mode) | `Linking.openURL('https://www.google.com/maps/dir/?api=1&origin=...&destination=...&travelmode=...')`. |
| I10 | **Filtrage des aires** : seuls les lieux à **moins de X km de la ligne** d'itinéraire restent visibles (`turf.pointToLineDistance`) | Réutiliser Turf à l'identique. |
| I11 | **Filtre temporel** : si « heure départ » + « position à » renseignées → calcule la position estimée sur le trajet à l'heure cible et ne garde que les aires à **moins de X km de CE point** (au lieu de la ligne) ; affiche un marker « 📍 HH:MM » sur le trajet | Voir algo ci-dessous. |
| I12 | Bloc « **étape** » dans chaque fiche quand un itinéraire existe : distance/durée OSRM depuis le **départ** du trajet jusqu'à l'aire | Appel OSRM `[départ → aire]`, afficher `X km · Hh MM` / `M min`. |
| I13 | **Permalink** itinéraire : `?from&to&mode&dist&start&target` (+ `lieu`) restauré au chargement | Deep link / persistance (§7). |

### 5.7 Override de coordonnées

| # | Fonctionnalité web | Spéc mobile |
|---|---|---|
| O1 | Si `data/coords/{id}.json` existe, ses `latitude`/`longitude` **remplacent** celles du lieu | **À traiter côté backend** : `GET /api/lieux*` renvoie déjà les coords corrigées. L'app n'a rien à faire. |

---

## 6. Algorithmes métier à reproduire fidèlement

### 6.1 Normalisation texte (recherche insensible aux accents)
```ts
const normaliser = (s: string) =>
  s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
```

### 6.2 Profils de routage OSRM
```ts
const OSRM_PROFILES = {
  car:  { endpoints: [
            { base: 'https://router.project-osrm.org', profile: 'driving' },
            { base: 'https://routing.openstreetmap.de/routed-car', profile: 'driving' } ],
          durationFactor: 0.9,  // OSRM sous-estime l'autoroute ; recalage ~Google
          gmaps: 'driving' },
  bike: { endpoints: [ { base: 'https://routing.openstreetmap.de/routed-bike', profile: 'bike' } ],
          durationFactor: 1.0,
          gmaps: 'bicycling' },
};
```
`fetchOsrmRoute(coords, mode)` : essaie chaque endpoint en fallback,
`...route/v1/{profile}/{lon,lat;lon,lat}?overview=full&geometries=geojson&steps=false`,
multiplie `duration` par `durationFactor`.

### 6.3 Calcul des itinéraires alternatifs (`computeRoute`)
1. **Géocoder** Départ et Arrivée via Nominatim (`countrycodes=fr`).
2. Calculer 3 candidats en parallèle :
   - direct `[start, end]`
   - via waypoint **gauche** `[start, wpLeft, end]`
   - via waypoint **droit** `[start, wpRight, end]`
   où `offset = max(2, min(totalKm*0.15, 30))` km, waypoints = `turf.destination(midpoint, offset, bearing ± 90)`.
3. **Dédupliquer** : deux routes sont identiques si `|Δdistance| < 500 m` **et** `|Δdurée| < 30 s`.
4. **Trier** par distance croissante. La 1ʳᵉ (la plus courte) est sélectionnée par défaut.
5. Tracer les polylignes (sélectionnée pleine, autres opacité 0.25), `fitBounds` sur la 1ʳᵉ.

### 6.4 Filtre temporel (`getTimeFilterPoint`)
```
elapsedMin = (positionA − heureDépart) en minutes, + 24h si négatif
fraction   = min(1, elapsedSec / route.duration)
distKm     = (route.distance / 1000) * fraction
point      = turf.along(route.feature, distKm, { units: 'kilometers' })
```
Puis : ne garder que les aires à `turf.distance(aire, point) ≤ distanceMax`.
Sans heures renseignées → on retombe sur le filtre « distance à la ligne » (6.3 / I10).

### 6.5 Formatage durée
```ts
const formatDuration = (s: number) => {
  const h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
  return h > 0 ? `${h}h${String(m).padStart(2,'0')}` : `${m} min`;
};
```

### 6.6 Liens externes (à reproduire)
- Fiche source : `https://www.pique-nique.info/AireDetail.php?lieu={id}`
- Street View : `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint={lat},{lon}`
- Directions Google Maps : voir I9.

---

## 7. État, navigation et deep links

Le web encode **tout l'état dans l'URL** (`history.replaceState`) + `localStorage`
pour le panneau itinéraire. À reproduire sur mobile via **expo-router** (deep links)
+ stockage persistant.

État à conserver / restaurer au lancement (équivalents des query params web) :

| Param web | Sens | Persistance mobile |
|---|---|---|
| `lat`, `lon`, `z` | position/zoom carte | MMKV + deep link |
| `lieu` | fiche ouverte | deep link `?lieu=` → ouvre la fiche |
| `eq` (répété) | filtres équipements actifs | MMKV + deep link |
| `from`, `to` | adresses itinéraire | MMKV + deep link |
| `mode` | `car` / `bike` | idem |
| `dist` | rayon (5–50) | idem |
| `start`, `target` | heures du filtre temporel | idem |
| (`localStorage`) `itineraire-collapsed` | panneau replié | MMKV |

Schéma de deep link recommandé : `picnic://map?lat=..&lon=..&z=..&lieu=..&eq=..&from=..&to=..&mode=..&dist=..&start=..&target=..`
(+ équivalents `https://` App Links / Universal Links pour le partage).

---

## 8. Services tiers & permissions

| Service | Usage | URL |
|---|---|---|
| Tuiles OSM | Fond de carte | `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png` |
| Photon (komoot) | Autocomplete adresses | `https://photon.komoot.io/api?...` |
| Nominatim | Résolution exacte d'adresse / fallback recherche | `https://nominatim.openstreetmap.org/search?format=json&countrycodes=fr&q=...` |
| OSRM | Calcul d'itinéraires (car/bike) | voir §6.2 |
| Google Maps / Street View | Liens sortants | `Linking` |

Bonnes pratiques mobiles (différences vs web) :
- **User-Agent / Referer** : Nominatim & instances OSRM publiques exigent un
  identifiant d'app et imposent des quotas → préférer le **proxy backend** (§4.6)
  avec cache pour la prod.
- Respecter le **débit** (le web fait `usleep(200ms)` côté scraper ; côté app,
  debounce 300 ms sur l'autocomplete déjà prévu).

Permissions natives (à déclarer dans `app.json` / `Info.plist` / `AndroidManifest`) :
- **Localisation** (`expo-location`) : `NSLocationWhenInUseUsageDescription`,
  `ACCESS_FINE_LOCATION` — pour le bouton 📍 et la géoloc.
- **Internet** (Android, implicite).

---

## 9. Hors-ligne & cache (apport mobile, optionnel)

L'app web n'a pas d'offline (dump PHP à chaque chargement). Sur mobile, on peut :
- Mettre en cache le dataset `GET /api/lieux` (TanStack Query + persistance MMKV).
- Sync incrémentale via `?updated_since=` (§4.1).
- Les notes restent **online** (lecture/écriture serveur), avec mise à jour
  optimiste du cache après vote.

---

## 10. Arborescence projet proposée

```
app/                      # expo-router
  _layout.tsx
  index.tsx               # écran carte principal
  lieu/[id].tsx           # (option) fiche plein écran / bottom-sheet
src/
  api/
    client.ts             # fetch + base URL
    lieux.ts              # GET /api/lieux, /api/lieux/{id}
    notes.ts              # GET/POST notes
    geocode.ts            # Photon + Nominatim
    routing.ts            # OSRM (profils, fetch, computeRoute)
  features/
    map/                  # carte, clustering, markers
    search/               # barre recherche + autocomplete
    filters/              # toolbar équipements + filtre note
    itinerary/            # panneau itinéraire, liste, filtre temporel
    rating/               # composant notation
  lib/
    turf.ts               # ré-exports @turf utilisés
    normaliser.ts
    duration.ts
    persist.ts            # MMKV / AsyncStorage
  state/                  # store état carte/filtres/itinéraire
constants/
  equipements.ts          # liste §3.3 (ou via GET /api/equipements)
```

---

## 11. Setup & commandes

```bash
# Initialisation
npx create-expo-app@latest picnic-mobile -t expo-template-blank-typescript
cd picnic-mobile

# Dépendances clés
npx expo install react-native-maps expo-location expo-linking
npm i @tanstack/react-query @turf/turf
npm i react-native-map-clustering
npx expo install @react-native-async-storage/async-storage   # ou react-native-mmkv (dev build)

# Dev
npx expo start                 # Expo Go (limité pour les cartes natives → préférer dev build)
npx expo run:ios               # build natif iOS
npx expo run:android           # build natif Android

# Distribution
eas build --platform all
eas update                     # OTA
```

> `react-native-maps` nécessite un **development build** (pas Expo Go) et, sur
> Android, une **clé Google Maps** (`app.json` → `android.config.googleMaps.apiKey`).
> Pour rester 100 % OSM, utiliser `UrlTile` avec le provider par défaut.

---

## 12. Lots de livraison (roadmap)

1. **Lot 1 — Carte + données** : `GET /api/lieux`, affichage markers + clustering,
   tuiles OSM, vue France, fiche au tap (titre, photo, équipements, liens externes).
2. **Lot 2 — Recherche & filtres** : barre + autocomplete (local + Photon),
   géolocalisation, toolbar équipements (AND), filtre note (modale).
3. **Lot 3 — Notation** : lecture moyenne + vote (GET/POST notes).
4. **Lot 4 — Itinéraire** : formulaire, OSRM multi-itinéraires, liste cliquable,
   filtrage par distance à la ligne, bloc « étape », lien Google Maps.
5. **Lot 5 — Filtre temporel** : heures départ/cible, marker temporel, filtrage au point.
6. **Lot 6 — Deep links & persistance** : restauration état (carte, filtres,
   itinéraire, lieu ouvert), partage par lien.
7. **Lot 7 (option)** — Offline/cache, sync incrémentale.

---

## 13. Points d'attention / écarts assumés avec le web

- **Pas de Leaflet** : le clustering, les popups et le tracé GeoJSON passent par
  `react-native-maps` ; le rendu visuel diffère mais le **comportement** doit être
  identique (filtres, sélection d'itinéraire, marker temporel).
- **Hover → tap** : la notation web utilise le survol pour pré-visualiser les
  étoiles ; sur mobile, remplacer par un retour au press.
- **`alert()`** web → `Alert.alert()` natif.
- **Override coords** : déplacé côté backend, l'app consomme des coords déjà corrigées.
- **Clé OpenRouteService** : l'app web a une clé ORS embarquée (côté client,
  considérée publique) ; le portage utilise **OSRM** comme le code actuel — pas
  de clé requise. Ne pas réintroduire de secret côté app.
- **Volume** : ~6045 lieux. Préférer le filtrage par `bbox` côté serveur ou un
  clustering performant pour éviter de tout rendre d'un coup.
