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.
|
**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).
|
**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.
|
||||||
- **Google-/Apple-Login** — zusätzlich zu E-Mail/Passwort.
|
|
||||||
|
**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
|
## Begriffe kurz erklärt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import {onCall, HttpsError} from "firebase-functions/v2/https";
|
import {onCall, HttpsError} from "firebase-functions/v2/https";
|
||||||
import {defineSecret} from "firebase-functions/params";
|
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),
|
// Secrets liegen im Google Secret Manager (firebase functions:secrets:set),
|
||||||
// niemals im Code oder in der App.
|
// 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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'core/firebase/firebase_providers.dart';
|
||||||
import 'core/router/app_router.dart';
|
import 'core/router/app_router.dart';
|
||||||
import 'core/settings/settings_provider.dart';
|
import 'core/settings/settings_provider.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
|
import 'features/notifications/data/push_registration_service.dart';
|
||||||
import 'l10n/generated/app_localizations.dart';
|
import 'l10n/generated/app_localizations.dart';
|
||||||
|
|
||||||
class LeafItToMeApp extends ConsumerWidget {
|
class LeafItToMeApp extends ConsumerWidget {
|
||||||
|
|
@ -14,6 +16,14 @@ class LeafItToMeApp extends ConsumerWidget {
|
||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final router = ref.watch(appRouterProvider);
|
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(
|
return MaterialApp.router(
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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.
|
/// Wird in main.dart mit der echten Instanz überschrieben.
|
||||||
final sharedPreferencesProvider = Provider<SharedPreferences>(
|
final sharedPreferencesProvider = Provider<SharedPreferences>(
|
||||||
(ref) => throw UnimplementedError('sharedPreferencesProvider muss in main.dart überschrieben werden'),
|
(ref) => throw UnimplementedError('sharedPreferencesProvider muss in main.dart überschrieben werden'),
|
||||||
|
|
@ -94,6 +96,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
_keyReminderTime,
|
_keyReminderTime,
|
||||||
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
|
'${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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.9.1"
|
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:
|
firebase_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -400,6 +424,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ dependencies:
|
||||||
image_picker: ^1.2.3
|
image_picker: ^1.2.3
|
||||||
cloud_functions: ^6.3.5
|
cloud_functions: ^6.3.5
|
||||||
firebase_storage: ^13.4.5
|
firebase_storage: ^13.4.5
|
||||||
|
firebase_messaging: ^16.4.3
|
||||||
|
flutter_timezone: ^5.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,22 @@ import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:leafittome/app.dart';
|
import 'package:leafittome/app.dart';
|
||||||
import 'package:leafittome/core/firebase/firebase_providers.dart';
|
import 'package:leafittome/core/firebase/firebase_providers.dart';
|
||||||
import 'package:leafittome/core/settings/settings_provider.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';
|
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
|
/// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' im Haushalt
|
||||||
/// 'h1' mit einer Monstera, deren Gießen seit einem Tag überfällig ist.
|
/// 'h1' mit einer Monstera, deren Gießen seit einem Tag überfällig ist.
|
||||||
Future<Widget> buildTestApp() async {
|
Future<Widget> buildTestApp() async {
|
||||||
|
|
@ -53,6 +64,8 @@ Future<Widget> buildTestApp() async {
|
||||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||||
firebaseAuthProvider.overrideWithValue(auth),
|
firebaseAuthProvider.overrideWithValue(auth),
|
||||||
firestoreProvider.overrideWithValue(firestore),
|
firestoreProvider.overrideWithValue(firestore),
|
||||||
|
pushRegistrationServiceProvider
|
||||||
|
.overrideWithValue(_FakePushRegistrationService()),
|
||||||
],
|
],
|
||||||
child: const LeafItToMeApp(),
|
child: const LeafItToMeApp(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue