diff --git a/docs/firebase-einrichtung.md b/docs/firebase-einrichtung.md index c3a9cd5..139222d 100644 --- a/docs/firebase-einrichtung.md +++ b/docs/firebase-einrichtung.md @@ -110,10 +110,23 @@ Die Keys liegen damit im **Google Secret Manager** — verschlüsselt, nur die C **Kosten:** PlantNet kostenlos; Claude ca. 1–3 Cent pro Foto (Erkennungs-Fallback + Pflegeprofil); Functions/Storage bei eurer Nutzung im Gratis-Kontingent. Der Budget-Alarm aus Schritt 2 überwacht alles. -## Schritt 6 und folgende (kommen mit den nächsten Blöcken) +## Schritt 6: Push-Erinnerungen (FCM + geplante Function) -- **FCM** — Push-Einrichtung inkl. APNs-Schlüssel für iOS (aus dem Apple Developer Account) und die geplante Erinnerungs-Function (läuft alle 15 Minuten). -- **Google-/Apple-Login** — zusätzlich zu E-Mail/Passwort. +**So funktioniert es:** Die App registriert nach dem Login das Gerät bei Firebase Cloud Messaging und speichert den Geräte-Token, die Zeitzone und die Erinnerungszeit im Nutzer-Dokument. Die geplante Function `sendDailyReminders` läuft **alle 15 Minuten**, prüft für jeden Nutzer „ist gerade seine Erinnerungszeit erreicht und gibt es offene Aufgaben?" und schickt dann **eine Sammel-Push pro Tag** („3 Pflanzen brauchen dich heute 🌱 — Gießen: Monstera, Orchidee · Düngen: Bogenhanf"). Ungültig gewordene Tokens (Gerät gewechselt, App gelöscht) werden automatisch aufgeräumt. Kosten: ~2.900 Läufe/Monat, tief im Gratis-Kontingent. + +**Android:** funktioniert sofort — beim ersten App-Start nach diesem Update fragt die App die Benachrichtigungs-Berechtigung ab, fertig. + +**iOS braucht den Apple Developer Account** (Push ist auf iOS ohne bezahlten Account nicht möglich). Sobald er freigeschaltet ist: + +1. **APNs-Schlüssel erzeugen:** → *Certificates, Identifiers & Profiles* → **Keys** → „+" → Namen vergeben, **Apple Push Notifications service (APNs)** ankreuzen → Continue → Register → **.p8-Datei herunterladen** (nur einmal möglich!) und die **Key ID** notieren; die **Team ID** steht oben rechts im Account. +2. **In Firebase hinterlegen:** Firebase Console → Projekteinstellungen (Zahnrad) → **Cloud Messaging** → Abschnitt „Apple-App-Konfiguration" → APNs-Authentifizierungsschlüssel **hochladen** (.p8 + Key ID + Team ID). +3. **Push-Capability in Xcode:** `ios/Runner.xcworkspace` öffnen → Ziel *Runner* → *Signing & Capabilities* → „+ Capability" → **Push Notifications** hinzufügen (und einmal **Background Modes → Remote notifications** anhaken). Dafür muss als Team der Developer Account gewählt sein. + +Danach einmal neu bauen (`flutter run`) — die Erinnerungen landen dann auch auf dem iPhone-Sperrbildschirm. + +## Schritt 7 und folgende (kommen mit den nächsten Blöcken) + +- **Google-/Apple-Login** — zusätzlich zu E-Mail/Passwort (Apple-Login braucht ebenfalls den Developer Account). ## Begriffe kurz erklärt diff --git a/functions/src/index.ts b/functions/src/index.ts index 83f2b5f..052a94e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,5 +1,10 @@ import {onCall, HttpsError} from "firebase-functions/v2/https"; import {defineSecret} from "firebase-functions/params"; +import * as admin from "firebase-admin"; + +admin.initializeApp(); + +export {sendDailyReminders} from "./reminders"; // Secrets liegen im Google Secret Manager (firebase functions:secrets:set), // niemals im Code oder in der App. diff --git a/functions/src/reminders.ts b/functions/src/reminders.ts new file mode 100644 index 0000000..4e0cb2f --- /dev/null +++ b/functions/src/reminders.ts @@ -0,0 +1,211 @@ +import {onSchedule} from "firebase-functions/v2/scheduler"; +import * as admin from "firebase-admin"; + +/** + * Tägliche Sammel-Erinnerung: läuft alle 15 Minuten und prüft, welche Nutzer + * gerade ihre Erinnerungszeit erreicht haben und offene Aufgaben besitzen. + * Pro Nutzer und Tag wird höchstens eine Push-Nachricht verschickt. + */ + +interface UserDoc { + householdId?: string; + fcmTokens?: string[]; + reminderTime?: string; // "HH:mm" + timezone?: string; // IANA, z. B. "Europe/Berlin" + lastReminderSentOn?: string; // "YYYY-MM-DD" in Nutzer-Zeitzone +} + +interface LocalDate { + year: number; + month: number; + day: number; +} + +/** Datum/Uhrzeit eines Zeitpunkts in einer Zeitzone. */ +function inTimezone(date: Date, timezone: string): LocalDate & { + hour: number; + minute: number; +} { + const parts = new Intl.DateTimeFormat("en-GB", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).formatToParts(date); + const get = (type: string) => + parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10); + return { + year: get("year"), + month: get("month"), + day: get("day"), + hour: get("hour") % 24, + minute: get("minute"), + }; +} + +function dateKey(d: LocalDate): string { + const mm = String(d.month).padStart(2, "0"); + const dd = String(d.day).padStart(2, "0"); + return `${d.year}-${mm}-${dd}`; +} + +/** Tage zwischen zwei lokalen Datumsangaben (b - a). */ +function daysBetween(a: LocalDate, b: LocalDate): number { + const utcA = Date.UTC(a.year, a.month - 1, a.day); + const utcB = Date.UTC(b.year, b.month - 1, b.day); + return Math.round((utcB - utcA) / 86_400_000); +} + +interface DueSummary { + watering: string[]; + fertilizing: string[]; + overdueCount: number; +} + +/** Fällige Aufgaben des Haushalts nach dem Intervall-Modell berechnen. */ +async function collectDueTasks( + db: admin.firestore.Firestore, + householdId: string, + today: LocalDate, + timezone: string +): Promise { + const snapshot = await db + .collection("households") + .doc(householdId) + .collection("plants") + .get(); + + const summary: DueSummary = {watering: [], fertilizing: [], overdueCount: 0}; + for (const doc of snapshot.docs) { + const plant = doc.data(); + const nickname = (plant.nickname as string) ?? "Pflanze"; + + const check = ( + last: admin.firestore.Timestamp | undefined, + intervalDays: number, + bucket: string[] + ) => { + let daysOverdue: number; + if (!last) { + daysOverdue = 0; // noch nie erledigt → heute fällig + } else { + const lastLocal = inTimezone(last.toDate(), timezone); + const dueInDays = intervalDays - daysBetween(lastLocal, today); + if (dueInDays > 0) return; + daysOverdue = -dueInDays; + } + bucket.push(nickname); + if (daysOverdue > 0) summary.overdueCount += 1; + }; + + check( + plant.lastWatered, + (plant.wateringIntervalDays as number) ?? 7, + summary.watering + ); + check( + plant.lastFertilized, + (plant.fertilizingIntervalDays as number) ?? 28, + summary.fertilizing + ); + } + return summary; +} + +function buildBody(summary: DueSummary): string { + const parts: string[] = []; + const list = (names: string[]) => + names.length <= 3 + ? names.join(", ") + : `${names.slice(0, 3).join(", ")} und ${names.length - 3} weitere`; + if (summary.watering.length > 0) { + parts.push(`Gießen: ${list(summary.watering)}`); + } + if (summary.fertilizing.length > 0) { + parts.push(`Düngen: ${list(summary.fertilizing)}`); + } + let body = parts.join(" · "); + if (summary.overdueCount > 0) { + body += ` (${summary.overdueCount} überfällig)`; + } + return body; +} + +export const sendDailyReminders = onSchedule( + { + schedule: "*/15 * * * *", + region: "europe-west3", + timeZone: "Europe/Berlin", + memory: "256MiB", + }, + async () => { + const db = admin.firestore(); + const now = new Date(); + + const users = await db.collection("users").get(); + for (const userDoc of users.docs) { + const user = userDoc.data() as UserDoc; + const tokens = user.fcmTokens ?? []; + if (tokens.length === 0 || !user.householdId) continue; + + const timezone = user.timezone ?? "Europe/Berlin"; + const reminderTime = user.reminderTime ?? "09:00"; + const local = inTimezone(now, timezone); + + // Erinnerungszeit in 15-Minuten-Fenstern vergleichen: die Erinnerung + // "09:05" feuert im Lauf zwischen 09:00 und 09:14 Lokalzeit. + const [remHour, remMinute] = reminderTime.split(":").map(Number); + const sameSlot = + local.hour === remHour && + Math.floor(local.minute / 15) === Math.floor((remMinute ?? 0) / 15); + if (!sameSlot) continue; + + // Höchstens eine Erinnerung pro Tag. + const todayKey = dateKey(local); + if (user.lastReminderSentOn === todayKey) continue; + + const summary = await collectDueTasks( + db, + user.householdId, + local, + timezone + ); + const taskCount = summary.watering.length + summary.fertilizing.length; + if (taskCount === 0) continue; + + const response = await admin.messaging().sendEachForMulticast({ + tokens, + notification: { + title: + taskCount === 1 + ? "1 Pflanze braucht dich heute 🌱" + : `${taskCount} Pflanzen brauchen dich heute 🌱`, + body: buildBody(summary), + }, + apns: { + payload: {aps: {sound: "default", badge: taskCount}}, + }, + android: { + notification: {defaultSound: true}, + priority: "high", + }, + }); + + // Ungültig gewordene Tokens (App gelöscht, Gerät gewechselt) entfernen. + const invalidTokens = response.responses + .map((r, i) => (r.error?.code === "messaging/registration-token-not-registered" ? tokens[i] : null)) + .filter((t): t is string => t !== null); + + const update: Record = {lastReminderSentOn: todayKey}; + if (invalidTokens.length > 0) { + update.fcmTokens = admin.firestore.FieldValue.arrayRemove( + ...invalidTokens + ); + } + await userDoc.ref.update(update); + } + } +); diff --git a/lib/app.dart b/lib/app.dart index 7244ebf..7df1f80 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/firebase/firebase_providers.dart'; import 'core/router/app_router.dart'; import 'core/settings/settings_provider.dart'; import 'core/theme/app_theme.dart'; +import 'features/notifications/data/push_registration_service.dart'; import 'l10n/generated/app_localizations.dart'; class LeafItToMeApp extends ConsumerWidget { @@ -14,6 +16,14 @@ class LeafItToMeApp extends ConsumerWidget { final settings = ref.watch(settingsProvider); final router = ref.watch(appRouterProvider); + // Nach jedem Login das Gerät für Push-Erinnerungen registrieren. + ref.listen(authStateProvider, (previous, next) { + final user = next.value; + if (user != null) { + ref.read(pushRegistrationServiceProvider).registerDevice(user); + } + }); + return MaterialApp.router( routerConfig: router, onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, diff --git a/lib/core/settings/settings_provider.dart b/lib/core/settings/settings_provider.dart index a49c134..ec78163 100644 --- a/lib/core/settings/settings_provider.dart +++ b/lib/core/settings/settings_provider.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../features/notifications/data/push_registration_service.dart'; + /// Wird in main.dart mit der echten Instanz überschrieben. final sharedPreferencesProvider = Provider( (ref) => throw UnimplementedError('sharedPreferencesProvider muss in main.dart überschrieben werden'), @@ -94,6 +96,9 @@ class SettingsNotifier extends Notifier { _keyReminderTime, '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', ); + // Ans Backend synchronisieren – die Erinnerungs-Function liest die Zeit + // aus dem Nutzer-Dokument. + ref.read(pushRegistrationServiceProvider).syncReminderTime(time); } } diff --git a/lib/features/notifications/data/push_registration_service.dart b/lib/features/notifications/data/push_registration_service.dart new file mode 100644 index 0000000..d98435e --- /dev/null +++ b/lib/features/notifications/data/push_registration_service.dart @@ -0,0 +1,73 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; + +import '../../../core/firebase/firebase_providers.dart'; + +/// Registriert das Gerät für Push-Erinnerungen und hält die +/// Erinnerungs-Einstellungen im Nutzer-Dokument aktuell. +/// +/// Abstrakte Basis, damit Tests eine No-Op-Variante einsetzen können. +abstract class PushRegistrationService { + Future registerDevice(User user); + Future syncReminderTime(TimeOfDay time); +} + +class FirebasePushRegistrationService implements PushRegistrationService { + FirebasePushRegistrationService(this._firestore, this._auth); + + final FirebaseFirestore _firestore; + final FirebaseAuth _auth; + bool _tokenListenerAttached = false; + + @override + Future registerDevice(User user) async { + final messaging = FirebaseMessaging.instance; + + // Fragt beim ersten Mal die Push-Berechtigung ab (iOS-Dialog, + // Android 13+ Systemdialog); danach ist der Aufruf ein No-Op. + final settings = await messaging.requestPermission(); + if (settings.authorizationStatus == AuthorizationStatus.denied) return; + + final token = await messaging.getToken(); + if (token != null) { + await _saveToken(user.uid, token); + } + if (!_tokenListenerAttached) { + _tokenListenerAttached = true; + messaging.onTokenRefresh.listen((newToken) { + final current = _auth.currentUser; + if (current != null) _saveToken(current.uid, newToken); + }); + } + } + + Future _saveToken(String uid, String token) async { + final timezone = await FlutterTimezone.getLocalTimezone(); + await _firestore.collection('users').doc(uid).set({ + 'fcmTokens': FieldValue.arrayUnion([token]), + 'timezone': timezone, + }, SetOptions(merge: true)); + } + + @override + Future syncReminderTime(TimeOfDay time) async { + final user = _auth.currentUser; + if (user == null) return; + final formatted = + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + await _firestore.collection('users').doc(user.uid).set({ + 'reminderTime': formatted, + }, SetOptions(merge: true)); + } +} + +final pushRegistrationServiceProvider = Provider((ref) { + return FirebasePushRegistrationService( + ref.watch(firestoreProvider), + ref.watch(firebaseAuthProvider), + ); +}); diff --git a/pubspec.lock b/pubspec.lock index 436c9be..f5bf28c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -329,6 +329,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556" + url: "https://pub.dev" + source: hosted + version: "16.4.3" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63" + url: "https://pub.dev" + source: hosted + version: "4.9.2" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0 + url: "https://pub.dev" + source: hosted + version: "4.2.3" firebase_storage: dependency: "direct main" description: @@ -400,6 +424,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: "869677426fde92dbe170fb7d2d4929f2a8343c2f5f62f08b0bb64f908630b073" + url: "https://pub.dev" + source: hosted + version: "5.1.0" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index ce565f4..f717df1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,8 @@ dependencies: image_picker: ^1.2.3 cloud_functions: ^6.3.5 firebase_storage: ^13.4.5 + firebase_messaging: ^16.4.3 + flutter_timezone: ^5.1.0 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index add7f53..aebf45a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,11 +4,22 @@ import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:leafittome/app.dart'; import 'package:leafittome/core/firebase/firebase_providers.dart'; import 'package:leafittome/core/settings/settings_provider.dart'; +import 'package:leafittome/features/notifications/data/push_registration_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Tests brauchen kein echtes FCM – Push-Registrierung ist ein No-Op. +class _FakePushRegistrationService implements PushRegistrationService { + @override + Future registerDevice(User user) async {} + + @override + Future syncReminderTime(TimeOfDay time) async {} +} + /// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' im Haushalt /// 'h1' mit einer Monstera, deren Gießen seit einem Tag überfällig ist. Future buildTestApp() async { @@ -53,6 +64,8 @@ Future buildTestApp() async { sharedPreferencesProvider.overrideWithValue(prefs), firebaseAuthProvider.overrideWithValue(auth), firestoreProvider.overrideWithValue(firestore), + pushRegistrationServiceProvider + .overrideWithValue(_FakePushRegistrationService()), ], child: const LeafItToMeApp(), );