V2.2: Besitzer-Konzept, Haushalte umbenennen, Austreten/Entfernen, Checkliste über alle Haushalte
- ownerUid je Haushalt (Alt-Haushalte: erster memberUids-Eintrag = Ersteller); Besitzer-Anzeige in Mitglieder- und Haushaltsliste - Haushalt umbenennen (Dialog im Haushalts-Screen); Rules erlauben Clients am Haushalts-Dokument nur noch das Namensfeld - Cloud Functions leaveHousehold (selbst austreten, auch als Sitter) und removeMember (nur Besitzer); beide biegen den aktiven Zeiger des Betroffenen um und legen notfalls einen frischen eigenen Haushalt an - Haushalts-Wechsler im Drawer (PopupMenu, ab zwei Haushalten) - Tages-Checkliste und nächste Fälligkeit aggregieren über alle Haushalte, mit Zwischenüberschrift je Haushalt; Bestätigen schreibt in den Herkunfts-Haushalt der Aufgabe - Widget-Tests: Gruppierung, Besitzer-Rechte, Umbenennen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1ca02f224b
commit
778b3bd34d
19 changed files with 899 additions and 69 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<string, {role?: string; email?: string}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
// 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Household>(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class AuthRepository {
|
|||
final batch = _firestore.batch();
|
||||
batch.set(householdRef, {
|
||||
'name': 'Mein Haushalt',
|
||||
'ownerUid': uid,
|
||||
'memberUids': [uid],
|
||||
'members': {
|
||||
uid: {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ Household _householdFromData(String id, Map<String, dynamic> 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<String>())
|
||||
HouseholdMember(
|
||||
|
|
@ -145,6 +149,35 @@ class HouseholdRepository {
|
|||
await callable.call<Map<String, dynamic>>({'code': code.trim()});
|
||||
return response.data['householdName'] as String? ?? 'Haushalt';
|
||||
}
|
||||
|
||||
/// Benennt einen Haushalt um (Rules: nur volle Mitglieder, nur das
|
||||
/// Namensfeld).
|
||||
Future<void> 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<void> leaveHousehold(String householdId) async {
|
||||
final callable = _functionsGetter().httpsCallable('leaveHousehold');
|
||||
await callable.call<Map<String, dynamic>>({'householdId': householdId});
|
||||
}
|
||||
|
||||
/// Entfernt ein Mitglied aus dem Haushalt (nur der Besitzer).
|
||||
Future<void> removeMember({
|
||||
required String householdId,
|
||||
required String memberUid,
|
||||
}) async {
|
||||
final callable = _functionsGetter().httpsCallable('removeMember');
|
||||
await callable.call<Map<String, dynamic>>({
|
||||
'householdId': householdId,
|
||||
'memberUid': memberUid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final householdRepositoryProvider = Provider<HouseholdRepository>((ref) {
|
||||
|
|
|
|||
|
|
@ -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<HouseholdMember> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,95 @@ class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _rename(Household household) async {
|
||||
final newName = await showDialog<String>(
|
||||
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<void> _leave(Household household) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _removeMember(
|
||||
Household household, HouseholdMember member) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final label = member.email.isEmpty ? l10n.roleMember : member.email;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _join() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final code = _codeController.text.trim();
|
||||
|
|
@ -151,8 +240,21 @@ class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
|
|||
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<HouseholdScreen> {
|
|||
),
|
||||
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<HouseholdScreen> {
|
|||
? 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<HouseholdScreen> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,12 +48,10 @@ Map<String, dynamic> _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<List<Plant>>((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<List<Plant>, String>((ref, householdId) {
|
||||
return ref
|
||||
.watch(firestoreProvider)
|
||||
.collection('households')
|
||||
|
|
@ -66,6 +64,14 @@ final plantsProvider = StreamProvider<List<Plant>>((ref) {
|
|||
]);
|
||||
});
|
||||
|
||||
/// Die Pflanzen des gerade aktiven Haushalts.
|
||||
/// Leere Liste, solange kein Haushalt geladen ist (z. B. direkt nach Login).
|
||||
final plantsProvider = Provider<AsyncValue<List<Plant>>>((ref) {
|
||||
final householdId = ref.watch(householdIdProvider).value;
|
||||
if (householdId == null) return const AsyncValue.data([]);
|
||||
return ref.watch(householdPlantsProvider(householdId));
|
||||
});
|
||||
|
||||
final plantByIdProvider = Provider.family<Plant?, String>((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<void> confirmTask(String plantId, CareTaskType type) {
|
||||
/// [householdId] kommt aus der Aufgabe – die Checkliste zeigt Aufgaben
|
||||
/// aus allen Haushalten, nicht nur dem aktiven.
|
||||
Future<void> 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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<DueTask> _tasksForPlant(Plant plant, DateTime today) sync* {
|
||||
Iterable<DueTask> _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<List<DueTask>>((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 = <DueTask>[];
|
||||
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<bool>((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<DateTime?>((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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <Widget>[
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ class _FakePushRegistrationService implements PushRegistrationService {
|
|||
Future<void> syncReminderTime(TimeOfDay time) async {}
|
||||
}
|
||||
|
||||
/// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' im Haushalt
|
||||
/// 'h1' mit einer Monstera, deren Gießen seit einem Tag überfällig ist.
|
||||
/// 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<Widget> buildTestApp({
|
||||
String role = 'member',
|
||||
bool secondHousehold = false,
|
||||
bool extraMember = false,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
|
@ -42,20 +45,39 @@ Future<Widget> 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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue