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:
parent
41e31bed88
commit
52e72c25b1
9 changed files with 367 additions and 3 deletions
|
|
@ -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:** <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
211
functions/src/reminders.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
);
|
||||
10
lib/app.dart
10
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
32
pubspec.lock
32
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue