Chapitre 9.2 — ⭐ Notifications locales : le défi quotidien
Où on en est : le rituel des modules est acquis. On l’applique au cas le plus complet du SDK : la notification quotidienne qui ramène jouer — permission, canal Android, planification récurrente, et tap → deep link vers un quiz.
⏱️ TL;DR — Locales = planifiées par l’app sur l’appareil (aucun serveur — notre cas) ; push = envoyées d’un serveur (hors périmètre, ADR 0001). La chaîne : handler global → canal Android (obligatoire) → permission (ch. 9.1) →
scheduleNotificationAsyncavec triggerDAILY→ au tap, lirenotification.request.content.data.urlet router. Le Switch « Rappel quotidien » des Réglages pilote tout.
🎯 Objectifs
- Distinguer local vs push (et pourquoi Drill n’aura jamais besoin du push).
- Configurer : handler, canal Android, icône monochrome.
- Planifier/annuler la notification quotidienne (trigger DAILY, heure choisie).
- Router le tap vers un quiz via le deep link (la promesse du ch. 5.5 tenue).
Local vs push — la distinction fondatrice
| Locale | Push | |
|---|---|---|
| Origine | L’app elle-même, planifiée sur l’appareil | Un serveur → FCM → l’appareil |
| Serveur requis | Aucun | Oui (+ tokens, + expo-notifications service) |
| Cas d’usage | Rappels, minuteurs, récurrences | Messages, actualités, événements externes |
| Expo Go | ✅ (locales) | ❌ (dev build requis) |
| Drill | ✅ le défi quotidien | Jamais (rien d’externe à annoncer) |
Le défi quotidien est un rappel récurrent local : le téléphone se le rappelle à lui-même. Zéro infra — cohérent avec toute notre architecture.
La configuration une fois pour toutes
npx expo install expo-notifications// app.json — le config plugin (icône MONOCHROME exigée par Android)
"plugins": [
["expo-notifications", {
"icon": "./assets/images/notification-icon.png",
"color": "#087ea4"
}]
]// lib/notifications.ts — le module complet de Drill
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
// 1. Le handler : que faire si une notif arrive APP OUVERTE ? (par défaut : rien de visible)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true, // on la montre quand même (discrète, en bannière)
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
})
// 2. Le canal Android — OBLIGATOIRE depuis Android 8 (catégorise les notifs pour l'utilisateur)
export async function setupNotificationChannel() {
if (Platform.OS !== 'android') return
await Notifications.setNotificationChannelAsync('daily-challenge', {
name: 'Défi quotidien',
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 200],
})
}Le canal est une spécificité Android à comprendre : l’utilisateur peut régler (ou couper) chaque canal indépendamment dans les paramètres système — nommez-les proprement, c’est votre vitrine dans les réglages du téléphone.
Planifier le défi quotidien
// lib/notifications.ts — suite
const DAILY_ID = 'daily-challenge'
export async function scheduleDailyChallenge(hour: number, minute = 0) {
await cancelDailyChallenge() // idempotent : jamais deux planifications
const themes = ['bootstrap', 'nextjs', 'php', 'symfony']
const theme = themes[Math.floor(Math.random() * themes.length)]
await Notifications.scheduleNotificationAsync({
identifier: DAILY_ID,
content: {
title: '🔥 Ton défi quotidien t\'attend',
body: `5 questions ${theme} — bats ton record !`,
data: { url: `/quiz?theme=${theme}&count=5` }, // ← le deep link embarqué
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour,
minute,
channelId: 'daily-challenge',
},
})
}
export function cancelDailyChallenge() {
return Notifications.cancelScheduledNotificationAsync(DAILY_ID)
}Points d’architecture :
identifierfixe + cancel avant schedule : la fonction est idempotente — l’appeler dix fois ne crée qu’UNE planification. (Le bug des notifs en double est un classique.)- Le trigger DAILY re-déclenche chaque jour à l’heure dite — le système gère, app tuée
comprise. (Autres triggers :
timeInterval,date,WEEKLY…) data.url: la charge utile — notre route interne, prête pour le routeur.
Le branchement UI est déjà écrit : le Switch dailyReminder des Réglages (ch. 6.4) appelle
ensureNotificationPermission() (ch. 9.1) puis schedule/cancel. Les couches s’emboîtent —
encore.
Le tap : de la notification au quiz
// hooks/useNotificationRouting.ts — monté une fois dans le layout racine
import * as Notifications from 'expo-notifications'
import { router } from 'expo-router'
import { useEffect } from 'react'
export function useNotificationRouting() {
useEffect(() => {
// Cas 1 : app OUVERTE ou en arrière-plan — l'événement suffit
const sub = Notifications.addNotificationResponseReceivedListener(response => {
const url = response.notification.request.content.data?.url
if (typeof url === 'string') router.push(url as never)
})
// Cas 2 : app TUÉE, lancée PAR la notification — l'événement est passé avant le JS :
// on interroge la « dernière réponse »
Notifications.getLastNotificationResponseAsync().then(response => {
const url = response?.notification.request.content.data?.url
if (typeof url === 'string') router.push(url as never)
})
return () => sub.remove()
}, [])
}Les DEUX cas sont obligatoires : le listener rate le lancement à froid (le JS n’existait pas
encore au tap), getLastNotificationResponseAsync le rattrape. Et la robustesse vient du
ch. 5.5 : /quiz?theme=…&count=5 est déjà blindé contre les params exotiques.
⚠️ Piège Android 13+ — La permission notifications (
POST_NOTIFICATIONS) n’existe que depuis Android 13 : sur les versions antérieures,grantedest vrai d’office. Et testez le trigger DAILY avec une heure PROCHE (dans 2 min) plutôt qu’en attendant demain — puis vérifiez dansNotifications.getAllScheduledNotificationsAsync()que la planification est bien là. Le mode « Ne pas déranger » et les optimisations batterie agressives (Xiaomi, Samsung) peuvent retarder les notifs : c’est le système qui décide, pas vous.
📱 Essaie sur ton téléphone — Le test complet : activer le rappel dans les Réglages (boîte de permission → oui), planifier à now+2 min, TUER l’app, verrouiller le téléphone. La notif tombe → tap → Drill s’ouvre DIRECTEMENT sur une partie. C’est le moment le plus « vraie app » du cours.
✏️ Exercices
1. Ajoutez le choix de l’heure du rappel dans les Réglages (défaut 19h) — persisté, et la re-planification à chaque changement.
✅ Solution
reminderHour: number dans le store settings (ch. 6.4). Le Switch ET le sélecteur d’heure
appellent une même fonction : if (dailyReminder) scheduleDailyChallenge(reminderHour) —
l’idempotence (cancel interne) rend la re-planification triviale. UI simple : une rangée de
Badges 8h/12h/19h/21h (un vrai time picker : @react-native-community/datetimepicker, en
« aller plus loin »).
2. La notification propose toujours le même texte. Variez : 5 accroches tirées au hasard à la planification. Pourquoi la variation se fait-elle À LA PLANIFICATION et pas à l’affichage ?
✅ Solution
Tableau d’accroches, pick() au moment du schedule. La notification locale est figée à la
planification : son contenu est remis au système, aucun code ne tourne à l’affichage (l’app
peut être tuée). Conséquence : le thème aléatoire aussi est fixé la veille — parfaitement
acceptable ; la vraie rotation quotidienne exigerait de re-planifier à chaque ouverture d’app
(pattern courant : rescheduler dans useNotificationRouting).
3. Le badge « refus définitif » : si l’utilisateur a bloqué les notifs, le Switch des Réglages doit afficher l’état et un lien vers les paramètres. Implémentez avec le helper à 3 états du ch. 9.1.
✅ Solution
Au toggle ON : const status = await ensureNotificationPermission(). 'granted' → schedule ;
'denied' → Switch off + toast discret ; 'blocked' → Switch off +
<AppText variant="caption">Notifications bloquées — <Text onPress={Linking.openSettings} className="text-brand-light">ouvrir les paramètres</Text></AppText>. L’état ‘blocked’ mérite
un chemin dédié : redemander est impossible, l’expliquer est la seule UX honnête.
🧠 Quiz
1. Pourquoi le défi quotidien n’a-t-il besoin d’aucun serveur ?
Réponse
C’est une notification locale : l’app la planifie sur l’appareil (trigger DAILY), le système la déclenche — app tuée comprise. Le push (serveur → FCM) ne sert qu’aux événements externes, que Drill n’a pas.
2. Qu’est-ce qu’un canal de notification Android ?
Réponse
Une catégorie déclarée par l’app (obligatoire depuis Android 8) que l’utilisateur peut régler ou couper indépendamment dans les paramètres système — importance, vibration, nom visible.
3. Pourquoi cancel avant schedule dans scheduleDailyChallenge ?
Réponse
Idempotence : avec un identifier fixe + cancel préalable, appeler la fonction N fois ne laisse qu’UNE planification — anti « notifications en double », le bug classique.
4. Pourquoi deux mécanismes pour gérer le tap ?
Réponse
Le listener (addNotificationResponseReceivedListener) couvre app ouverte/arrière-plan ; le
lancement à froid l’a raté (pas de JS au moment du tap) → getLastNotificationResponseAsync
le rattrape au démarrage.
5. Où voyage la destination du tap ?
Réponse
Dans content.data.url — notre route interne (/quiz?theme=…&count=5), poussée au router au
tap. La robustesse des params vient du travail du ch. 5.3/5.5.
👉 Chapitre suivant : 9.3 — Haptics & sons — le feedback qui rend un quiz addictif.