From 1ca02f224bb820ea7518d5d840369ab4c38f17b1 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Sun, 19 Jul 2026 21:18:25 +0200 Subject: [PATCH] =?UTF-8?q?V2.1:=20Mehrere=20Haushalte=20je=20Nutzer=20?= =?UTF-8?q?=E2=80=93=20Mitgliedschaft=20bleibt=20beim=20Beitritt=20erhalte?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memberUids ist Quelle der Wahrheit, users.householdId nur noch aktiver Zeiger - Haushalts-Screen: Liste „Meine Haushalte" mit Rolle, Aktiv-Markierung und Wechsel per Tipp - Beitritts-Dialog warnt nicht mehr vor Datenverlust, sondern erklärt das Zurückwechseln - sendDailyReminders sammelt fällige Aufgaben aus allen Haushalten des Nutzers - FirebaseFunctions im HouseholdRepository lazy erzeugt (Widget-Tests ohne Firebase-Init) - Drawer-Titel mit Ellipsis gegen Overflow auf schmalen Breiten - Widget-Test für den Haushaltswechsel Co-Authored-By: Claude Fable 5 --- docs/architektur.md | 3 +- docs/firebase-einrichtung.md | 5 +- docs/handoff.md | 5 +- functions/src/reminders.ts | 39 +++++--- lib/core/widgets/app_drawer.dart | 9 +- .../household/data/household_providers.dart | 93 +++++++++++++------ .../presentation/household_screen.dart | 42 +++++++++ lib/l10n/app_de.arb | 12 ++- lib/l10n/generated/app_localizations.dart | 22 ++++- lib/l10n/generated/app_localizations_de.dart | 15 ++- test/widget_test.dart | 41 +++++++- 11 files changed, 233 insertions(+), 53 deletions(-) diff --git a/docs/architektur.md b/docs/architektur.md index bbef1fc..048c947 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -18,7 +18,7 @@ lib/ │ ├── today/ # Tages-Checkliste (Start-Screen) │ ├── plants/ # Pflanzen: Liste, Profil, Anlegen/Bearbeiten │ ├── locations/ # Stellplätze -│ ├── household/ # Haushalt (V2-Platzhalter) +│ ├── household/ # Haushalt: Mitglieder, Einladen, Wechseln │ └── settings/ # Einstellungs-Screen └── l10n/ ├── app_de.arb # ALLE deutschen Texte (Quelle) @@ -46,6 +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. - **`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 a1be93d..c108c95 100644 --- a/docs/firebase-einrichtung.md +++ b/docs/firebase-einrichtung.md @@ -75,7 +75,8 @@ firebase deploy --only firestore --project leaf-it-to-me-app ## Das Datenmodell und die Sicherheitsregeln (zum Verständnis) ``` -users/{uid} → E-Mail, householdId, fcmTokens, reminderTime (nur der Nutzer selbst) +users/{uid} → E-Mail, householdId (= aktiver Haushalt), fcmTokens, reminderTime + (nur der Nutzer selbst) households/{id} → Name, memberUids, members {uid → Rolle+E-Mail} households/{id}/plants/{id} → Pflanze: Art, Intervalle, lastWatered(+By), ... households/{id}/locations/{id} → Stellplatz: Name @@ -85,6 +86,8 @@ 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. + Die Regeln in `firestore.rules` setzen das Haushalts-Prinzip durch: - Dein **Nutzerprofil** (`users/{uid}`) kannst nur du selbst lesen/schreiben. diff --git a/docs/handoff.md b/docs/handoff.md index 2785030..7eb5c13 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -18,11 +18,12 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si ## Verifizierter Stand - **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 und deployt** (Commit `1b2c880`), vom Nutzer **noch nicht getestet**: Einladungscodes (member/sitter), `joinHousehold`-Function, Rules-Enforcement für Sitter, „bestätigt von"-Anzeige. Testplan wurde dem Nutzer genannt (iPhone-Konto lädt Android-Konto ein — er hat versehentlich zwei getrennte Konten/Haushalte). +- **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). ## Offen / Nächstes -1. Rückmeldung zum V2-Test abwarten, Bugs fixen. +1. V2.1 (Haushalts-Wechsel) vom Nutzer auf den Geräten testen lassen. 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/functions/src/reminders.ts b/functions/src/reminders.ts index 020c4cd..992d836 100644 --- a/functions/src/reminders.ts +++ b/functions/src/reminders.ts @@ -9,7 +9,7 @@ import * as admin from "firebase-admin"; */ interface UserDoc { - householdId?: string; + householdId?: string; // aktiver Haushalt (nur Anzeige-Zeiger der App) fcmTokens?: string[]; reminderTime?: string; // "HH:mm" timezone?: string; // IANA, z. B. "Europe/Berlin" @@ -150,12 +150,8 @@ export const sendDailyReminders = onSchedule( for (const userDoc of users.docs) { const user = userDoc.data() as UserDoc; const tokens = user.fcmTokens ?? []; - if (tokens.length === 0 || !user.householdId) { - logger.info("Übersprungen: kein Token oder kein Haushalt", { - uid: userDoc.id, - tokens: tokens.length, - householdId: user.householdId ?? null, - }); + if (tokens.length === 0) { + logger.info("Übersprungen: kein Token", {uid: userDoc.id}); continue; } @@ -178,12 +174,29 @@ export const sendDailyReminders = onSchedule( continue; } - const summary = await collectDueTasks( - db, - user.householdId, - local, - timezone - ); + // Aufgaben aus ALLEN Haushalten sammeln, in denen der Nutzer Mitglied + // ist – nicht nur aus dem gerade aktiven (users.householdId ist seit + // Multi-Haushalt nur noch der Anzeige-Zeiger der App). + const memberships = await db + .collection("households") + .where("memberUids", "array-contains", userDoc.id) + .get(); + const summary: DueSummary = { + watering: [], + fertilizing: [], + overdueCount: 0, + }; + for (const householdDoc of memberships.docs) { + const part = await collectDueTasks( + db, + householdDoc.id, + local, + timezone + ); + summary.watering.push(...part.watering); + summary.fertilizing.push(...part.fertilizing); + summary.overdueCount += part.overdueCount; + } const taskCount = summary.watering.length + summary.fertilizing.length; logger.info("Erinnerungs-Slot erreicht", { uid: userDoc.id, diff --git a/lib/core/widgets/app_drawer.dart b/lib/core/widgets/app_drawer.dart index 9e6f13c..ca1537d 100644 --- a/lib/core/widgets/app_drawer.dart +++ b/lib/core/widgets/app_drawer.dart @@ -38,9 +38,12 @@ class AppDrawer extends ConsumerWidget { color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 12), - Text( - l10n.appTitle, - style: Theme.of(context).textTheme.headlineSmall, + Expanded( + child: Text( + l10n.appTitle, + style: Theme.of(context).textTheme.headlineSmall, + overflow: TextOverflow.ellipsis, + ), ), ], ), diff --git a/lib/features/household/data/household_providers.dart b/lib/features/household/data/household_providers.dart index fa4f448..7d06026 100644 --- a/lib/features/household/data/household_providers.dart +++ b/lib/features/household/data/household_providers.dart @@ -7,8 +7,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/firebase/firebase_providers.dart'; import '../domain/household.dart'; -/// Die Haushalts-ID des angemeldeten Nutzers (aus users/{uid}.householdId). +/// Die ID des **aktiven** Haushalts (aus users/{uid}.householdId). /// +/// Man kann Mitglied mehrerer Haushalte sein ([myHouseholdsProvider]); +/// dieser Zeiger bestimmt, welcher davon gerade in der App angezeigt wird. /// null, solange niemand angemeldet ist oder das Profil noch lädt. /// Alle Daten-Provider (Pflanzen, Stellplätze) hängen hieran. final householdIdProvider = StreamProvider((ref) { @@ -23,7 +25,30 @@ final householdIdProvider = StreamProvider((ref) { .map((snapshot) => snapshot.data()?['householdId'] as String?); }); -/// Der komplette Haushalt inkl. Mitgliedern und Rollen, live aus Firestore. +Household _householdFromData(String id, Map data) { + final membersMap = (data['members'] as Map?) ?? {}; + final memberUids = (data['memberUids'] as List?) ?? []; + return Household( + id: id, + name: data['name'] as String? ?? 'Haushalt', + members: [ + for (final uid in memberUids.cast()) + HouseholdMember( + uid: uid, + role: ((membersMap[uid] as Map?)?['role'] + as String?) == + 'sitter' + ? HouseholdRole.sitter + : HouseholdRole.member, + email: (membersMap[uid] as Map?)?['email'] + as String? ?? + '', + ), + ], + ); +} + +/// Der aktive Haushalt inkl. Mitgliedern und Rollen, live aus Firestore. final householdProvider = StreamProvider((ref) { final householdId = ref.watch(householdIdProvider).value; if (householdId == null) return Stream.value(null); @@ -36,26 +61,29 @@ final householdProvider = StreamProvider((ref) { .map((snapshot) { final data = snapshot.data(); if (data == null) return null; - final membersMap = (data['members'] as Map?) ?? {}; - final memberUids = (data['memberUids'] as List?) ?? []; - return Household( - id: snapshot.id, - name: data['name'] as String? ?? 'Haushalt', - members: [ - for (final uid in memberUids.cast()) - HouseholdMember( - uid: uid, - role: ((membersMap[uid] - as Map?)?['role'] as String?) == - 'sitter' - ? HouseholdRole.sitter - : HouseholdRole.member, - email: (membersMap[uid] as Map?)?['email'] - as String? ?? - '', - ), - ], - ); + return _householdFromData(snapshot.id, data); + }); +}); + +/// Alle Haushalte, in denen der angemeldete Nutzer Mitglied ist. +/// +/// Quelle der Wahrheit ist das Feld `memberUids` der Haushalte selbst – +/// wer per Einladung beitritt, bleibt in seinen bisherigen Haushalten +/// Mitglied und kann jederzeit zurückwechseln. +final myHouseholdsProvider = StreamProvider>((ref) { + final user = ref.watch(authStateProvider).value; + if (user == null) return Stream.value(const []); + + return ref + .watch(firestoreProvider) + .collection('households') + .where('memberUids', arrayContains: user.uid) + .snapshots() + .map((snapshot) { + final households = [ + for (final doc in snapshot.docs) _householdFromData(doc.id, doc.data()), + ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return households; }); }); @@ -68,10 +96,12 @@ final myRoleProvider = Provider((ref) { }); class HouseholdRepository { - HouseholdRepository(this._firestore, this._functions, this._uidGetter); + HouseholdRepository(this._firestore, this._functionsGetter, this._uidGetter); final FirebaseFirestore _firestore; - final FirebaseFunctions _functions; + // Lazy, damit Widget-Tests ohne initialisiertes Firebase auskommen – + // FirebaseFunctions.instanceFor wirft sonst schon beim Erzeugen. + final FirebaseFunctions Function() _functionsGetter; final String? Function() _uidGetter; static const _codeAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; @@ -96,10 +126,21 @@ class HouseholdRepository { return code; } + /// Macht einen anderen Haushalt zum aktiven (users/{uid}.householdId). + /// Die Mitgliedschaft in allen anderen Haushalten bleibt bestehen. + Future switchHousehold(String householdId) async { + final uid = _uidGetter(); + if (uid == null) return; + await _firestore + .collection('users') + .doc(uid) + .set({'householdId': householdId}, SetOptions(merge: true)); + } + /// Löst einen Code ein und wechselt in den zugehörigen Haushalt. /// Gibt den Namen des neuen Haushalts zurück. Future joinHousehold(String code) async { - final callable = _functions.httpsCallable('joinHousehold'); + final callable = _functionsGetter().httpsCallable('joinHousehold'); final response = await callable.call>({'code': code.trim()}); return response.data['householdName'] as String? ?? 'Haushalt'; @@ -109,7 +150,7 @@ class HouseholdRepository { final householdRepositoryProvider = Provider((ref) { return HouseholdRepository( ref.watch(firestoreProvider), - FirebaseFunctions.instanceFor(region: 'europe-west3'), + () => FirebaseFunctions.instanceFor(region: 'europe-west3'), () => ref.read(firebaseAuthProvider).currentUser?.uid, ); }); diff --git a/lib/features/household/presentation/household_screen.dart b/lib/features/household/presentation/household_screen.dart index 53de83f..50a6532 100644 --- a/lib/features/household/presentation/household_screen.dart +++ b/lib/features/household/presentation/household_screen.dart @@ -77,6 +77,18 @@ class _HouseholdScreenState extends ConsumerState { ); } + Future _switchTo(Household target) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + await ref.read(householdRepositoryProvider).switchHousehold(target.id); + if (!mounted) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.switchHouseholdSuccess(target.name))), + ); + } + Future _join() async { final l10n = AppLocalizations.of(context); final code = _codeController.text.trim(); @@ -135,6 +147,7 @@ class _HouseholdScreenState extends ConsumerState { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final household = ref.watch(householdProvider).value; + final myHouseholds = ref.watch(myHouseholdsProvider).value ?? const []; final myRole = ref.watch(myRoleProvider); final myUid = ref.watch(authStateProvider).value?.uid; @@ -144,6 +157,35 @@ class _HouseholdScreenState extends ConsumerState { body: ListView( padding: const EdgeInsets.all(16), children: [ + if (myHouseholds.length > 1) ...[ + Text(l10n.myHouseholds, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + for (final entry in myHouseholds) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + entry.id == household?.id + ? Icons.home + : Icons.home_outlined, + color: entry.id == household?.id + ? theme.colorScheme.primary + : null, + ), + title: Text(entry.name), + subtitle: Text( + myUid != null && entry.roleOf(myUid) == HouseholdRole.sitter + ? l10n.roleSitter + : l10n.roleMember, + ), + trailing: entry.id == household?.id + ? Chip(label: Text(l10n.activeHouseholdLabel)) + : null, + onTap: entry.id == household?.id + ? null + : () => _switchTo(entry), + ), + const Divider(height: 32), + ], Text(l10n.householdMembers, style: theme.textTheme.titleMedium), const SizedBox(height: 8), if (household != null) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c4e192d..e7cc71f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -105,6 +105,14 @@ } }, "householdTitle": "Haushalt", + "myHouseholds": "Meine Haushalte", + "activeHouseholdLabel": "Aktiv", + "switchHouseholdSuccess": "Du bist jetzt im Haushalt „{name}“.", + "@switchHouseholdSuccess": { + "placeholders": { + "name": { "type": "String" } + } + }, "householdMembers": "Mitglieder", "roleMember": "Mitglied", "roleSitter": "Pflanzen-Sitter", @@ -120,8 +128,8 @@ "joinTitle": "Haushalt beitreten", "joinCodeLabel": "Einladungscode eingeben", "joinButton": "Beitreten", - "joinWarningTitle": "Haushalt wechseln?", - "joinWarningBody": "Du trittst einem anderen Haushalt bei. Die Pflanzen deines bisherigen Haushalts siehst du danach nicht mehr.", + "joinWarningTitle": "Haushalt beitreten?", + "joinWarningBody": "Du trittst dem Haushalt bei und wechselst direkt dorthin. Deine bisherigen Haushalte bleiben erhalten – unter „Meine Haushalte“ kannst du jederzeit zurückwechseln.", "joinSuccess": "Willkommen im Haushalt „{name}“!", "@joinSuccess": { "placeholders": { diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 31d9af3..ba72d23 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -364,6 +364,24 @@ abstract class AppLocalizations { /// **'Haushalt'** String get householdTitle; + /// No description provided for @myHouseholds. + /// + /// In de, this message translates to: + /// **'Meine Haushalte'** + String get myHouseholds; + + /// No description provided for @activeHouseholdLabel. + /// + /// In de, this message translates to: + /// **'Aktiv'** + String get activeHouseholdLabel; + + /// No description provided for @switchHouseholdSuccess. + /// + /// In de, this message translates to: + /// **'Du bist jetzt im Haushalt „{name}“.'** + String switchHouseholdSuccess(String name); + /// No description provided for @householdMembers. /// /// In de, this message translates to: @@ -457,13 +475,13 @@ abstract class AppLocalizations { /// No description provided for @joinWarningTitle. /// /// In de, this message translates to: - /// **'Haushalt wechseln?'** + /// **'Haushalt beitreten?'** String get joinWarningTitle; /// No description provided for @joinWarningBody. /// /// In de, this message translates to: - /// **'Du trittst einem anderen Haushalt bei. Die Pflanzen deines bisherigen Haushalts siehst du danach nicht mehr.'** + /// **'Du trittst dem Haushalt bei und wechselst direkt dorthin. Deine bisherigen Haushalte bleiben erhalten – unter „Meine Haushalte“ kannst du jederzeit zurückwechseln.'** String get joinWarningBody; /// No description provided for @joinSuccess. diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 0bd2107..1e6d355 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -187,6 +187,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String get householdTitle => 'Haushalt'; + @override + String get myHouseholds => 'Meine Haushalte'; + + @override + String get activeHouseholdLabel => 'Aktiv'; + + @override + String switchHouseholdSuccess(String name) { + return 'Du bist jetzt im Haushalt „$name“.'; + } + @override String get householdMembers => 'Mitglieder'; @@ -235,11 +246,11 @@ class AppLocalizationsDe extends AppLocalizations { String get joinButton => 'Beitreten'; @override - String get joinWarningTitle => 'Haushalt wechseln?'; + String get joinWarningTitle => 'Haushalt beitreten?'; @override String get joinWarningBody => - 'Du trittst einem anderen Haushalt bei. Die Pflanzen deines bisherigen Haushalts siehst du danach nicht mehr.'; + 'Du trittst dem Haushalt bei und wechselst direkt dorthin. Deine bisherigen Haushalte bleiben erhalten – unter „Meine Haushalte“ kannst du jederzeit zurückwechseln.'; @override String joinSuccess(String name) { diff --git a/test/widget_test.dart b/test/widget_test.dart index 33863b6..6e71b62 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -22,7 +22,11 @@ class _FakePushRegistrationService implements PushRegistrationService { /// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' im Haushalt /// 'h1' mit einer Monstera, deren Gießen seit einem Tag überfällig ist. -Future buildTestApp({String role = 'member'}) async { +/// Mit [secondHousehold] ist u1 zusätzlich Mitglied im Haushalt 'h2'. +Future buildTestApp({ + String role = 'member', + bool secondHousehold = false, +}) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -43,6 +47,16 @@ Future buildTestApp({String role = 'member'}) async { 'u1': {'role': role, 'email': 'test@example.com'}, }, }); + if (secondHousehold) { + await firestore.collection('households').doc('h2').set({ + 'name': 'Zweiter Haushalt', + 'memberUids': ['u1', 'u2'], + 'members': { + 'u1': {'role': 'sitter', 'email': 'test@example.com'}, + 'u2': {'role': 'member', 'email': 'other@example.com'}, + }, + }); + } final now = DateTime.now(); await firestore .collection('households') @@ -107,4 +121,29 @@ void main() { // … aber Pflanzen anlegen nicht. expect(find.text('Pflanze hinzufügen'), findsNothing); }); + + testWidgets('Mitglied mehrerer Haushalte kann den aktiven Haushalt wechseln', + (tester) async { + await tester.pumpWidget(await buildTestApp(secondHousehold: true)); + await tester.pumpAndSettle(); + + // Über das Menü zur Haushalts-Ansicht. + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Haushalt')); + await tester.pumpAndSettle(); + + // Beide Haushalte sind gelistet, h1 ist aktiv. + expect(find.text('Meine Haushalte'), findsOneWidget); + expect(find.text('Aktiv'), findsOneWidget); + expect(find.text('Zweiter Haushalt'), findsOneWidget); + + // Wechsel nach h2: Zeiger in users/u1 wird umgestellt … + await tester.tap(find.text('Zweiter Haushalt')); + await tester.pumpAndSettle(); + + // … und die Ansicht zeigt den neuen Haushalt (AppBar + Listeneintrag). + expect(find.text('Zweiter Haushalt'), findsNWidgets(2)); + expect(find.text('other@example.com'), findsOneWidget); + }); }