Skip to Content
React Native9. APIs natives9.2 Notifications locales ⭐

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;DRLocales = 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) → scheduleNotificationAsync avec trigger DAILY → au tap, lire notification.request.content.data.url et 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

LocalePush
OrigineL’app elle-même, planifiée sur l’appareilUn serveur → FCM → l’appareil
Serveur requisAucunOui (+ tokens, + expo-notifications service)
Cas d’usageRappels, minuteurs, récurrencesMessages, actualités, événements externes
Expo Go✅ (locales)❌ (dev build requis)
Drill✅ le défi quotidienJamais (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 :

  • identifier fixe + 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, granted est vrai d’office. Et testez le trigger DAILY avec une heure PROCHE (dans 2 min) plutôt qu’en attendant demain — puis vérifiez dans Notifications.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.