V2.1: Mehrere Haushalte je Nutzer – Mitgliedschaft bleibt beim Beitritt erhalten

- 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 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-07-19 21:18:25 +02:00
parent 8b74a5b3df
commit 1ca02f224b
11 changed files with 233 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String?>((ref) {
@ -23,7 +25,30 @@ final householdIdProvider = StreamProvider<String?>((ref) {
.map((snapshot) => snapshot.data()?['householdId'] as String?);
});
/// Der komplette Haushalt inkl. Mitgliedern und Rollen, live aus Firestore.
Household _householdFromData(String id, Map<String, dynamic> data) {
final membersMap = (data['members'] as Map<String, dynamic>?) ?? {};
final memberUids = (data['memberUids'] as List<dynamic>?) ?? [];
return Household(
id: id,
name: data['name'] as String? ?? 'Haushalt',
members: [
for (final uid in memberUids.cast<String>())
HouseholdMember(
uid: uid,
role: ((membersMap[uid] as Map<String, dynamic>?)?['role']
as String?) ==
'sitter'
? HouseholdRole.sitter
: HouseholdRole.member,
email: (membersMap[uid] as Map<String, dynamic>?)?['email']
as String? ??
'',
),
],
);
}
/// Der aktive Haushalt inkl. Mitgliedern und Rollen, live aus Firestore.
final householdProvider = StreamProvider<Household?>((ref) {
final householdId = ref.watch(householdIdProvider).value;
if (householdId == null) return Stream.value(null);
@ -36,26 +61,29 @@ final householdProvider = StreamProvider<Household?>((ref) {
.map((snapshot) {
final data = snapshot.data();
if (data == null) return null;
final membersMap = (data['members'] as Map<String, dynamic>?) ?? {};
final memberUids = (data['memberUids'] as List<dynamic>?) ?? [];
return Household(
id: snapshot.id,
name: data['name'] as String? ?? 'Haushalt',
members: [
for (final uid in memberUids.cast<String>())
HouseholdMember(
uid: uid,
role: ((membersMap[uid]
as Map<String, dynamic>?)?['role'] as String?) ==
'sitter'
? HouseholdRole.sitter
: HouseholdRole.member,
email: (membersMap[uid] as Map<String, dynamic>?)?['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<List<Household>>((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<HouseholdRole>((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<void> 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<String> joinHousehold(String code) async {
final callable = _functions.httpsCallable('joinHousehold');
final callable = _functionsGetter().httpsCallable('joinHousehold');
final response =
await callable.call<Map<String, dynamic>>({'code': code.trim()});
return response.data['householdName'] as String? ?? 'Haushalt';
@ -109,7 +150,7 @@ class HouseholdRepository {
final householdRepositoryProvider = Provider<HouseholdRepository>((ref) {
return HouseholdRepository(
ref.watch(firestoreProvider),
FirebaseFunctions.instanceFor(region: 'europe-west3'),
() => FirebaseFunctions.instanceFor(region: 'europe-west3'),
() => ref.read(firebaseAuthProvider).currentUser?.uid,
);
});

View file

@ -77,6 +77,18 @@ class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
);
}
Future<void> _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<void> _join() async {
final l10n = AppLocalizations.of(context);
final code = _codeController.text.trim();
@ -135,6 +147,7 @@ class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
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<HouseholdScreen> {
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)

View file

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

View file

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

View file

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

View file

@ -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<Widget> buildTestApp({String role = 'member'}) async {
/// Mit [secondHousehold] ist u1 zusätzlich Mitglied im Haushalt 'h2'.
Future<Widget> buildTestApp({
String role = 'member',
bool secondHousehold = false,
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
@ -43,6 +47,16 @@ Future<Widget> 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);
});
}