Push-Erinnerungen: FCM-Registrierung in der App, geplante Function sendDailyReminders

- Function läuft alle 15 Min (europe-west3): prüft Erinnerungszeit pro Nutzer (Zeitzonen-korrekt), berechnet fällige Aufgaben, schickt eine Sammel-Push pro Tag, räumt ungültige Tokens auf
- App registriert Gerät nach Login (Berechtigung, Token, Zeitzone), Erinnerungszeit wird ins Nutzer-Dokument synchronisiert
- Doku Schritt 6 inkl. APNs-Anleitung für iOS (sobald Developer Account da ist)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-07-19 13:06:02 +02:00
parent 41e31bed88
commit 52e72c25b1
9 changed files with 367 additions and 3 deletions

View file

@ -110,10 +110,23 @@ Die Keys liegen damit im **Google Secret Manager** — verschlüsselt, nur die C
**Kosten:** PlantNet kostenlos; Claude ca. 13 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:** <https://developer.apple.com>*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

View file

@ -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.

211
functions/src/reminders.ts Normal file
View file

@ -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<DueSummary> {
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<string, unknown> = {lastReminderSentOn: todayKey};
if (invalidTokens.length > 0) {
update.fcmTokens = admin.firestore.FieldValue.arrayRemove(
...invalidTokens
);
}
await userDoc.ref.update(update);
}
}
);

View file

@ -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,

View file

@ -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<SharedPreferences>(
(ref) => throw UnimplementedError('sharedPreferencesProvider muss in main.dart überschrieben werden'),
@ -94,6 +96,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
_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);
}
}

View file

@ -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<void> registerDevice(User user);
Future<void> syncReminderTime(TimeOfDay time);
}
class FirebasePushRegistrationService implements PushRegistrationService {
FirebasePushRegistrationService(this._firestore, this._auth);
final FirebaseFirestore _firestore;
final FirebaseAuth _auth;
bool _tokenListenerAttached = false;
@override
Future<void> 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<void> _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<void> 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<PushRegistrationService>((ref) {
return FirebasePushRegistrationService(
ref.watch(firestoreProvider),
ref.watch(firebaseAuthProvider),
);
});

View file

@ -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

View file

@ -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:

View file

@ -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<void> registerDevice(User user) async {}
@override
Future<void> 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<Widget> buildTestApp() async {
@ -53,6 +64,8 @@ Future<Widget> buildTestApp() async {
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
);