diff --git a/docs/architektur.md b/docs/architektur.md index 048c947..95c9f50 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -46,7 +46,7 @@ Jedes Feature folgt demselben Muster (nicht jedes braucht alle Schichten): - **Aufgaben werden nicht gespeichert, sondern berechnet.** Es gibt keine „Task-Tabelle": Aus `lastWatered + wateringIntervalDays` ergibt sich die Fälligkeit. Eine Bestätigung setzt nur `lastWatered` neu — dadurch kann nichts inkonsistent werden, und Überfälliges „wandert" automatisch mit. - **Haushaltszentriert gedacht:** Pflanzen, Stellplätze und Aufgaben gehören dem *Haushalt*, nicht einem Nutzer. In Firestore wird das `households/{id}/plants/{id}` usw. — so ist V2 (Sharing) nur „weiteres Mitglied hinzufügen", keine Datenmigration. -- **Mehrere Haushalte pro Nutzer:** Quelle der Wahrheit für die Mitgliedschaft ist das Feld `memberUids` der Haushalte (`myHouseholdsProvider` fragt sie per `arrayContains` ab). `users/{uid}.householdId` ist nur noch der Zeiger auf den gerade **aktiven** Haushalt, den die App anzeigt — Wechseln heißt: diesen Zeiger umstellen (Haushalts-Screen, „Meine Haushalte“). Beim Beitritt per Einladungscode bleibt man Mitglied aller bisherigen Haushalte; die tägliche Sammel-Push berücksichtigt Aufgaben aus allen Haushalten. +- **Mehrere Haushalte pro Nutzer:** Quelle der Wahrheit für die Mitgliedschaft ist das Feld `memberUids` der Haushalte (`myHouseholdsProvider` fragt sie per `arrayContains` ab). `users/{uid}.householdId` ist nur noch der Zeiger auf den gerade **aktiven** Haushalt — Wechseln heißt: diesen Zeiger umstellen (Haushalts-Screen „Meine Haushalte“ oder Wechsler im Menü). Der aktive Haushalt bestimmt Pflanzen-/Stellplatz-Listen und wohin neue Pflanzen wandern; die **Tages-Checkliste und die Sammel-Push aggregieren dagegen über alle Haushalte** (Checkliste mit Zwischenüberschriften, `DueTask` trägt dafür seine Haushalts-Herkunft). Jeder Haushalt hat einen **Besitzer** (`ownerUid`): nur er entfernt Mitglieder (`removeMember`-Function), er kann nicht austreten; alle anderen können per `leaveHousehold`-Function austreten. Umbenennen dürfen volle Mitglieder direkt (Rules lassen am Haushalts-Dokument clientseitig nur noch `name` zu). - **`null` bei `lastWatered` heißt „noch nie"** → Aufgabe ist sofort fällig. So braucht eine neu angelegte Pflanze keine Sonderbehandlung. ## Übersetzungen (i18n) diff --git a/docs/firebase-einrichtung.md b/docs/firebase-einrichtung.md index c108c95..6437c70 100644 --- a/docs/firebase-einrichtung.md +++ b/docs/firebase-einrichtung.md @@ -77,7 +77,8 @@ firebase deploy --only firestore --project leaf-it-to-me-app ``` users/{uid} → E-Mail, householdId (= aktiver Haushalt), fcmTokens, reminderTime (nur der Nutzer selbst) -households/{id} → Name, memberUids, members {uid → Rolle+E-Mail} +households/{id} → Name, ownerUid (Besitzer), memberUids, + members {uid → Rolle+E-Mail} households/{id}/plants/{id} → Pflanze: Art, Intervalle, lastWatered(+By), ... households/{id}/locations/{id} → Stellplatz: Name invites/{code} → Einladung: householdId, Rolle; nur Erstellen erlaubt, @@ -86,7 +87,14 @@ invites/{code} → Einladung: householdId, Rolle; nur Erstelle **Rollen (V2):** `member` = volle Rechte, `sitter` = Pflanzen-Sitter (sieht alles, darf aber nur Aufgaben bestätigen — die Rules erlauben Sittern an Pflanzen ausschließlich Änderungen der Bestätigungs-Felder). Der Beitritt per Einladungscode läuft über die Cloud Function `joinHousehold`, weil der Beitretende laut Rules noch kein Mitglied ist. -**Mehrere Haushalte (V2.1):** Ein Nutzer kann Mitglied beliebig vieler Haushalte sein — maßgeblich ist, in welchen `memberUids` er steht. `users/{uid}.householdId` zeigt nur, welcher Haushalt in der App gerade **aktiv** ist; gewechselt wird im Haushalts-Screen unter „Meine Haushalte“ (die App fragt die eigenen Haushalte per `where('memberUids', arrayContains: uid)` ab — das erlauben die bestehenden Rules bereits, es waren keine Regel-Änderungen nötig). Beim Beitritt per Code wechselt die App in den neuen Haushalt, alle bisherigen Mitgliedschaften bleiben bestehen. Die tägliche Sammel-Push (`sendDailyReminders`) sammelt fällige Aufgaben aus **allen** Haushalten des Nutzers, nicht nur aus dem aktiven. +**Mehrere Haushalte (V2.1):** Ein Nutzer kann Mitglied beliebig vieler Haushalte sein — maßgeblich ist, in welchen `memberUids` er steht. `users/{uid}.householdId` zeigt nur, welcher Haushalt in der App gerade **aktiv** ist; gewechselt wird im Haushalts-Screen unter „Meine Haushalte“ oder über den Wechsler oben im Menü (die App fragt die eigenen Haushalte per `where('memberUids', arrayContains: uid)` ab — das erlauben die bestehenden Rules bereits). Beim Beitritt per Code wechselt die App in den neuen Haushalt, alle bisherigen Mitgliedschaften bleiben bestehen. Die tägliche Sammel-Push (`sendDailyReminders`) und die Tages-Checkliste sammeln fällige Aufgaben aus **allen** Haushalten des Nutzers (in der Checkliste mit Zwischenüberschrift je Haushalt). + +**Besitzer, Austreten, Entfernen (V2.2):** Jeder Haushalt hat einen **Besitzer** (`ownerUid` = Ersteller; Alt-Haushalte ohne das Feld: erster Eintrag in `memberUids`, denn Beitretende werden hinten angehängt). Der Besitzer wird in der App ausgewiesen und kann als Einziger Mitglieder entfernen; er kann seinen eigenen Haushalt nicht verlassen. Umbenennen dürfen alle vollen Mitglieder — die Rules erlauben Clients am Haushalts-Dokument seitdem **nur noch das Namensfeld**; jede Mitglieder-Verwaltung läuft über Cloud Functions mit Admin-Rechten: + +- `leaveHousehold` — selbst austreten (auch als Sitter, der laut Rules das Dokument nicht ändern dürfte) +- `removeMember` — ein Mitglied entfernen (nur der Besitzer) + +Beide biegen, falls nötig, den aktiven Zeiger (`users/{uid}.householdId`) des Betroffenen auf einen seiner übrigen Haushalte um; bleibt keiner übrig, legen sie ihm einen frischen eigenen Haushalt an — so steht die App nie ohne Haushalt da. Die Regeln in `firestore.rules` setzen das Haushalts-Prinzip durch: diff --git a/docs/handoff.md b/docs/handoff.md index 7eb5c13..7a370c9 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -19,11 +19,12 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si - **V1 komplett** und auf Android Ende-zu-Ende getestet: Foto → Erkennung → vorbefülltes Profil; Tages-Checkliste; Sammel-Push kam im regulären Scheduler-Lauf auf dem Sperrbildschirm an. iOS-App läuft ebenfalls (Login, Pflanzen, Fotos) — **nur iOS-Push fehlt** (braucht Apple Developer Account, Beantragung scheiterte bisher an Apples Webseite; Schritte stehen in `docs/firebase-einrichtung.md` Schritt 6). - **V2 implementiert, deployt und vom Nutzer erfolgreich getestet** (Einladungscodes member/sitter, `joinHousehold`-Function, Rules-Enforcement für Sitter, „bestätigt von"-Anzeige). -- **V2.1 Multi-Haushalt implementiert**: Nutzer bleiben Mitglied aller Haushalte (`memberUids` ist Quelle der Wahrheit, `users.householdId` nur noch aktiver Zeiger), Wechsel-Liste „Meine Haushalte" im Haushalts-Screen, `sendDailyReminders` sammelt Aufgaben aus allen Haushalten. Rules unverändert (arrayContains-Query war schon erlaubt). +- **V2.1 Multi-Haushalt implementiert und getestet**: Nutzer bleiben Mitglied aller Haushalte (`memberUids` ist Quelle der Wahrheit, `users.householdId` nur noch aktiver Zeiger), `sendDailyReminders` sammelt Aufgaben aus allen Haushalten. +- **V2.2 implementiert, noch nicht vom Nutzer getestet**: Besitzer-Konzept (`ownerUid`, Alt-Haushalte: memberUids[0]), Besitzer-Anzeige, Haushalt umbenennen (Rules: Clients dürfen nur noch `name` ändern), Haushalts-Wechsler im Drawer, Tages-Checkliste gruppiert Aufgaben aus allen Haushalten, Functions `leaveHousehold`/`removeMember` (biegen den aktiven Zeiger des Betroffenen um, legen notfalls frischen Haushalt an). ## Offen / Nächstes -1. V2.1 (Haushalts-Wechsel) vom Nutzer auf den Geräten testen lassen. +1. V2.2 vom Nutzer testen lassen (Umbenennen, Austreten, Entfernen, Drawer-Wechsler, gruppierte Checkliste); Functions- und Rules-Deploy nötig. 2. iOS-Push, sobald Apple Developer Account existiert. 3. **V3:** Krankheits-Diagnose per Foto, Stellplatz-Bewertung (Standort-Analyse pro Stellplatz + Eignungs-Sterne pro Pflanze — Zwei-Ebenen-Konzept siehe Memory), Umtopf-Erinnerungen. 4. **Backlog:** Button „KI-Zweitmeinung" bei unbefriedigendem PlantNet-Ergebnis; Push-Anzeige auch bei App im Vordergrund; App Check vor App-Store-Release; Englisch (`app_en.arb`). diff --git a/firestore.rules b/firestore.rules index 99385cd..2aac382 100644 --- a/firestore.rules +++ b/firestore.rules @@ -48,13 +48,16 @@ service cloud.firestore { match /households/{householdId} { allow read: if signedIn() && request.auth.uid in resource.data.memberUids; - // Ändern (z. B. Name) nur für volle Mitglieder; Mitglieder-Verwaltung - // läuft über die Cloud Function. + // Vom Client aus dürfen volle Mitglieder nur den Namen ändern; + // jede Mitglieder-Verwaltung (Beitritt, Austritt, Entfernen) läuft + // ausschließlich über Cloud Functions mit Admin-Rechten. allow update: if signedIn() && request.auth.uid in resource.data.memberUids && resource.data.get('members', {}) .get(request.auth.uid, {'role': 'member'}) - .get('role', 'member') == 'member'; + .get('role', 'member') == 'member' + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['name']); // Anlegen nur, wenn man sich selbst als Mitglied einträgt // (passiert automatisch bei der Registrierung). allow create: if signedIn() diff --git a/functions/src/household.ts b/functions/src/household.ts index fa2019a..4f7462b 100644 --- a/functions/src/household.ts +++ b/functions/src/household.ts @@ -4,6 +4,197 @@ import * as admin from "firebase-admin"; /** Gültigkeit eines Einladungscodes. */ const INVITE_TTL_DAYS = 7; +interface HouseholdDoc { + name?: string; + memberUids?: string[]; + ownerUid?: string; + members?: Record; +} + +/** + * Besitzer eines Haushalts. Haushalte aus der Zeit vor V2.2 haben noch kein + * ownerUid-Feld – dort gilt der Ersteller als Besitzer, und der steht durch + * die Anlage-Reihenfolge (arrayUnion hängt Beitretende hinten an) immer an + * erster Stelle von memberUids. + */ +function ownerOf(household: HouseholdDoc): string | undefined { + return household.ownerUid ?? household.memberUids?.[0]; +} + +/** + * Entfernt einen Nutzer aus einem Haushalt (Transaktions-Baustein für + * leaveHousehold und removeMember). + * + * Zeigt dessen users/{uid}.householdId gerade auf diesen Haushalt, wird der + * Zeiger auf einen anderen Haushalt des Nutzers umgebogen; gibt es keinen, + * bekommt er einen frischen eigenen Haushalt (wie bei der Registrierung). + */ +async function removeUidFromHousehold( + db: admin.firestore.Firestore, + tx: admin.firestore.Transaction, + householdRef: admin.firestore.DocumentReference, + household: HouseholdDoc, + uid: string, + email: string +): Promise { + // Alle Lesezugriffe müssen in der Transaktion vor den Schreibzugriffen + // liegen. + const userRef = db.collection("users").doc(uid); + const userSnap = await tx.get(userRef); + const otherHouseholds = await tx.get( + db.collection("households").where("memberUids", "array-contains", uid) + ); + const fallbackId = otherHouseholds.docs.find( + (doc) => doc.id !== householdRef.id + )?.id; + + tx.update(householdRef, { + memberUids: admin.firestore.FieldValue.arrayRemove(uid), + [`members.${uid}`]: admin.firestore.FieldValue.delete(), + // Besitzer bei der Gelegenheit persistieren (Alt-Haushalte ohne Feld). + ownerUid: ownerOf(household), + }); + + if (userSnap.data()?.householdId !== householdRef.id) return; + if (fallbackId) { + tx.set(userRef, {householdId: fallbackId}, {merge: true}); + return; + } + // Kein weiterer Haushalt übrig → frischen eigenen Haushalt anlegen, + // damit die App nie ohne Haushalt dasteht. + const newHouseholdRef = db.collection("households").doc(); + tx.set(newHouseholdRef, { + name: "Mein Haushalt", + ownerUid: uid, + memberUids: [uid], + members: { + [uid]: { + role: "member", + email, + joinedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + }, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + tx.set(userRef, {householdId: newHouseholdRef.id}, {merge: true}); +} + +/** + * Einen Haushalt verlassen, dem man per Einladung beigetreten ist. + * Der Besitzer kann seinen eigenen Haushalt nicht verlassen. + */ +export const leaveHousehold = onCall( + {region: "europe-west3"}, + async (request) => { + if (!request.auth) { + throw new HttpsError("unauthenticated", "Anmeldung erforderlich."); + } + const uid = request.auth.uid; + const email = (request.auth.token.email as string | undefined) ?? ""; + const householdId = request.data?.householdId; + if (typeof householdId !== "string" || householdId.length === 0) { + throw new HttpsError("invalid-argument", "householdId fehlt."); + } + + const db = admin.firestore(); + return db.runTransaction(async (tx) => { + const householdRef = db.collection("households").doc(householdId); + const householdSnap = await tx.get(householdRef); + if (!householdSnap.exists) { + throw new HttpsError("not-found", "Der Haushalt existiert nicht mehr."); + } + const household = householdSnap.data() as HouseholdDoc; + if (!(household.memberUids ?? []).includes(uid)) { + throw new HttpsError( + "failed-precondition", + "Du bist kein Mitglied dieses Haushalts." + ); + } + if (ownerOf(household) === uid) { + throw new HttpsError( + "failed-precondition", + "Der Besitzer kann seinen eigenen Haushalt nicht verlassen." + ); + } + + await removeUidFromHousehold( + db, + tx, + householdRef, + household, + uid, + email + ); + return {householdName: household.name ?? "Haushalt"}; + }); + } +); + +/** + * Ein Mitglied aus dem eigenen Haushalt entfernen (nur der Besitzer). + */ +export const removeMember = onCall( + {region: "europe-west3"}, + async (request) => { + if (!request.auth) { + throw new HttpsError("unauthenticated", "Anmeldung erforderlich."); + } + const callerUid = request.auth.uid; + const householdId = request.data?.householdId; + const targetUid = request.data?.memberUid; + if ( + typeof householdId !== "string" || + householdId.length === 0 || + typeof targetUid !== "string" || + targetUid.length === 0 + ) { + throw new HttpsError( + "invalid-argument", + "householdId oder memberUid fehlt." + ); + } + if (targetUid === callerUid) { + throw new HttpsError( + "failed-precondition", + "Sich selbst entfernt man über „Haushalt verlassen“." + ); + } + + const db = admin.firestore(); + return db.runTransaction(async (tx) => { + const householdRef = db.collection("households").doc(householdId); + const householdSnap = await tx.get(householdRef); + if (!householdSnap.exists) { + throw new HttpsError("not-found", "Der Haushalt existiert nicht mehr."); + } + const household = householdSnap.data() as HouseholdDoc; + if (ownerOf(household) !== callerUid) { + throw new HttpsError( + "permission-denied", + "Nur der Besitzer kann Mitglieder entfernen." + ); + } + if (!(household.memberUids ?? []).includes(targetUid)) { + throw new HttpsError( + "failed-precondition", + "Diese Person ist kein Mitglied mehr." + ); + } + + const targetEmail = household.members?.[targetUid]?.email ?? ""; + await removeUidFromHousehold( + db, + tx, + householdRef, + household, + targetUid, + targetEmail + ); + return {removedEmail: targetEmail}; + }); + } +); + /** * Einladungscode einlösen und dem Haushalt beitreten. * diff --git a/functions/src/index.ts b/functions/src/index.ts index c819da3..22cec86 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -5,7 +5,7 @@ import * as admin from "firebase-admin"; admin.initializeApp(); export {sendDailyReminders} from "./reminders"; -export {joinHousehold} from "./household"; +export {joinHousehold, leaveHousehold, removeMember} from "./household"; // Secrets liegen im Google Secret Manager (firebase functions:secrets:set), // niemals im Code oder in der App. diff --git a/lib/core/widgets/app_drawer.dart b/lib/core/widgets/app_drawer.dart index ca1537d..9c15407 100644 --- a/lib/core/widgets/app_drawer.dart +++ b/lib/core/widgets/app_drawer.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../features/household/data/household_providers.dart'; +import '../../features/household/domain/household.dart'; import '../../features/today/application/due_tasks_provider.dart'; import '../../l10n/generated/app_localizations.dart'; import '../router/app_router.dart'; @@ -16,6 +18,8 @@ class AppDrawer extends ConsumerWidget { final l10n = AppLocalizations.of(context); final currentPath = GoRouterState.of(context).uri.path; final openTaskCount = ref.watch(dueTasksProvider).length; + final myHouseholds = ref.watch(myHouseholdsProvider).value ?? const []; + final activeHousehold = ref.watch(householdProvider).value; void goTo(String path) { Navigator.pop(context); @@ -48,6 +52,45 @@ class AppDrawer extends ConsumerWidget { ], ), ), + // Schneller Haushalts-Wechsel, sobald man in mehreren ist. + if (myHouseholds.length > 1) + PopupMenuButton( + tooltip: l10n.myHouseholds, + onSelected: (target) => ref + .read(householdRepositoryProvider) + .switchHousehold(target.id), + itemBuilder: (context) => [ + for (final entry in myHouseholds) + PopupMenuItem( + value: entry, + enabled: entry.id != activeHousehold?.id, + child: Row( + children: [ + Icon( + entry.id == activeHousehold?.id + ? Icons.home + : Icons.home_outlined, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text(entry.name, + overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ], + child: ListTile( + leading: const Icon(Icons.home), + title: Text( + activeHousehold?.name ?? l10n.menuHousehold, + style: const TextStyle(fontSize: 17), + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.unfold_more), + ), + ), const Divider(), _DrawerItem( icon: Icons.checklist, diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 8367330..437bae3 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -30,6 +30,7 @@ class AuthRepository { final batch = _firestore.batch(); batch.set(householdRef, { 'name': 'Mein Haushalt', + 'ownerUid': uid, 'memberUids': [uid], 'members': { uid: { diff --git a/lib/features/household/data/household_providers.dart b/lib/features/household/data/household_providers.dart index 7d06026..486ad0c 100644 --- a/lib/features/household/data/household_providers.dart +++ b/lib/features/household/data/household_providers.dart @@ -31,6 +31,10 @@ Household _householdFromData(String id, Map data) { return Household( id: id, name: data['name'] as String? ?? 'Haushalt', + // Alt-Haushalte ohne ownerUid: Der Ersteller steht immer an erster + // Stelle von memberUids (Beitretende werden hinten angehängt). + ownerUid: data['ownerUid'] as String? ?? + (memberUids.isNotEmpty ? memberUids.first as String : ''), members: [ for (final uid in memberUids.cast()) HouseholdMember( @@ -145,6 +149,35 @@ class HouseholdRepository { await callable.call>({'code': code.trim()}); return response.data['householdName'] as String? ?? 'Haushalt'; } + + /// Benennt einen Haushalt um (Rules: nur volle Mitglieder, nur das + /// Namensfeld). + Future renameHousehold(String householdId, String name) { + return _firestore + .collection('households') + .doc(householdId) + .update({'name': name}); + } + + /// Tritt aus einem Haushalt aus, dem man per Einladung beigetreten ist. + /// Läuft als Cloud Function, weil auch Sitter austreten dürfen, die laut + /// Rules das Haushalts-Dokument nicht ändern können. + Future leaveHousehold(String householdId) async { + final callable = _functionsGetter().httpsCallable('leaveHousehold'); + await callable.call>({'householdId': householdId}); + } + + /// Entfernt ein Mitglied aus dem Haushalt (nur der Besitzer). + Future removeMember({ + required String householdId, + required String memberUid, + }) async { + final callable = _functionsGetter().httpsCallable('removeMember'); + await callable.call>({ + 'householdId': householdId, + 'memberUid': memberUid, + }); + } } final householdRepositoryProvider = Provider((ref) { diff --git a/lib/features/household/domain/household.dart b/lib/features/household/domain/household.dart index 35a6282..46d0f14 100644 --- a/lib/features/household/domain/household.dart +++ b/lib/features/household/domain/household.dart @@ -26,17 +26,32 @@ class Household { const Household({ required this.id, required this.name, + required this.ownerUid, required this.members, }); final String id; final String name; + + /// Besitzer = Ersteller des Haushalts. Nur er kann Mitglieder entfernen, + /// und er kann seinen Haushalt nicht verlassen. + final String ownerUid; + final List members; + bool isOwner(String uid) => uid == ownerUid; + HouseholdRole roleOf(String uid) { for (final member in members) { if (member.uid == uid) return member.role; } return HouseholdRole.member; } + + HouseholdMember? memberByUid(String uid) { + for (final member in members) { + if (member.uid == uid) return member; + } + return null; + } } diff --git a/lib/features/household/presentation/household_screen.dart b/lib/features/household/presentation/household_screen.dart index 50a6532..8081fcf 100644 --- a/lib/features/household/presentation/household_screen.dart +++ b/lib/features/household/presentation/household_screen.dart @@ -89,6 +89,95 @@ class _HouseholdScreenState extends ConsumerState { ); } + Future _rename(Household household) async { + final newName = await showDialog( + context: context, + builder: (_) => _RenameDialog(initialName: household.name), + ); + if (newName == null || newName.isEmpty || newName == household.name) { + return; + } + await ref + .read(householdRepositoryProvider) + .renameHousehold(household.id, newName); + } + + Future _leave(Household household) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.leaveHouseholdConfirmTitle), + content: Text(l10n.leaveHouseholdConfirmBody(household.name)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.leaveHousehold), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + final messenger = ScaffoldMessenger.of(context); + try { + await ref + .read(householdRepositoryProvider) + .leaveHousehold(household.id); + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.leftHousehold(household.name)))); + } on FirebaseFunctionsException { + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.authErrorGeneric))); + } + } + + Future _removeMember( + Household household, HouseholdMember member) async { + final l10n = AppLocalizations.of(context); + final label = member.email.isEmpty ? l10n.roleMember : member.email; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.removeMemberConfirmTitle), + content: Text(l10n.removeMemberConfirmBody(label)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.removeMemberAction), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + final messenger = ScaffoldMessenger.of(context); + try { + await ref.read(householdRepositoryProvider).removeMember( + householdId: household.id, + memberUid: member.uid, + ); + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.memberRemoved(label)))); + } on FirebaseFunctionsException { + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.authErrorGeneric))); + } + } + Future _join() async { final l10n = AppLocalizations.of(context); final code = _codeController.text.trim(); @@ -151,8 +240,21 @@ class _HouseholdScreenState extends ConsumerState { final myRole = ref.watch(myRoleProvider); final myUid = ref.watch(authStateProvider).value?.uid; + final iAmOwner = + household != null && myUid != null && household.isOwner(myUid); + return Scaffold( - appBar: AppBar(title: Text(household?.name ?? l10n.householdTitle)), + appBar: AppBar( + title: Text(household?.name ?? l10n.householdTitle), + actions: [ + if (household != null && myRole == HouseholdRole.member) + IconButton( + icon: const Icon(Icons.edit), + tooltip: l10n.renameHousehold, + onPressed: () => _rename(household), + ), + ], + ), drawer: const AppDrawer(), body: ListView( padding: const EdgeInsets.all(16), @@ -173,9 +275,10 @@ class _HouseholdScreenState extends ConsumerState { ), title: Text(entry.name), subtitle: Text( - myUid != null && entry.roleOf(myUid) == HouseholdRole.sitter - ? l10n.roleSitter - : l10n.roleMember, + myUid != null && entry.isOwner(myUid) + ? l10n.ownerIsMe + : l10n.ownerIs( + entry.memberByUid(entry.ownerUid)?.email ?? '?'), ), trailing: entry.id == household?.id ? Chip(label: Text(l10n.activeHouseholdLabel)) @@ -202,13 +305,35 @@ class _HouseholdScreenState extends ConsumerState { ? l10n.meLabel : member.email, ), - subtitle: Text(member.role == HouseholdRole.sitter - ? l10n.roleSitter - : l10n.roleMember), + subtitle: Text([ + member.role == HouseholdRole.sitter + ? l10n.roleSitter + : l10n.roleMember, + if (household.isOwner(member.uid)) l10n.ownerLabel, + ].join(' · ')), trailing: member.uid == myUid ? Chip(label: Text(l10n.meLabel)) - : null, + : iAmOwner + ? IconButton( + icon: Icon(Icons.person_remove, + color: theme.colorScheme.error), + tooltip: l10n.removeMemberAction, + onPressed: () => + _removeMember(household, member), + ) + : null, ), + if (household != null && myUid != null && !iAmOwner) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => _leave(household), + icon: Icon(Icons.logout, color: theme.colorScheme.error), + label: Text( + l10n.leaveHousehold, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], if (myRole == HouseholdRole.member) ...[ const Divider(height: 32), Text(l10n.inviteTitle, style: theme.textTheme.titleMedium), @@ -261,3 +386,53 @@ class _HouseholdScreenState extends ConsumerState { ); } } + +/// Eigener Dialog fürs Umbenennen: Der TextEditingController gehört dem +/// Dialog-State und lebt damit bis zum Ende der Zuklapp-Animation +/// (sofortiges dispose nach showDialog crasht während der Animation). +class _RenameDialog extends StatefulWidget { + const _RenameDialog({required this.initialName}); + + final String initialName; + + @override + State<_RenameDialog> createState() => _RenameDialogState(); +} + +class _RenameDialogState extends State<_RenameDialog> { + late final TextEditingController _controller = + TextEditingController(text: widget.initialName); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return AlertDialog( + title: Text(l10n.renameHousehold), + content: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: l10n.householdNameLabel, + border: const OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.sentences, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, _controller.text.trim()), + child: Text(l10n.saveButton), + ), + ], + ); + } +} diff --git a/lib/features/plants/data/plants_provider.dart b/lib/features/plants/data/plants_provider.dart index fc0a8fa..2e87110 100644 --- a/lib/features/plants/data/plants_provider.dart +++ b/lib/features/plants/data/plants_provider.dart @@ -48,12 +48,10 @@ Map _plantToMap(Plant plant) { }; } -/// Alle Pflanzen des Haushalts, live aus Firestore. -/// Leere Liste, solange kein Haushalt geladen ist (z. B. direkt nach Login). -final plantsProvider = StreamProvider>((ref) { - final householdId = ref.watch(householdIdProvider).value; - if (householdId == null) return Stream.value(const []); - +/// Alle Pflanzen eines bestimmten Haushalts, live aus Firestore. +/// Die Tages-Checkliste nutzt das für jeden Haushalt des Nutzers. +final householdPlantsProvider = + StreamProvider.family, String>((ref, householdId) { return ref .watch(firestoreProvider) .collection('households') @@ -66,6 +64,14 @@ final plantsProvider = StreamProvider>((ref) { ]); }); +/// Die Pflanzen des gerade aktiven Haushalts. +/// Leere Liste, solange kein Haushalt geladen ist (z. B. direkt nach Login). +final plantsProvider = Provider>>((ref) { + final householdId = ref.watch(householdIdProvider).value; + if (householdId == null) return const AsyncValue.data([]); + return ref.watch(householdPlantsProvider(householdId)); +}); + final plantByIdProvider = Provider.family((ref, id) { final plants = ref.watch(plantsProvider).value ?? const []; for (final plant in plants) { @@ -103,12 +109,20 @@ class PlantRepository { /// Bestätigt eine Aufgabe: setzt das Erledigungsdatum, wodurch sich die /// nächste Fälligkeit automatisch neu berechnet (Intervall-Modell). - Future confirmTask(String plantId, CareTaskType type) { + /// [householdId] kommt aus der Aufgabe – die Checkliste zeigt Aufgaben + /// aus allen Haushalten, nicht nur dem aktiven. + Future confirmTask( + String householdId, String plantId, CareTaskType type) { final field = switch (type) { CareTaskType.watering => 'lastWatered', CareTaskType.fertilizing => 'lastFertilized', }; - return _plants.doc(plantId).update({ + return _firestore + .collection('households') + .doc(householdId) + .collection('plants') + .doc(plantId) + .update({ field: Timestamp.now(), '${field}By': _userLabelGetter(), }); diff --git a/lib/features/today/application/due_tasks_provider.dart b/lib/features/today/application/due_tasks_provider.dart index 2b5a053..fedb839 100644 --- a/lib/features/today/application/due_tasks_provider.dart +++ b/lib/features/today/application/due_tasks_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../household/data/household_providers.dart'; +import '../../household/domain/household.dart'; import '../../plants/data/plants_provider.dart'; import '../../plants/domain/plant.dart'; import '../domain/due_task.dart'; @@ -12,49 +14,93 @@ DateTime? _nextDue(DateTime? lastDone, int intervalDays, DateTime today) { return _dateOnly(lastDone).add(Duration(days: intervalDays)); } -Iterable _tasksForPlant(Plant plant, DateTime today) sync* { +Iterable _tasksForPlant( + Plant plant, Household household, DateTime today) sync* { final watering = _nextDue(plant.lastWatered, plant.wateringIntervalDays, today); if (watering != null && !watering.isAfter(today)) { - yield DueTask(plant: plant, type: CareTaskType.watering, dueDate: watering); + yield DueTask( + plant: plant, + type: CareTaskType.watering, + dueDate: watering, + householdId: household.id, + householdName: household.name, + ); } final fertilizing = _nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today); if (fertilizing != null && !fertilizing.isAfter(today)) { yield DueTask( - plant: plant, type: CareTaskType.fertilizing, dueDate: fertilizing); + plant: plant, + type: CareTaskType.fertilizing, + dueDate: fertilizing, + householdId: household.id, + householdName: household.name, + ); } } -/// Alle heute fälligen und überfälligen Aufgaben, Überfälliges zuerst. +/// Alle heute fälligen und überfälligen Aufgaben aus **allen** Haushalten +/// des Nutzers. Sortiert nach Haushalt (aktiver zuerst), darin Überfälliges +/// zuerst – so gruppiert die Checkliste ohne weitere Logik. final dueTasksProvider = Provider>((ref) { - final plants = ref.watch(plantsProvider).value ?? const []; + final households = ref.watch(myHouseholdsProvider).value ?? const []; + final activeId = ref.watch(householdIdProvider).value; final today = _dateOnly(DateTime.now()); - final tasks = [ - for (final plant in plants) ..._tasksForPlant(plant, today), - ]; - tasks.sort((a, b) { - final byDate = a.dueDate.compareTo(b.dueDate); - if (byDate != 0) return byDate; - return a.plant.nickname.compareTo(b.plant.nickname); - }); + final ordered = [...households]..sort((a, b) { + if (a.id == activeId) return -1; + if (b.id == activeId) return 1; + return 0; // myHouseholdsProvider liefert bereits nach Name sortiert. + }); + + final tasks = []; + for (final household in ordered) { + final plants = + ref.watch(householdPlantsProvider(household.id)).value ?? const []; + final householdTasks = [ + for (final plant in plants) ..._tasksForPlant(plant, household, today), + ]..sort((a, b) { + final byDate = a.dueDate.compareTo(b.dueDate); + if (byDate != 0) return byDate; + return a.plant.nickname.compareTo(b.plant.nickname); + }); + tasks.addAll(householdTasks); + } return tasks; }); -/// Das nächste zukünftige Fälligkeitsdatum – für den "Alles versorgt"-Screen -/// ("Nächste Aufgabe: Freitag"). +/// True, solange die Haushalte oder deren Pflanzen noch erstmalig laden – +/// damit die Checkliste nicht kurz „Alles versorgt“ zeigt, bevor Daten da sind. +final dueTasksLoadingProvider = Provider((ref) { + final householdsAsync = ref.watch(myHouseholdsProvider); + if (householdsAsync.isLoading && !householdsAsync.hasValue) return true; + final households = householdsAsync.value ?? const []; + for (final household in households) { + if (!ref.watch(householdPlantsProvider(household.id)).hasValue) { + return true; + } + } + return false; +}); + +/// Das nächste zukünftige Fälligkeitsdatum über alle Haushalte – für den +/// "Alles versorgt"-Screen ("Nächste Aufgabe: Freitag"). final nextDueDateProvider = Provider((ref) { - final plants = ref.watch(plantsProvider).value ?? const []; + final households = ref.watch(myHouseholdsProvider).value ?? const []; final today = _dateOnly(DateTime.now()); DateTime? next; - for (final plant in plants) { - for (final candidate in [ - _nextDue(plant.lastWatered, plant.wateringIntervalDays, today), - _nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today), - ]) { - if (candidate == null || !candidate.isAfter(today)) continue; - if (next == null || candidate.isBefore(next)) next = candidate; + for (final household in households) { + final plants = + ref.watch(householdPlantsProvider(household.id)).value ?? const []; + for (final plant in plants) { + for (final candidate in [ + _nextDue(plant.lastWatered, plant.wateringIntervalDays, today), + _nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today), + ]) { + if (candidate == null || !candidate.isAfter(today)) continue; + if (next == null || candidate.isBefore(next)) next = candidate; + } } } return next; diff --git a/lib/features/today/domain/due_task.dart b/lib/features/today/domain/due_task.dart index 3fe6704..e95dfc1 100644 --- a/lib/features/today/domain/due_task.dart +++ b/lib/features/today/domain/due_task.dart @@ -14,12 +14,19 @@ class DueTask { required this.plant, required this.type, required this.dueDate, + required this.householdId, + required this.householdName, }); final Plant plant; final CareTaskType type; final DateTime dueDate; + /// Aus welchem Haushalt die Aufgabe stammt – die Checkliste sammelt + /// Aufgaben aus allen Haushalten des Nutzers. + final String householdId; + final String householdName; + /// 0 = heute fällig, 1 = seit gestern überfällig, usw. int overdueDays(DateTime today) => today.difference(dueDate).inDays; } diff --git a/lib/features/today/presentation/today_screen.dart b/lib/features/today/presentation/today_screen.dart index 3abe114..a13ab04 100644 --- a/lib/features/today/presentation/today_screen.dart +++ b/lib/features/today/presentation/today_screen.dart @@ -20,11 +20,10 @@ class TodayScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); - final plantsAsync = ref.watch(plantsProvider); final tasks = ref.watch(dueTasksProvider); final Widget body; - if (plantsAsync.isLoading && !plantsAsync.hasValue) { + if (ref.watch(dueTasksLoadingProvider)) { body = const Center(child: CircularProgressIndicator()); } else if (tasks.isEmpty) { body = const _AllDoneView(); @@ -57,19 +56,51 @@ class _TaskListView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + + // Zwischenüberschriften nur, wenn Aufgaben aus mehreren Haushalten da + // sind – im Normalfall (ein Haushalt) bleibt die Liste wie gewohnt. + final householdIds = {for (final task in tasks) task.householdId}; + final showHouseholdHeaders = householdIds.length > 1; + + final children = [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + l10n.todayOpenTasks(tasks.length), + style: theme.textTheme.titleMedium, + ), + ), + ]; + String? currentHouseholdId; + for (final task in tasks) { + if (showHouseholdHeaders && task.householdId != currentHouseholdId) { + currentHouseholdId = task.householdId; + children.add(Padding( + padding: const EdgeInsets.only(top: 12, bottom: 4), + child: Row( + children: [ + Icon(Icons.home_outlined, + size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + task.householdName, + style: theme.textTheme.titleSmall + ?.copyWith(color: theme.colorScheme.primary), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + )); + } + children.add(_TaskCard(task: task)); + } return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - l10n.todayOpenTasks(tasks.length), - style: Theme.of(context).textTheme.titleMedium, - ), - ), - for (final task in tasks) _TaskCard(task: task), - ], + children: children, ); } } @@ -126,9 +157,8 @@ class _TaskCard extends ConsumerWidget { const SizedBox(width: 12), FilledButton.icon( onPressed: () { - ref - .read(plantRepositoryProvider) - .confirmTask(task.plant.id, task.type); + ref.read(plantRepositoryProvider).confirmTask( + task.householdId, task.plant.id, task.type); ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e7cc71f..4682d65 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -116,6 +116,45 @@ "householdMembers": "Mitglieder", "roleMember": "Mitglied", "roleSitter": "Pflanzen-Sitter", + "ownerLabel": "Besitzer", + "ownerIsMe": "Dein Haushalt", + "ownerIs": "Besitzer: {name}", + "@ownerIs": { + "placeholders": { + "name": { "type": "String" } + } + }, + "renameHousehold": "Haushalt umbenennen", + "householdNameLabel": "Name des Haushalts", + "saveButton": "Speichern", + "leaveHousehold": "Haushalt verlassen", + "leaveHouseholdConfirmTitle": "Haushalt verlassen?", + "leaveHouseholdConfirmBody": "Du verlierst den Zugriff auf „{name}“ und alle Pflanzen darin. Ein neuer Beitritt ist nur mit einem neuen Einladungscode möglich.", + "@leaveHouseholdConfirmBody": { + "placeholders": { + "name": { "type": "String" } + } + }, + "leftHousehold": "Du hast den Haushalt „{name}“ verlassen.", + "@leftHousehold": { + "placeholders": { + "name": { "type": "String" } + } + }, + "removeMemberConfirmTitle": "Mitglied entfernen?", + "removeMemberConfirmBody": "{name} verliert sofort den Zugriff auf diesen Haushalt und alle Pflanzen darin.", + "@removeMemberConfirmBody": { + "placeholders": { + "name": { "type": "String" } + } + }, + "removeMemberAction": "Entfernen", + "memberRemoved": "{name} wurde aus dem Haushalt entfernt.", + "@memberRemoved": { + "placeholders": { + "name": { "type": "String" } + } + }, "meLabel": "Ich", "inviteTitle": "Einladen", "inviteMember": "Mitglied einladen", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ba72d23..11283a5 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -400,6 +400,90 @@ abstract class AppLocalizations { /// **'Pflanzen-Sitter'** String get roleSitter; + /// No description provided for @ownerLabel. + /// + /// In de, this message translates to: + /// **'Besitzer'** + String get ownerLabel; + + /// No description provided for @ownerIsMe. + /// + /// In de, this message translates to: + /// **'Dein Haushalt'** + String get ownerIsMe; + + /// No description provided for @ownerIs. + /// + /// In de, this message translates to: + /// **'Besitzer: {name}'** + String ownerIs(String name); + + /// No description provided for @renameHousehold. + /// + /// In de, this message translates to: + /// **'Haushalt umbenennen'** + String get renameHousehold; + + /// No description provided for @householdNameLabel. + /// + /// In de, this message translates to: + /// **'Name des Haushalts'** + String get householdNameLabel; + + /// No description provided for @saveButton. + /// + /// In de, this message translates to: + /// **'Speichern'** + String get saveButton; + + /// No description provided for @leaveHousehold. + /// + /// In de, this message translates to: + /// **'Haushalt verlassen'** + String get leaveHousehold; + + /// No description provided for @leaveHouseholdConfirmTitle. + /// + /// In de, this message translates to: + /// **'Haushalt verlassen?'** + String get leaveHouseholdConfirmTitle; + + /// No description provided for @leaveHouseholdConfirmBody. + /// + /// In de, this message translates to: + /// **'Du verlierst den Zugriff auf „{name}“ und alle Pflanzen darin. Ein neuer Beitritt ist nur mit einem neuen Einladungscode möglich.'** + String leaveHouseholdConfirmBody(String name); + + /// No description provided for @leftHousehold. + /// + /// In de, this message translates to: + /// **'Du hast den Haushalt „{name}“ verlassen.'** + String leftHousehold(String name); + + /// No description provided for @removeMemberConfirmTitle. + /// + /// In de, this message translates to: + /// **'Mitglied entfernen?'** + String get removeMemberConfirmTitle; + + /// No description provided for @removeMemberConfirmBody. + /// + /// In de, this message translates to: + /// **'{name} verliert sofort den Zugriff auf diesen Haushalt und alle Pflanzen darin.'** + String removeMemberConfirmBody(String name); + + /// No description provided for @removeMemberAction. + /// + /// In de, this message translates to: + /// **'Entfernen'** + String get removeMemberAction; + + /// No description provided for @memberRemoved. + /// + /// In de, this message translates to: + /// **'{name} wurde aus dem Haushalt entfernt.'** + String memberRemoved(String name); + /// No description provided for @meLabel. /// /// In de, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 1e6d355..a926db7 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -207,6 +207,58 @@ class AppLocalizationsDe extends AppLocalizations { @override String get roleSitter => 'Pflanzen-Sitter'; + @override + String get ownerLabel => 'Besitzer'; + + @override + String get ownerIsMe => 'Dein Haushalt'; + + @override + String ownerIs(String name) { + return 'Besitzer: $name'; + } + + @override + String get renameHousehold => 'Haushalt umbenennen'; + + @override + String get householdNameLabel => 'Name des Haushalts'; + + @override + String get saveButton => 'Speichern'; + + @override + String get leaveHousehold => 'Haushalt verlassen'; + + @override + String get leaveHouseholdConfirmTitle => 'Haushalt verlassen?'; + + @override + String leaveHouseholdConfirmBody(String name) { + return 'Du verlierst den Zugriff auf „$name“ und alle Pflanzen darin. Ein neuer Beitritt ist nur mit einem neuen Einladungscode möglich.'; + } + + @override + String leftHousehold(String name) { + return 'Du hast den Haushalt „$name“ verlassen.'; + } + + @override + String get removeMemberConfirmTitle => 'Mitglied entfernen?'; + + @override + String removeMemberConfirmBody(String name) { + return '$name verliert sofort den Zugriff auf diesen Haushalt und alle Pflanzen darin.'; + } + + @override + String get removeMemberAction => 'Entfernen'; + + @override + String memberRemoved(String name) { + return '$name wurde aus dem Haushalt entfernt.'; + } + @override String get meLabel => 'Ich'; diff --git a/test/widget_test.dart b/test/widget_test.dart index 6e71b62..f58e587 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -20,12 +20,15 @@ class _FakePushRegistrationService implements PushRegistrationService { 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. -/// Mit [secondHousehold] ist u1 zusätzlich Mitglied im Haushalt 'h2'. +/// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' als Besitzer +/// des Haushalts 'h1' mit einer Monstera, deren Gießen überfällig ist. +/// Mit [secondHousehold] ist u1 zusätzlich Sitter im fremden Haushalt 'h2' +/// (Besitzer 'u2'), in dem ein Ficus fällig ist. +/// Mit [extraMember] hat 'h1' zusätzlich den Sitter 'u3'. Future buildTestApp({ String role = 'member', bool secondHousehold = false, + bool extraMember = false, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -42,20 +45,39 @@ Future buildTestApp({ }); await firestore.collection('households').doc('h1').set({ 'name': 'Mein Haushalt', - 'memberUids': ['u1'], + 'ownerUid': 'u1', + 'memberUids': ['u1', if (extraMember) 'u3'], 'members': { 'u1': {'role': role, 'email': 'test@example.com'}, + if (extraMember) 'u3': {'role': 'sitter', 'email': 'sitter@example.com'}, }, }); if (secondHousehold) { await firestore.collection('households').doc('h2').set({ 'name': 'Zweiter Haushalt', - 'memberUids': ['u1', 'u2'], + 'ownerUid': 'u2', + 'memberUids': ['u2', 'u1'], 'members': { 'u1': {'role': 'sitter', 'email': 'test@example.com'}, 'u2': {'role': 'member', 'email': 'other@example.com'}, }, }); + await firestore + .collection('households') + .doc('h2') + .collection('plants') + .add({ + 'nickname': 'Ficus', + 'species': 'Ficus benjamina', + 'locationId': null, + 'description': '', + 'careNotes': '', + 'wateringIntervalDays': 7, + 'fertilizingIntervalDays': 28, + 'lastWatered': null, + 'lastFertilized': Timestamp.fromDate( + DateTime.now().subtract(const Duration(days: 3))), + }); } final now = DateTime.now(); await firestore @@ -133,10 +155,12 @@ void main() { await tester.tap(find.text('Haushalt')); await tester.pumpAndSettle(); - // Beide Haushalte sind gelistet, h1 ist aktiv. + // Beide Haushalte sind gelistet, h1 ist aktiv, Besitzer sind ausgewiesen. expect(find.text('Meine Haushalte'), findsOneWidget); expect(find.text('Aktiv'), findsOneWidget); expect(find.text('Zweiter Haushalt'), findsOneWidget); + expect(find.text('Dein Haushalt'), findsOneWidget); + expect(find.text('Besitzer: other@example.com'), findsOneWidget); // Wechsel nach h2: Zeiger in users/u1 wird umgestellt … await tester.tap(find.text('Zweiter Haushalt')); @@ -145,5 +169,69 @@ void main() { // … und die Ansicht zeigt den neuen Haushalt (AppBar + Listeneintrag). expect(find.text('Zweiter Haushalt'), findsNWidgets(2)); expect(find.text('other@example.com'), findsOneWidget); + // Im fremden Haushalt (nicht Besitzer): Austreten möglich, Entfernen nicht. + expect(find.text('Haushalt verlassen'), findsOneWidget); + expect(find.byIcon(Icons.person_remove), findsNothing); + }); + + testWidgets('Checkliste gruppiert Aufgaben aus mehreren Haushalten', + (tester) async { + await tester.pumpWidget(await buildTestApp(secondHousehold: true)); + await tester.pumpAndSettle(); + + // Aufgaben beider Haushalte mit Zwischenüberschriften. + expect(find.textContaining('Monstera gießen'), findsOneWidget); + expect(find.textContaining('Ficus gießen'), findsOneWidget); + expect(find.text('Mein Haushalt'), findsOneWidget); + expect(find.text('Zweiter Haushalt'), findsOneWidget); + + // Die Ficus-Aufgabe (zweite Gruppe) bestätigen → sie schreibt in den + // richtigen Haushalt, die h2-Gruppe verschwindet mitsamt Überschriften. + await tester.tap(find.text('Erledigt').last); + await tester.pumpAndSettle(); + + expect(find.textContaining('Ficus gießen'), findsNothing); + expect(find.textContaining('Monstera gießen'), findsOneWidget); + expect(find.text('Zweiter Haushalt'), findsNothing); + }); + + testWidgets('Besitzer sieht Besitzer-Kennzeichnung und kann entfernen, ' + 'aber nicht austreten', (tester) async { + await tester.pumpWidget(await buildTestApp(extraMember: true)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Haushalt')); + await tester.pumpAndSettle(); + + // Besitzer-Kennzeichnung am eigenen Eintrag, Entfernen-Knopf beim Sitter. + expect(find.text('Mitglied · Besitzer'), findsOneWidget); + expect(find.byIcon(Icons.person_remove), findsOneWidget); + expect(find.text('Haushalt verlassen'), findsNothing); + + // Entfernen fragt nach. + await tester.tap(find.byIcon(Icons.person_remove)); + await tester.pumpAndSettle(); + expect(find.text('Mitglied entfernen?'), findsOneWidget); + }); + + testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async { + await tester.pumpWidget(await buildTestApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Haushalt')); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, 'Wohnung Chris'); + await tester.tap(find.text('Speichern')); + await tester.pumpAndSettle(); + + // Der neue Name kommt live aus Firestore zurück (AppBar). + expect(find.text('Wohnung Chris'), findsOneWidget); }); }