Corrections: - Liens vers ADR: docs/adr/ → adr/ dans index.md et technical.md - Liens internes entre règles métier (anciens noms numérotés) - Chemins relatifs ADR depuis les domaines: ../adr/ → ../../../adr/ - Lien ADR-010 → ADR-012 (frontend-mobile) - Suppression référence vers sequences/scoring-recommandation.md (non créé) Script: scripts/fix-remaining-links.sh
758 lines
26 KiB
Markdown
758 lines
26 KiB
Markdown
## 17. Contenus géolocalisés en mode voiture
|
||
|
||
### 17.1 Principe général
|
||
|
||
**Objectif** : Proposer des contenus audio au moment précis où l'utilisateur passe devant un point d'intérêt géographique, pour enrichir son trajet avec des informations contextuelles liées au paysage.
|
||
|
||
**Contrainte principale** : **Sécurité routière**
|
||
- Aucune distraction visuelle (pas de texte à lire)
|
||
- Notification sonore uniquement + icône minimale
|
||
- Validation par un seul bouton physique au volant ("Suivant")
|
||
- App doit être ouverte (premier plan)
|
||
|
||
**Distinction avec audio-guides** :
|
||
|
||
| Type | Description | Use case |
|
||
|------|-------------|----------|
|
||
| **Contenu géolocalisé simple** (cette section) | 1 point GPS unique, 1 contenu audio | Monument, panneau historique, point de vue |
|
||
| **Audio-guide multi-séquences** (section 16) | Plusieurs points GPS, séquences enchaînées | Safari-parc, circuit touristique, parcours urbain |
|
||
|
||
---
|
||
|
||
### 17.2 Détection et notification
|
||
|
||
#### 17.2.1 Calcul ETA (Estimated Time of Arrival)
|
||
|
||
**Méthode** : API GPS native iOS/Android
|
||
|
||
**Principe** :
|
||
- Calcul temps d'arrivée au point GPS basé sur vitesse actuelle et distance
|
||
- Notification déclenchée **7 secondes avant** d'atteindre le point
|
||
- Permet au conducteur d'avoir le temps de réagir (2s) + décompte (5s)
|
||
|
||
**Implémentation iOS (Swift)** :
|
||
|
||
```swift
|
||
import CoreLocation
|
||
|
||
class GeoContentDetector {
|
||
let locationManager = CLLocationManager()
|
||
var geoContents: [GeoContent] = [] // Points GPS proches
|
||
|
||
func calculateETA(to targetLocation: CLLocation) -> TimeInterval? {
|
||
guard let currentLocation = locationManager.location,
|
||
let currentSpeed = currentLocation.speed, // m/s
|
||
currentSpeed > 0 else { return nil }
|
||
|
||
let distance = currentLocation.distance(from: targetLocation) // mètres
|
||
let eta = distance / currentSpeed // secondes
|
||
return eta
|
||
}
|
||
|
||
func checkTriggers() {
|
||
// Appelé toutes les 1 seconde
|
||
for geoContent in geoContents {
|
||
let targetLocation = CLLocation(
|
||
latitude: geoContent.latitude,
|
||
longitude: geoContent.longitude
|
||
)
|
||
|
||
// Cas particulier : vitesse très faible
|
||
if let speed = locationManager.location?.speed,
|
||
speed < 1.4, // < 5 km/h
|
||
let distance = locationManager.location?.distance(from: targetLocation),
|
||
distance < 50 {
|
||
triggerNotification(for: geoContent)
|
||
continue
|
||
}
|
||
|
||
// Cas normal : calcul ETA
|
||
if let eta = calculateETA(to: targetLocation),
|
||
eta <= 7.0 && eta > 0 {
|
||
triggerNotification(for: geoContent)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Implémentation Android (Kotlin)** :
|
||
|
||
```kotlin
|
||
import android.location.Location
|
||
import com.google.android.gms.location.FusedLocationProviderClient
|
||
|
||
class GeoContentDetector(private val fusedLocationClient: FusedLocationProviderClient) {
|
||
private var geoContents: List<GeoContent> = emptyList()
|
||
|
||
fun calculateETA(targetLocation: Location, currentLocation: Location): Double? {
|
||
val speed = currentLocation.speed // m/s
|
||
if (speed <= 0) return null
|
||
|
||
val distance = currentLocation.distanceTo(targetLocation) // mètres
|
||
return distance / speed // secondes
|
||
}
|
||
|
||
fun checkTriggers(currentLocation: Location) {
|
||
for (geoContent in geoContents) {
|
||
val targetLocation = Location("").apply {
|
||
latitude = geoContent.latitude
|
||
longitude = geoContent.longitude
|
||
}
|
||
|
||
// Cas particulier : vitesse très faible (< 5 km/h)
|
||
if (currentLocation.speed < 1.4 &&
|
||
currentLocation.distanceTo(targetLocation) < 50) {
|
||
triggerNotification(geoContent)
|
||
continue
|
||
}
|
||
|
||
// Cas normal : calcul ETA
|
||
val eta = calculateETA(targetLocation, currentLocation)
|
||
if (eta != null && eta <= 7.0 && eta > 0) {
|
||
triggerNotification(geoContent)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Cas particulier : vitesse nulle ou très faible** :
|
||
- Si vitesse < 5 km/h (1.4 m/s) ET distance < 50m → notification immédiate
|
||
- Exemple : user arrêté à un feu rouge à 30m du point
|
||
- Évite notification trop tardive quand user redémarre
|
||
|
||
**Fréquence de vérification** :
|
||
- GPS updates : toutes les 1 seconde (balance batterie/précision)
|
||
- Calcul ETA : à chaque update GPS
|
||
- Notification : déclenchée immédiatement quand ETA ≤ 7s
|
||
|
||
---
|
||
|
||
#### 17.2.2 Format de la notification
|
||
|
||
**Philosophie** : **Minimalisme absolu** pour sécurité routière
|
||
|
||
❌ **Pas de** :
|
||
- Titre texte à lire
|
||
- Description longue
|
||
- Bouton "Annuler" ou "Plus tard"
|
||
- Popup bloquante
|
||
- Image/cover du contenu
|
||
|
||
✅ **Uniquement** :
|
||
- Son bref (notification)
|
||
- Icône selon tag du contenu
|
||
- Compteur chiffres (7→1)
|
||
|
||
**Icônes par tag** :
|
||
|
||
| Tag | Icône | Couleur |
|
||
|-----|-------|---------|
|
||
| Culture générale | 🏛️ | Bleu |
|
||
| Histoire | 📜 | Marron |
|
||
| Voyage | ✈️ | Vert |
|
||
| Famille | 👨👩👧 | Orange |
|
||
| Musique | 🎵 | Rose |
|
||
| Sport | ⚽ | Rouge |
|
||
| Technologie | 💻 | Gris |
|
||
| Automobile | 🚗 | Noir |
|
||
| Politique | 🏛️ | Bleu foncé |
|
||
| Économie | 📈 | Vert foncé |
|
||
| Cryptomonnaie | ₿ | Or |
|
||
| Santé | 🏥 | Rouge clair |
|
||
| Amour | ❤️ | Rose vif |
|
||
|
||
**Interface visuelle (minimaliste)** :
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [Player actuel] │
|
||
│ ▶️ Podcast en cours... │
|
||
│ 0:00 ────●──────────── 12:34 │
|
||
│ │
|
||
│ [|◀] [⏸️] [▶|] │
|
||
├────────────────────────────────────────┤
|
||
│ 🏛️ │
|
||
│ 7 │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
**Évolution du compteur** :
|
||
- Affichage pendant 7 secondes
|
||
- Compteur décrémente : 7 → 6 → 5 → 4 → 3 → 2 → 1 → disparaît
|
||
- Police grande (72pt), bold, couleur blanche
|
||
- Background semi-transparent (noir 50% opacity)
|
||
|
||
**Notification sonore** :
|
||
- **Son** : bip court (0.5s) ou "ding" doux personnalisé RoadWave
|
||
- **Volume** : suit le volume système notification (indépendant du volume media)
|
||
- **Pas de vibration** : inutile en voiture (téléphone sur support)
|
||
- **Configurable** : user peut choisir dans settings :
|
||
- Bip système
|
||
- Son RoadWave personnalisé
|
||
- Muet (visuel uniquement)
|
||
|
||
---
|
||
|
||
#### 17.2.2b Conformité CarPlay / Android Auto
|
||
|
||
**Décision** : Notification sonore uniquement en mode CarPlay/Android Auto
|
||
|
||
**Contexte** :
|
||
- [CarPlay Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/carplay) interdisent les overlays qui "takeover" l'écran
|
||
- [Android Auto Media Apps Guidelines](https://developer.android.com/training/cars/media) imposent des interactions minimales
|
||
- Sécurité routière maximale = pas de distraction visuelle
|
||
|
||
**Comportement en mode CarPlay/Android Auto** :
|
||
|
||
❌ **Désactivé** :
|
||
- Icône overlay
|
||
- Compteur visuel (7...6...5...)
|
||
- Tout élément graphique supplémentaire
|
||
|
||
✅ **Activé uniquement** :
|
||
- Notification sonore (bip ou ding)
|
||
- Bouton "Suivant" standard (déjà présent)
|
||
|
||
**Détection automatique** :
|
||
|
||
```swift
|
||
// iOS - Détection CarPlay
|
||
import MediaPlayer
|
||
|
||
class NotificationManager {
|
||
var isCarPlayActive: Bool {
|
||
if #available(iOS 14.0, *) {
|
||
return MPNowPlayingInfoCenter.default().playbackState == .playing &&
|
||
MPPlayableContentManager.shared().context != nil
|
||
}
|
||
return false
|
||
}
|
||
|
||
func showGeoNotification(content: GeoContent) {
|
||
if isCarPlayActive {
|
||
// CarPlay : sonore uniquement
|
||
AudioServicesPlaySystemSound(1052) // Notification beep
|
||
// Pas d'overlay visuel
|
||
} else {
|
||
// Mode normal : sonore + icône + compteur
|
||
showFullNotification(content)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```kotlin
|
||
// Android - Détection Android Auto
|
||
import android.car.Car
|
||
|
||
class NotificationManager(private val context: Context) {
|
||
private fun isAndroidAutoActive(): Boolean {
|
||
val carManager = context.getSystemService(Context.CAR_SERVICE) as? Car
|
||
return carManager?.isConnected == true
|
||
}
|
||
|
||
fun showGeoNotification(content: GeoContent) {
|
||
if (isAndroidAutoActive()) {
|
||
// Android Auto : sonore uniquement
|
||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||
.setSmallIcon(R.drawable.ic_notification)
|
||
.setSound(defaultSoundUri)
|
||
.setCategory(NotificationCompat.CATEGORY_NAVIGATION)
|
||
.build()
|
||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||
} else {
|
||
// Mode normal : sonore + overlay visuel
|
||
showFullNotification(content)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Expérience utilisateur en CarPlay/Android Auto** :
|
||
|
||
1. User roule, app connectée à CarPlay/Android Auto
|
||
2. ETA 7s atteint → notification sonore uniquement (bip)
|
||
3. User entend le bip, sait qu'un contenu géolocalisé est disponible
|
||
4. User appuie sur bouton "Suivant" physique au volant
|
||
5. Décompte 5s démarre (audio continue normalement)
|
||
6. Contenu géolocalisé démarre après 5s
|
||
|
||
**Justification** :
|
||
- ✅ Conformité maximale CarPlay/Android Auto guidelines
|
||
- ✅ Sécurité routière (pas de distraction visuelle)
|
||
- ✅ User peut toujours valider via bouton "Suivant" standard
|
||
- ✅ Apps comparables (Waze, Apple Maps) utilisent alertes sonores similaires
|
||
|
||
**Alternative envisagée** : Mini-badge sur bouton "Suivant"
|
||
- Moins invasif qu'un compteur
|
||
- Mais toujours considéré comme overlay (zone grise)
|
||
- **Décision** : Privilégier conformité maximale (sonore uniquement)
|
||
|
||
---
|
||
|
||
#### 17.2.3 Décompte après validation
|
||
|
||
**User appuie sur "Suivant"** :
|
||
|
||
**Étape 1 : Transition visuelle (0.3s)**
|
||
- Icône + compteur "7" disparaissent avec fade out
|
||
- Nouveau compteur "5" apparaît (plus grand, centré)
|
||
|
||
**Étape 2 : Décompte 5 secondes**
|
||
- Compteur décrémente : 5 → 4 → 3 → 2 → 1
|
||
- Contenu actuel continue de jouer **normalement** (pas de baisse volume)
|
||
- User entend le contenu en cours pendant le décompte
|
||
|
||
**Étape 3 : Fin du décompte**
|
||
- Compteur atteint "0"
|
||
- Fade out 0.3s du contenu actuel
|
||
- Fade in 0.3s du contenu géolocalisé
|
||
- Transition audio fluide (pas de silence)
|
||
|
||
**Cas particulier : contenu actuel se termine pendant le décompte**
|
||
|
||
Exemple : User clique "Suivant", décompte démarre à 5s, mais contenu actuel se termine après 2s.
|
||
|
||
```
|
||
Timeline :
|
||
T+0s : User clique "Suivant", décompte démarre (5...4...3...)
|
||
T+2s : Contenu actuel se termine
|
||
→ Contenu suivant du buffer démarre immédiatement
|
||
→ Décompte continue (2...1...)
|
||
T+5s : Décompte atteint 0
|
||
→ Fade out du contenu buffer (celui qui a démarré à T+2s)
|
||
→ Fade in du contenu géolocalisé
|
||
```
|
||
|
||
**Justification** :
|
||
- Évite silence inconfortable pendant décompte
|
||
- User continue d'écouter du contenu intéressant
|
||
- Transition naturelle
|
||
|
||
**Interface pendant décompte** :
|
||
|
||
```
|
||
┌────────────────────────────────────────┐
|
||
│ [Player actuel] │
|
||
│ ▶️ Podcast en cours... │
|
||
│ 0:00 ────●──────────── 12:34 │
|
||
│ │
|
||
│ [|◀] [⏸️] [▶|] │
|
||
├────────────────────────────────────────┤
|
||
│ │
|
||
│ 3 │
|
||
│ │
|
||
│ Contenu géolocalisé arrive │
|
||
└────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 17.3 Limitation anti-spam
|
||
|
||
**Problème identifié** :
|
||
- Routes riches en points d'intérêt (parcours touristiques)
|
||
- Risque de notifier toutes les 30 secondes
|
||
- Fatigue utilisateur + distraction conducteur
|
||
|
||
**Solution : quota horaire + cooldown**
|
||
|
||
#### 17.3.1 Quota : 6 contenus géolocalisés par heure
|
||
|
||
**Règle** :
|
||
- Maximum **6 contenus géolocalisés** notifiés par heure
|
||
- Fenêtre glissante : calcul sur les 60 dernières minutes (pas réinitialisation à 0h)
|
||
- Si quota atteint : notifications suivantes ignorées silencieusement
|
||
|
||
**Exemple** :
|
||
```
|
||
10h05 : Contenu 1 notifié ✅ (quota : 1/6)
|
||
10h12 : Contenu 2 notifié ✅ (quota : 2/6)
|
||
10h18 : Contenu 3 notifié ✅ (quota : 3/6)
|
||
10h25 : Contenu 4 notifié ✅ (quota : 4/6)
|
||
10h33 : Contenu 5 notifié ✅ (quota : 5/6)
|
||
10h41 : Contenu 6 notifié ✅ (quota : 6/6)
|
||
10h52 : Contenu 7 détecté mais ignoré ❌ (quota atteint)
|
||
11h06 : Contenu 1 expire (>60 min) → quota libéré (5/6)
|
||
11h08 : Contenu 8 notifié ✅ (quota : 6/6)
|
||
```
|
||
|
||
**Exception : audio-guides multi-séquences** :
|
||
- Un audio-guide avec N séquences compte comme **1 seul contenu** dans le quota
|
||
- Une fois démarré, toutes ses séquences sont jouables (pas de limite)
|
||
- Exemple : audio-guide 8 séquences = 1 quota, contenus simples restants = 5
|
||
|
||
**Implémentation backend (Redis)** :
|
||
|
||
```go
|
||
// Structure quota (sorted set avec timestamps)
|
||
// Clé : user:{user_id}:geo_quota
|
||
// Valeur : ZADD avec timestamp
|
||
|
||
func CanNotify(userID string) bool {
|
||
key := fmt.Sprintf("user:%s:geo_quota", userID)
|
||
now := time.Now().Unix()
|
||
oneHourAgo := now - 3600
|
||
|
||
// Supprimer entrées > 1h
|
||
redis.ZRemRangeByScore(key, "-inf", oneHourAgo)
|
||
|
||
// Compter entrées restantes
|
||
count := redis.ZCard(key)
|
||
|
||
return count < 6
|
||
}
|
||
|
||
func RecordNotification(userID string, contentID string) {
|
||
key := fmt.Sprintf("user:%s:geo_quota", userID)
|
||
now := time.Now().Unix()
|
||
|
||
// Ajouter entrée avec timestamp actuel
|
||
redis.ZAdd(key, now, contentID)
|
||
|
||
// TTL 1h (sécurité)
|
||
redis.Expire(key, 3600)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### 17.3.2 Cooldown : 10 minutes après notification ignorée
|
||
|
||
**Règle** :
|
||
- Si user ne clique pas sur "Suivant" pendant les 7 secondes
|
||
- → Cooldown de **10 minutes** activé
|
||
- → Aucune nouvelle notification pendant ce délai
|
||
- → Même si quota non atteint
|
||
|
||
**Justification** :
|
||
- User a probablement une raison de ne pas vouloir de contenu géolocalisé maintenant
|
||
- Évite harcèlement (notifications répétées ignorées)
|
||
- Respecte choix implicite de l'utilisateur
|
||
|
||
**Timeline exemple** :
|
||
```
|
||
10h00 : Notification contenu A (7s)
|
||
10h00+7s : User n'a pas cliqué → cooldown activé
|
||
10h05 : Contenu B détecté → ignoré (cooldown actif)
|
||
10h08 : Contenu C détecté → ignoré (cooldown actif)
|
||
10h10 : Cooldown expire
|
||
10h11 : Contenu D détecté → notification affichée ✅
|
||
```
|
||
|
||
**Implémentation (Redis)** :
|
||
|
||
```go
|
||
func IsInCooldown(userID string) bool {
|
||
key := fmt.Sprintf("user:%s:geo_cooldown", userID)
|
||
exists := redis.Exists(key)
|
||
return exists == 1
|
||
}
|
||
|
||
func ActivateCooldown(userID string) {
|
||
key := fmt.Sprintf("user:%s:geo_cooldown", userID)
|
||
redis.Set(key, "1", 10*time.Minute)
|
||
}
|
||
```
|
||
|
||
**Exception : notification validée (user a cliqué)** :
|
||
- Pas de cooldown si user a cliqué sur "Suivant"
|
||
- Même si user skip le contenu ensuite (pendant le décompte ou après)
|
||
- Cooldown = pénalité uniquement pour notification complètement ignorée
|
||
|
||
---
|
||
|
||
### 17.4 Navigation avec contenus géolocalisés
|
||
|
||
#### 17.4.1 Historique de navigation
|
||
|
||
**Structure** (voir [../../recommendation/rules/interactions-navigation.md](../../recommendation/rules/interactions-navigation.md#52-commande-précédent)) :
|
||
|
||
- **10 contenus maximum** en mémoire (Redis List)
|
||
- Structure : `[{content_id, position_seconds, listened_at, type}, ...]`
|
||
- FIFO : au-delà de 10, suppression du plus ancien
|
||
|
||
**Comportement avec contenus géolocalisés** :
|
||
|
||
Les contenus géolocalisés s'intègrent dans l'historique comme des contenus normaux :
|
||
|
||
```
|
||
Historique :
|
||
[
|
||
{id: "buffer_1", position: 180, type: "contextuel"},
|
||
{id: "geo_tour_eiffel", position: 42, type: "geo_anchored"},
|
||
{id: "buffer_2", position: 90, type: "neutre"},
|
||
{id: "buffer_3", position: 0, type: "contextuel"}
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
#### 17.4.2 Commande "Précédent" avec contenu géolocalisé
|
||
|
||
**Cas A : User écoute contenu géolocalisé, puis skip**
|
||
|
||
Timeline :
|
||
1. Contenu buffer_1 joue (position 3:00)
|
||
2. Notification contenu géolocalisé
|
||
3. User clique "Suivant" → décompte 5s → contenu géolocalisé démarre
|
||
4. User écoute 42 secondes du contenu géolocalisé
|
||
5. User appuie "Suivant" (skip) → contenu buffer_2 démarre
|
||
6. User appuie "Précédent" → **retour au contenu géolocalisé à 42s**
|
||
|
||
**Règle** : Comme décrit dans [../../recommendation/rules/interactions-navigation.md](../../recommendation/rules/interactions-navigation.md#52-commande-précédent) :
|
||
- Si temps écouté ≥ 10 secondes → replay contenu actuel depuis début
|
||
- Si temps écouté < 10 secondes → retour contenu précédent (position exacte)
|
||
|
||
Dans ce cas : 42s ≥ 10s → le contenu géolocalisé reprend à 42s (où user l'avait arrêté)
|
||
|
||
---
|
||
|
||
**Cas B : User ne clique pas pendant notification (ignore)**
|
||
|
||
Timeline :
|
||
1. Contenu buffer_1 joue
|
||
2. Notification contenu géolocalisé (7s)
|
||
3. User ne clique pas → notification disparaît
|
||
4. Contenu buffer_1 continue
|
||
5. User appuie "Précédent" → **retour contenu avant buffer_1**
|
||
|
||
**Règle** : Contenu géolocalisé ignoré n'entre PAS dans l'historique.
|
||
|
||
---
|
||
|
||
**Cas C : User skip pendant le décompte**
|
||
|
||
Timeline :
|
||
1. Notification contenu géolocalisé
|
||
2. User clique "Suivant" → décompte 5s démarre (5...4...3...)
|
||
3. User re-clique "Suivant" pendant le décompte → **annule le décompte**
|
||
4. Contenu suivant du buffer démarre
|
||
5. Contenu géolocalisé n'entre PAS dans l'historique
|
||
|
||
**Justification** : User a explicitement annulé, pas d'intérêt pour ce contenu.
|
||
|
||
---
|
||
|
||
### 17.5 Basculement automatique voiture ↔ piéton
|
||
|
||
**Principe** : Détection automatique du mode selon vitesse GPS, sans action utilisateur.
|
||
|
||
#### 17.5.1 Calcul vitesse moyenne
|
||
|
||
**Méthode** : Moyenne glissante sur 30 secondes
|
||
|
||
```javascript
|
||
// Historique GPS : 30 derniers points (1 point/seconde)
|
||
const gpsHistory = [
|
||
{lat: 48.8584, lon: 2.2945, speed: 8.3, timestamp: 1642861200},
|
||
{lat: 48.8585, lon: 2.2946, speed: 8.5, timestamp: 1642861201},
|
||
// ... 28 autres points
|
||
];
|
||
|
||
// Calcul vitesse moyenne
|
||
const avgSpeed = gpsHistory
|
||
.reduce((sum, point) => sum + point.speed, 0) / gpsHistory.length;
|
||
|
||
// km/h = m/s × 3.6
|
||
const avgSpeedKmh = avgSpeed * 3.6;
|
||
|
||
if (avgSpeedKmh < 5) {
|
||
mode = "piéton";
|
||
} else {
|
||
mode = "voiture";
|
||
}
|
||
```
|
||
|
||
**Hysteresis (éviter basculements intempestifs)** :
|
||
- Nouvelle vitesse doit être stable pendant **10 secondes** avant basculement
|
||
- Exemple : user passe de 20 km/h à 3 km/h (arrêt feu rouge)
|
||
- Si vitesse remonte à 20 km/h après 8s → pas de basculement
|
||
- Si vitesse reste à 3 km/h pendant 10s → basculement piéton
|
||
|
||
---
|
||
|
||
#### 17.5.2 Effets du basculement
|
||
|
||
**Passage voiture → piéton** :
|
||
|
||
| Paramètre | Avant (voiture) | Après (piéton) |
|
||
|-----------|----------------|----------------|
|
||
| **App requise** | Premier plan | Arrière-plan OK |
|
||
| **Notification** | Sonore + icône + compteur | Push système standard |
|
||
| **Rayon détection** | ETA 7s (distance variable) | 200 mètres fixes |
|
||
| **Type contenu** | Tous contenus géolocalisés | Audio-guides uniquement |
|
||
|
||
**Passage piéton → voiture** :
|
||
|
||
| Paramètre | Avant (piéton) | Après (voiture) |
|
||
|-----------|---------------|----------------|
|
||
| **App requise** | Arrière-plan OK | Premier plan |
|
||
| **Notification** | Push système standard | Sonore + icône + compteur |
|
||
| **Rayon détection** | 200 mètres fixes | ETA 7s (distance variable) |
|
||
| **Type contenu** | Audio-guides uniquement | Tous contenus géolocalisés |
|
||
|
||
**Transition fluide** :
|
||
- Pas de popup ou message à l'utilisateur
|
||
- Basculement invisible et automatique
|
||
- Permissions ajustées automatiquement (si déjà accordées)
|
||
|
||
---
|
||
|
||
### 17.6 Edge cases
|
||
|
||
#### 17.6.1 User passe trop vite devant le point (>130 km/h)
|
||
|
||
**Scénario** : Autoroute A6, vitesse 130 km/h, contenu géolocalisé détecté.
|
||
|
||
**Calcul** :
|
||
- Vitesse : 130 km/h = 36.1 m/s
|
||
- ETA 7s → distance notification : 7 × 36.1 = **252 mètres** avant le point
|
||
- User a 7s pour cliquer "Suivant"
|
||
- Décompte 5s démarre
|
||
- À 130 km/h, user parcourt : 5 × 36.1 = 180m pendant décompte
|
||
- Distance restante au démarrage contenu : 252 - 180 = **72 mètres avant le point**
|
||
|
||
**Conclusion** : Le système fonctionne même à très haute vitesse ✅
|
||
|
||
**Cas extrême : 180 km/h** (illégal mais théoriquement possible) :
|
||
- Vitesse : 180 km/h = 50 m/s
|
||
- ETA 7s → distance notification : 350m avant le point
|
||
- Décompte 5s : user parcourt 250m
|
||
- Distance restante : 350 - 250 = **100m avant le point**
|
||
- Contenu démarre encore avant le point ✅
|
||
|
||
---
|
||
|
||
#### 17.6.2 Multiples points géolocalisés proches
|
||
|
||
**Scénario** : Route départementale avec 3 châteaux espacés de 800m chacun.
|
||
|
||
```
|
||
Château A ----800m---- Château B ----800m---- Château C
|
||
```
|
||
|
||
À 50 km/h (13.9 m/s), user met 57 secondes pour parcourir 800m.
|
||
|
||
**Timeline** :
|
||
```
|
||
T+0s : Notification Château A (7s avant)
|
||
T+7s : User clique "Suivant" → décompte 5s
|
||
T+12s : Contenu Château A démarre
|
||
T+30s : Contenu Château A se termine (durée 18s)
|
||
T+30s : Retour buffer normal (contenu contextuel)
|
||
T+50s : Notification Château B devrait se déclencher
|
||
MAIS : quota 1/6 utilisé, pas de cooldown (user a validé A)
|
||
→ Notification affichée ✅
|
||
T+57s : User clique "Suivant" → contenu Château B démarre
|
||
...
|
||
```
|
||
|
||
**Problème** : Si user ignore une notification, cooldown 10 min bloque les suivantes.
|
||
|
||
**Solution proposée** : **Ajuster le cooldown selon validation précédente**
|
||
|
||
Nouvelle règle :
|
||
- Notification validée (user a cliqué) : pas de cooldown
|
||
- Notification ignorée (user n'a pas cliqué) : cooldown 10 min
|
||
- Exception : si 2+ notifications validées consécutives, cooldown réduit à 5 min
|
||
|
||
**Implémentation** :
|
||
|
||
```go
|
||
func CalculateCooldown(userID string) time.Duration {
|
||
// Récupérer historique dernières notifications
|
||
history := getNotificationHistory(userID, 3) // 3 dernières
|
||
|
||
validatedCount := 0
|
||
for _, notif := range history {
|
||
if notif.Validated {
|
||
validatedCount++
|
||
}
|
||
}
|
||
|
||
if validatedCount >= 2 {
|
||
return 5 * time.Minute // Cooldown réduit
|
||
}
|
||
return 10 * time.Minute // Cooldown standard
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### 17.6.3 User arrêté longtemps près d'un point (parking)
|
||
|
||
**Scénario** : User se gare à 30m d'un château, sort de voiture, visite 1h, revient.
|
||
|
||
**Problème** :
|
||
- Vitesse < 5 km/h + distance < 50m → notification immédiate
|
||
- Mais user en mode "stationnement", pas en mode "conduite"
|
||
|
||
**Solution** : **Détection mode stationnement**
|
||
|
||
Règle :
|
||
- Si vitesse < 1 km/h pendant **2 minutes** consécutives
|
||
- → Mode "stationnement" activé
|
||
- → Pas de notification de contenus géolocalisés
|
||
- → Basculement automatique en mode piéton (push arrière-plan)
|
||
|
||
**Reprise conduite** :
|
||
- Vitesse > 5 km/h pendant 10s
|
||
- → Mode "voiture" réactivé
|
||
- → Notifications reprennent (si quota non atteint)
|
||
|
||
---
|
||
|
||
### 17.7 Récapitulatif
|
||
|
||
| Point | Décision | Justification |
|
||
|-------|----------|---------------|
|
||
| **Détection** | ETA 7s avant point (API GPS native) | Précis, fiable, adapté à toutes vitesses |
|
||
| **Notification** | Sonore + icône + compteur (pas de texte) | Sécurité routière, minimalisme |
|
||
| **Validation** | Bouton "Suivant" uniquement | Un seul geste, simple, pas de distraction |
|
||
| **Décompte** | 5s avec contenu actuel qui continue | Évite silence, fluidité |
|
||
| **Quota** | 6 contenus/heure, fenêtre glissante | Anti-spam efficace |
|
||
| **Cooldown** | 10 min après notification ignorée | Respecte choix utilisateur |
|
||
| **Exception audio-guides** | Toutes séquences comptent comme 1 | Encourage création audio-guides |
|
||
| **Basculement auto** | Vitesse < 5 km/h = piéton, ≥ 5 km/h = voiture | Transparent, pas de friction |
|
||
| **Mode stationnement** | Vitesse < 1 km/h pendant 2 min → piéton | Évite notifications inutiles |
|
||
|
||
---
|
||
|
||
## Récapitulatif Section 17
|
||
|
||
| Point | Décision | Coût | Complexité |
|
||
|-------|----------|------|------------|
|
||
| **17.1** Principe général | Notification 7s avant, app ouverte requise | 0€ | Faible |
|
||
| **17.2.1** Calcul ETA | API GPS native iOS/Android | 0€ | Faible |
|
||
| **17.2.2** Format notification | Sonore + icône + compteur (minimaliste) | 0€ | Faible |
|
||
| **17.2.3** Décompte 5s | Contenu actuel continue, transition fluide | 0€ | Faible |
|
||
| **17.3.1** Quota 6/h | Redis sorted set, fenêtre glissante | 0€ | Moyenne |
|
||
| **17.3.2** Cooldown 10 min | Redis TTL après ignorance | 0€ | Faible |
|
||
| **17.4** Historique navigation | 10 contenus max, FIFO, comme contenus normaux | 0€ | Faible |
|
||
| **17.5** Basculement auto | Vitesse moyenne 30s, hysteresis 10s | 0€ | Moyenne |
|
||
| **17.6** Edge cases | Haute vitesse, points multiples, stationnement | 0€ | Moyenne |
|
||
|
||
**Coût total MVP : 0€** (GPS natif, Redis existant)
|
||
|
||
---
|
||
|
||
## Points d'attention pour Gherkin
|
||
|
||
- Tester calcul ETA à différentes vitesses (10 km/h, 50 km/h, 130 km/h)
|
||
- Tester cas vitesse < 5 km/h ET distance < 50m (notification immédiate)
|
||
- Tester affichage notification (icône + compteur, pas de texte)
|
||
- Tester validation "Suivant" → décompte 5s → contenu démarre
|
||
- Tester contenu actuel se termine pendant décompte → buffer démarre
|
||
- Tester quota 6/h : 7e notification ignorée
|
||
- Tester cooldown 10 min après notification ignorée
|
||
- Tester exception audio-guides (toutes séquences = 1 quota)
|
||
- Tester navigation "Précédent" après skip contenu géolocalisé (position sauvegardée)
|
||
- Tester basculement voiture → piéton (vitesse < 5 km/h stable 10s)
|
||
- Tester basculement piéton → voiture (vitesse ≥ 5 km/h stable 10s)
|
||
- Tester mode stationnement (vitesse < 1 km/h pendant 2 min)
|
||
- Tester multiples points proches (notifications successives si quota OK)
|
||
- Tester haute vitesse (130 km/h) : notification 252m avant, contenu démarre au bon moment
|