From 1b2c880c89c4c112d6ebf906582620b4981b945d Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Sun, 19 Jul 2026 19:14:34 +0200 Subject: [PATCH] =?UTF-8?q?V2:=20Haushalt=20teilen=20=E2=80=93=20Einladung?= =?UTF-8?q?scodes,=20Mitglieder-=20und=20Sitter-Rolle,=20sichtbare=20Best?= =?UTF-8?q?=C3=A4tigungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Einladen per einmaligem 6-stelligem Code (7 Tage gültig), wahlweise als Mitglied oder Pflanzen-Sitter - Beitritt über Cloud Function joinHousehold (Transaktion, Admin-Rechte) - Security Rules: Sitter dürfen an Pflanzen nur Bestätigungs-Felder ändern; invites nur erstellen, nie lesen - Haushalts-Screen: Mitgliederliste mit Rollen, Einladen, Beitreten mit Wechsel-Warnung - UI-Gating: Sitter sehen keine Anlegen-/Bearbeiten-/Löschen-Aktionen - Bestätigungen speichern den Namen (lastWateredBy/lastFertilizedBy), Anzeige im Profil - Tests: Members-Map im Seed, neuer Sitter-Test Co-Authored-By: Claude Fable 5 --- README.md | 2 +- docs/firebase-einrichtung.md | 10 +- firestore.rules | 66 +++++- functions/src/household.ts | 87 +++++++ functions/src/index.ts | 1 + lib/features/auth/data/auth_repository.dart | 7 + .../household/data/household_providers.dart | 96 ++++++++ lib/features/household/domain/household.dart | 42 ++++ .../presentation/household_screen.dart | 221 ++++++++++++++++-- .../presentation/locations_screen.dart | 16 +- lib/features/plants/data/plants_provider.dart | 19 +- lib/features/plants/domain/plant.dart | 10 + .../presentation/plant_detail_screen.dart | 16 +- .../plants/presentation/plants_screen.dart | 16 +- .../today/presentation/today_screen.dart | 16 +- lib/l10n/app_de.arb | 33 ++- lib/l10n/generated/app_localizations.dart | 126 +++++++++- lib/l10n/generated/app_localizations_de.dart | 69 +++++- test/widget_test.dart | 16 +- 19 files changed, 802 insertions(+), 67 deletions(-) create mode 100644 functions/src/household.ts create mode 100644 lib/features/household/domain/household.dart diff --git a/README.md b/README.md index 301f459..9949264 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ App zur Verwaltung und Pflege von Pflanzen — für den eigenen Haushalt und Pfl ## Status -**V1 in Entwicklung.** Aktueller Stand: App-Gerüst mit Tages-Checkliste, Pflanzenverwaltung, Stellplätzen und Einstellungen (In-Memory-Demo-Daten, noch ohne Firebase-Anbindung). +**V1 fertig, V2 in Arbeit.** V1 (Foto-Erkennung mit KI-Pflegeprofil, Pflegeplan mit Tages-Checkliste, tägliche Sammel-Push) ist auf Android komplett verifiziert; iOS-Push wartet auf den Apple Developer Account. V2 (Haushalt teilen per Einladungscode, Mitglieder- und Sitter-Rolle, sichtbare Bestätigungen) ist implementiert und deployt. ## Roadmap diff --git a/docs/firebase-einrichtung.md b/docs/firebase-einrichtung.md index dc7ae42..a1be93d 100644 --- a/docs/firebase-einrichtung.md +++ b/docs/firebase-einrichtung.md @@ -75,12 +75,16 @@ firebase deploy --only firestore --project leaf-it-to-me-app ## Das Datenmodell und die Sicherheitsregeln (zum Verständnis) ``` -users/{uid} → E-Mail, householdId (nur der Nutzer selbst) -households/{id} → Name, memberUids [Liste der Mitglieder] -households/{id}/plants/{id} → Pflanze: Art, Intervalle, lastWatered, ... +users/{uid} → E-Mail, householdId, 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 +invites/{code} → Einladung: householdId, Rolle; nur Erstellen erlaubt, + Einlösen ausschließlich über die Function joinHousehold ``` +**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. + Die Regeln in `firestore.rules` setzen das Haushalts-Prinzip durch: - Dein **Nutzerprofil** (`users/{uid}`) kannst nur du selbst lesen/schreiben. diff --git a/firestore.rules b/firestore.rules index 60e2ee1..99385cd 100644 --- a/firestore.rules +++ b/firestore.rules @@ -2,9 +2,10 @@ rules_version = '2'; // Sicherheitsregeln für LeafItToMe. // -// Grundprinzip: Alle App-Daten hängen an einem Haushalt. Lesen und Schreiben -// darf nur, wer im Feld `memberUids` des Haushalts steht. Das eigene -// Nutzer-Dokument (users/{uid}) darf nur der Nutzer selbst sehen. +// Grundprinzip: Alle App-Daten hängen an einem Haushalt. Lesen darf nur, +// wer im Feld `memberUids` des Haushalts steht. Schreiben hängt zusätzlich +// von der Rolle ab: "member" darf alles, "sitter" (Pflanzen-Sitter) darf +// nur Aufgaben bestätigen. Das eigene Nutzer-Dokument sieht nur der Nutzer. service cloud.firestore { match /databases/{database}/documents { @@ -12,9 +13,22 @@ service cloud.firestore { return request.auth != null; } + function householdData(householdId) { + return get(/databases/$(database)/documents/households/$(householdId)).data; + } + function isMember(householdId) { return signedIn() - && request.auth.uid in get(/databases/$(database)/documents/households/$(householdId)).data.memberUids; + && request.auth.uid in householdData(householdId).memberUids; + } + + // Rolle des aufrufenden Nutzers. Haushalte aus der Zeit vor V2 haben + // noch keine members-Map – dann gilt jeder Eingetragene als "member". + function roleOf(householdId) { + return householdData(householdId) + .get('members', {}) + .get(request.auth.uid, {'role': 'member'}) + .get('role', 'member'); } // Eigenes Nutzerprofil (enthält u. a. die householdId). @@ -22,19 +36,47 @@ service cloud.firestore { allow read, write: if signedIn() && request.auth.uid == uid; } + // Einladungscodes: Erstellen darf jedes Mitglied des jeweiligen + // Haushalts. Gelesen und eingelöst werden Codes ausschließlich über + // die Cloud Function joinHousehold (Admin-Rechte) – Clients nie direkt. + match /invites/{code} { + allow create: if signedIn() + && request.resource.data.createdBy == request.auth.uid + && isMember(request.resource.data.householdId); + allow read, update, delete: if false; + } + match /households/{householdId} { - // Lesen/Ändern nur für Mitglieder. - allow read, update: if signedIn() && request.auth.uid in resource.data.memberUids; + 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. + 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'; // Anlegen nur, wenn man sich selbst als Mitglied einträgt // (passiert automatisch bei der Registrierung). - allow create: if signedIn() && request.auth.uid in request.resource.data.memberUids; - // Löschen von Haushalten ist bewusst nicht erlaubt. + allow create: if signedIn() + && request.auth.uid in request.resource.data.memberUids; allow delete: if false; - // Alle Unterdaten des Haushalts (plants, locations, ...): - // voller Zugriff für Mitglieder, niemand sonst. - match /{document=**} { - allow read, write: if isMember(householdId); + match /plants/{plantId} { + allow read: if isMember(householdId); + allow create, delete: if isMember(householdId) + && roleOf(householdId) == 'member'; + // Sitter dürfen ausschließlich die Bestätigungs-Felder ändern. + allow update: if isMember(householdId) + && (roleOf(householdId) == 'member' + || request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['lastWatered', 'lastWateredBy', + 'lastFertilized', 'lastFertilizedBy'])); + } + + match /locations/{locationId} { + allow read: if isMember(householdId); + allow write: if isMember(householdId) + && roleOf(householdId) == 'member'; } } } diff --git a/functions/src/household.ts b/functions/src/household.ts new file mode 100644 index 0000000..fa2019a --- /dev/null +++ b/functions/src/household.ts @@ -0,0 +1,87 @@ +import {onCall, HttpsError} from "firebase-functions/v2/https"; +import * as admin from "firebase-admin"; + +/** Gültigkeit eines Einladungscodes. */ +const INVITE_TTL_DAYS = 7; + +/** + * Einladungscode einlösen und dem Haushalt beitreten. + * + * Läuft mit Admin-Rechten in einer Transaktion, weil der Beitretende laut + * Security Rules (noch) kein Mitglied ist und den Haushalt selbst nicht + * ändern dürfte. Codes sind einmalig verwendbar und 7 Tage gültig. + */ +export const joinHousehold = 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 rawCode = request.data?.code; + if (typeof rawCode !== "string" || rawCode.trim().length === 0) { + throw new HttpsError("invalid-argument", "Code fehlt."); + } + const code = rawCode.trim().toUpperCase(); + + const db = admin.firestore(); + return db.runTransaction(async (tx) => { + const inviteRef = db.collection("invites").doc(code); + const inviteSnap = await tx.get(inviteRef); + if (!inviteSnap.exists) { + throw new HttpsError("not-found", "Dieser Code ist ungültig."); + } + const invite = inviteSnap.data() as { + householdId: string; + role?: string; + createdAt?: admin.firestore.Timestamp; + usedBy?: string; + }; + if (invite.usedBy) { + throw new HttpsError( + "failed-precondition", + "Dieser Code wurde bereits verwendet." + ); + } + const ageMs = invite.createdAt + ? Date.now() - invite.createdAt.toMillis() + : 0; + if (ageMs > INVITE_TTL_DAYS * 24 * 60 * 60 * 1000) { + throw new HttpsError("not-found", "Dieser Code ist abgelaufen."); + } + + const householdRef = db + .collection("households") + .doc(invite.householdId); + const householdSnap = await tx.get(householdRef); + if (!householdSnap.exists) { + throw new HttpsError("not-found", "Der Haushalt existiert nicht mehr."); + } + + const role = invite.role === "sitter" ? "sitter" : "member"; + tx.update(householdRef, { + memberUids: admin.firestore.FieldValue.arrayUnion(uid), + [`members.${uid}`]: { + role, + email, + joinedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + }); + tx.set( + db.collection("users").doc(uid), + {householdId: invite.householdId}, + {merge: true} + ); + tx.update(inviteRef, { + usedBy: uid, + usedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + + return { + householdName: + (householdSnap.data()?.name as string | undefined) ?? "Haushalt", + }; + }); + } +); diff --git a/functions/src/index.ts b/functions/src/index.ts index 052a94e..c819da3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -5,6 +5,7 @@ import * as admin from "firebase-admin"; admin.initializeApp(); export {sendDailyReminders} from "./reminders"; +export {joinHousehold} from "./household"; // Secrets liegen im Google Secret Manager (firebase functions:secrets:set), // niemals im Code oder in der App. diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 168912d..8367330 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -31,6 +31,13 @@ class AuthRepository { batch.set(householdRef, { 'name': 'Mein Haushalt', 'memberUids': [uid], + 'members': { + uid: { + 'role': 'member', + 'email': email, + 'joinedAt': FieldValue.serverTimestamp(), + }, + }, 'createdAt': FieldValue.serverTimestamp(), }); batch.set(userRef, { diff --git a/lib/features/household/data/household_providers.dart b/lib/features/household/data/household_providers.dart index 8166b86..fa4f448 100644 --- a/lib/features/household/data/household_providers.dart +++ b/lib/features/household/data/household_providers.dart @@ -1,6 +1,11 @@ +import 'dart:math'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; 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). /// @@ -17,3 +22,94 @@ final householdIdProvider = StreamProvider((ref) { .snapshots() .map((snapshot) => snapshot.data()?['householdId'] as String?); }); + +/// Der komplette 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); + + return ref + .watch(firestoreProvider) + .collection('households') + .doc(householdId) + .snapshots() + .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? ?? + '', + ), + ], + ); + }); +}); + +/// Rolle des angemeldeten Nutzers im aktuellen Haushalt. +final myRoleProvider = Provider((ref) { + final household = ref.watch(householdProvider).value; + final uid = ref.watch(authStateProvider).value?.uid; + if (household == null || uid == null) return HouseholdRole.member; + return household.roleOf(uid); +}); + +class HouseholdRepository { + HouseholdRepository(this._firestore, this._functions, this._uidGetter); + + final FirebaseFirestore _firestore; + final FirebaseFunctions _functions; + final String? Function() _uidGetter; + + static const _codeAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + + /// Erzeugt einen einmaligen Einladungscode (7 Tage gültig). + Future createInvite({ + required String householdId, + required HouseholdRole role, + }) async { + final random = Random.secure(); + final code = List.generate( + 6, + (_) => _codeAlphabet[random.nextInt(_codeAlphabet.length)], + ).join(); + + await _firestore.collection('invites').doc(code).set({ + 'householdId': householdId, + 'role': role.name, + 'createdBy': _uidGetter(), + 'createdAt': FieldValue.serverTimestamp(), + }); + return code; + } + + /// 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 response = + await callable.call>({'code': code.trim()}); + return response.data['householdName'] as String? ?? 'Haushalt'; + } +} + +final householdRepositoryProvider = Provider((ref) { + return HouseholdRepository( + ref.watch(firestoreProvider), + FirebaseFunctions.instanceFor(region: 'europe-west3'), + () => ref.read(firebaseAuthProvider).currentUser?.uid, + ); +}); diff --git a/lib/features/household/domain/household.dart b/lib/features/household/domain/household.dart new file mode 100644 index 0000000..35a6282 --- /dev/null +++ b/lib/features/household/domain/household.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +/// Rolle eines Haushalts-Mitglieds. +/// +/// member = volle Rechte; sitter = Pflanzen-Sitter, darf Pläne sehen und +/// Aufgaben bestätigen, aber nichts anlegen, ändern oder löschen. +/// Die Durchsetzung passiert serverseitig in den Security Rules – +/// die UI blendet entsprechende Aktionen zusätzlich aus. +enum HouseholdRole { member, sitter } + +@immutable +class HouseholdMember { + const HouseholdMember({ + required this.uid, + required this.role, + required this.email, + }); + + final String uid; + final HouseholdRole role; + final String email; +} + +@immutable +class Household { + const Household({ + required this.id, + required this.name, + required this.members, + }); + + final String id; + final String name; + final List members; + + HouseholdRole roleOf(String uid) { + for (final member in members) { + if (member.uid == uid) return member.role; + } + return HouseholdRole.member; + } +} diff --git a/lib/features/household/presentation/household_screen.dart b/lib/features/household/presentation/household_screen.dart index fba89e8..53de83f 100644 --- a/lib/features/household/presentation/household_screen.dart +++ b/lib/features/household/presentation/household_screen.dart @@ -1,39 +1,220 @@ +import 'package:cloud_functions/cloud_functions.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/firebase/firebase_providers.dart'; import '../../../core/widgets/app_drawer.dart'; import '../../../l10n/generated/app_localizations.dart'; +import '../data/household_providers.dart'; +import '../domain/household.dart'; -/// Platzhalter für das Haushalt-Sharing (V2): Einladungen, Mitglieder, -/// Sitter-Rolle. -class HouseholdScreen extends StatelessWidget { +/// Haushalt: Mitglieder mit Rollen, Einladen per Code, Beitreten per Code. +class HouseholdScreen extends ConsumerStatefulWidget { const HouseholdScreen({super.key}); + @override + ConsumerState createState() => _HouseholdScreenState(); +} + +class _HouseholdScreenState extends ConsumerState { + final _codeController = TextEditingController(); + bool _joining = false; + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + Future _createInvite(HouseholdRole role) async { + final l10n = AppLocalizations.of(context); + final householdId = ref.read(householdIdProvider).value; + if (householdId == null) return; + + final code = await ref + .read(householdRepositoryProvider) + .createInvite(householdId: householdId, role: role); + if (!mounted) return; + + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.inviteCodeTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + code, + style: Theme.of(dialogContext) + .textTheme + .displaySmall + ?.copyWith(letterSpacing: 6, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text(l10n.inviteCodeHint), + ], + ), + actions: [ + TextButton.icon( + icon: const Icon(Icons.copy), + label: Text(l10n.copyCode), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + await Clipboard.setData(ClipboardData(text: code)); + messenger.showSnackBar( + SnackBar(content: Text(l10n.codeCopied)), + ); + }, + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('OK'), + ), + ], + ), + ); + } + + Future _join() async { + final l10n = AppLocalizations.of(context); + final code = _codeController.text.trim(); + if (code.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.joinWarningTitle), + content: Text(l10n.joinWarningBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.joinButton), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _joining = true); + try { + final name = + await ref.read(householdRepositoryProvider).joinHousehold(code); + if (!mounted) return; + _codeController.clear(); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.joinSuccess(name)))); + } on FirebaseFunctionsException catch (e) { + if (!mounted) return; + final message = switch (e.code) { + 'not-found' => l10n.joinErrorInvalid, + 'failed-precondition' => l10n.joinErrorUsed, + _ => l10n.authErrorGeneric, + }; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.authErrorGeneric))); + } finally { + if (mounted) setState(() => _joining = false); + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); + final household = ref.watch(householdProvider).value; + final myRole = ref.watch(myRoleProvider); + final myUid = ref.watch(authStateProvider).value?.uid; return Scaffold( - appBar: AppBar(title: Text(l10n.householdTitle)), + appBar: AppBar(title: Text(household?.name ?? l10n.householdTitle)), drawer: const AppDrawer(), - body: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.group, size: 72, color: theme.colorScheme.primary), - const SizedBox(height: 16), - Text( - l10n.householdPlaceholder, - style: theme.textTheme.bodyLarge, - textAlign: TextAlign.center, + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(l10n.householdMembers, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + if (household != null) + for (final member in household.members) + ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + child: Icon(member.role == HouseholdRole.sitter + ? Icons.volunteer_activism + : Icons.person), + ), + title: Text( + member.uid == myUid && member.email.isEmpty + ? l10n.meLabel + : member.email, + ), + subtitle: Text(member.role == HouseholdRole.sitter + ? l10n.roleSitter + : l10n.roleMember), + trailing: member.uid == myUid + ? Chip(label: Text(l10n.meLabel)) + : null, ), - const SizedBox(height: 16), - Chip(label: Text(l10n.householdComingSoon)), - ], + if (myRole == HouseholdRole.member) ...[ + const Divider(height: 32), + Text(l10n.inviteTitle, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + l10n.inviteExplanation, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 12), + FilledButton.tonalIcon( + onPressed: () => _createInvite(HouseholdRole.member), + icon: const Icon(Icons.person_add), + label: Text(l10n.inviteMember), + ), + const SizedBox(height: 8), + FilledButton.tonalIcon( + onPressed: () => _createInvite(HouseholdRole.sitter), + icon: const Icon(Icons.volunteer_activism), + label: Text(l10n.inviteSitter), + ), + ], + const Divider(height: 32), + Text(l10n.joinTitle, style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + TextField( + controller: _codeController, + decoration: InputDecoration( + labelText: l10n.joinCodeLabel, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.key), + ), + textCapitalization: TextCapitalization.characters, + autocorrect: false, ), - ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _joining ? null : _join, + icon: _joining + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.group_add), + label: Text(l10n.joinButton), + ), + ], ), ); } diff --git a/lib/features/locations/presentation/locations_screen.dart b/lib/features/locations/presentation/locations_screen.dart index bb5d3d6..3db29b6 100644 --- a/lib/features/locations/presentation/locations_screen.dart +++ b/lib/features/locations/presentation/locations_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/widgets/app_drawer.dart'; import '../../../l10n/generated/app_localizations.dart'; +import '../../household/data/household_providers.dart'; +import '../../household/domain/household.dart'; import '../../plants/data/plants_provider.dart'; import '../data/locations_provider.dart'; @@ -81,14 +83,18 @@ class LocationsScreen extends ConsumerWidget { ); } + final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member; + return Scaffold( appBar: AppBar(title: Text(l10n.locationsTitle)), drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _addLocation(context, ref), - icon: const Icon(Icons.add), - label: Text(l10n.addLocation), - ), + floatingActionButton: canEdit + ? FloatingActionButton.extended( + onPressed: () => _addLocation(context, ref), + icon: const Icon(Icons.add), + label: Text(l10n.addLocation), + ) + : null, body: body, ); } diff --git a/lib/features/plants/data/plants_provider.dart b/lib/features/plants/data/plants_provider.dart index 5efce53..fc0a8fa 100644 --- a/lib/features/plants/data/plants_provider.dart +++ b/lib/features/plants/data/plants_provider.dart @@ -23,6 +23,8 @@ Plant _plantFromDoc(String id, Map data) { (data['fertilizingIntervalDays'] as num?)?.toInt() ?? 28, lastWatered: (data['lastWatered'] as Timestamp?)?.toDate(), lastFertilized: (data['lastFertilized'] as Timestamp?)?.toDate(), + lastWateredBy: data['lastWateredBy'] as String?, + lastFertilizedBy: data['lastFertilizedBy'] as String?, ); } @@ -41,6 +43,8 @@ Map _plantToMap(Plant plant) { 'lastFertilized': plant.lastFertilized != null ? Timestamp.fromDate(plant.lastFertilized!) : null, + 'lastWateredBy': plant.lastWateredBy, + 'lastFertilizedBy': plant.lastFertilizedBy, }; } @@ -71,11 +75,14 @@ final plantByIdProvider = Provider.family((ref, id) { }); class PlantRepository { - PlantRepository(this._firestore, this._householdIdGetter); + PlantRepository(this._firestore, this._householdIdGetter, this._userLabelGetter); final FirebaseFirestore _firestore; final String? Function() _householdIdGetter; + /// Anzeigename des angemeldeten Nutzers für "bestätigt von". + final String? Function() _userLabelGetter; + CollectionReference> get _plants { final householdId = _householdIdGetter(); if (householdId == null) { @@ -101,7 +108,10 @@ class PlantRepository { CareTaskType.watering => 'lastWatered', CareTaskType.fertilizing => 'lastFertilized', }; - return _plants.doc(plantId).update({field: Timestamp.now()}); + return _plants.doc(plantId).update({ + field: Timestamp.now(), + '${field}By': _userLabelGetter(), + }); } } @@ -109,5 +119,10 @@ final plantRepositoryProvider = Provider((ref) { return PlantRepository( ref.watch(firestoreProvider), () => ref.read(householdIdProvider).value, + () { + final email = ref.read(firebaseAuthProvider).currentUser?.email; + // "chris@example.com" → "chris" als Anzeigename. + return email?.split('@').first; + }, ); }); diff --git a/lib/features/plants/domain/plant.dart b/lib/features/plants/domain/plant.dart index 3d5ebe6..e1d66c1 100644 --- a/lib/features/plants/domain/plant.dart +++ b/lib/features/plants/domain/plant.dart @@ -19,6 +19,8 @@ class Plant { this.photoUrl, this.lastWatered, this.lastFertilized, + this.lastWateredBy, + this.lastFertilizedBy, }); final String id; @@ -47,6 +49,10 @@ class Plant { final DateTime? lastWatered; final DateTime? lastFertilized; + /// Wer zuletzt bestätigt hat (Anzeigename) – für den Haushalt sichtbar. + final String? lastWateredBy; + final String? lastFertilizedBy; + Plant copyWith({ String? nickname, String? species, @@ -58,6 +64,8 @@ class Plant { int? fertilizingIntervalDays, DateTime? lastWatered, DateTime? lastFertilized, + String? lastWateredBy, + String? lastFertilizedBy, }) { return Plant( id: id, @@ -72,6 +80,8 @@ class Plant { fertilizingIntervalDays ?? this.fertilizingIntervalDays, lastWatered: lastWatered ?? this.lastWatered, lastFertilized: lastFertilized ?? this.lastFertilized, + lastWateredBy: lastWateredBy ?? this.lastWateredBy, + lastFertilizedBy: lastFertilizedBy ?? this.lastFertilizedBy, ); } } diff --git a/lib/features/plants/presentation/plant_detail_screen.dart b/lib/features/plants/presentation/plant_detail_screen.dart index 86c4a13..782a506 100644 --- a/lib/features/plants/presentation/plant_detail_screen.dart +++ b/lib/features/plants/presentation/plant_detail_screen.dart @@ -5,6 +5,8 @@ import 'package:intl/intl.dart'; import '../../../core/router/app_router.dart'; import '../../../l10n/generated/app_localizations.dart'; +import '../../household/data/household_providers.dart'; +import '../../household/domain/household.dart'; import '../../locations/data/locations_provider.dart'; import '../data/plants_provider.dart'; @@ -29,10 +31,13 @@ class PlantDetailScreen extends ConsumerWidget { String formatDate(DateTime? date) => date != null ? dateFormat.format(date) : l10n.never; + final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member; + return Scaffold( appBar: AppBar( title: Text(plant.nickname), actions: [ + if (canEdit) ...[ IconButton( icon: const Icon(Icons.edit), tooltip: l10n.plantDetailTitle, @@ -64,6 +69,7 @@ class PlantDetailScreen extends ConsumerWidget { } }, ), + ], ], ), body: ListView( @@ -94,12 +100,18 @@ class PlantDetailScreen extends ConsumerWidget { _InfoTile( icon: Icons.water_drop, label: l10n.wateringEvery(plant.wateringIntervalDays), - value: l10n.lastWatered(formatDate(plant.lastWatered)), + value: l10n.lastWatered(formatDate(plant.lastWatered)) + + (plant.lastWateredBy != null + ? l10n.doneBy(plant.lastWateredBy!) + : ''), ), _InfoTile( icon: Icons.compost, label: l10n.fertilizingEvery(plant.fertilizingIntervalDays), - value: l10n.lastFertilized(formatDate(plant.lastFertilized)), + value: l10n.lastFertilized(formatDate(plant.lastFertilized)) + + (plant.lastFertilizedBy != null + ? l10n.doneBy(plant.lastFertilizedBy!) + : ''), ), if (plant.description.isNotEmpty) ...[ const Divider(height: 32), diff --git a/lib/features/plants/presentation/plants_screen.dart b/lib/features/plants/presentation/plants_screen.dart index b6f170a..1e40777 100644 --- a/lib/features/plants/presentation/plants_screen.dart +++ b/lib/features/plants/presentation/plants_screen.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import '../../../core/router/app_router.dart'; import '../../../core/widgets/app_drawer.dart'; import '../../../l10n/generated/app_localizations.dart'; +import '../../household/data/household_providers.dart'; +import '../../household/domain/household.dart'; import '../../locations/data/locations_provider.dart'; import '../data/plants_provider.dart'; import '../domain/plant.dart'; @@ -27,14 +29,18 @@ class PlantsScreen extends ConsumerWidget { body = _plantList(context, ref, plants); } + final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member; + return Scaffold( appBar: AppBar(title: Text(l10n.plantsTitle)), drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => context.push(AppRoutes.plantAdd), - icon: const Icon(Icons.add), - label: Text(l10n.addPlant), - ), + floatingActionButton: canEdit + ? FloatingActionButton.extended( + onPressed: () => context.push(AppRoutes.plantAdd), + icon: const Icon(Icons.add), + label: Text(l10n.addPlant), + ) + : null, body: body, ); } diff --git a/lib/features/today/presentation/today_screen.dart b/lib/features/today/presentation/today_screen.dart index 93927ae..3abe114 100644 --- a/lib/features/today/presentation/today_screen.dart +++ b/lib/features/today/presentation/today_screen.dart @@ -6,6 +6,8 @@ import 'package:intl/intl.dart'; import '../../../core/router/app_router.dart'; import '../../../core/widgets/app_drawer.dart'; import '../../../l10n/generated/app_localizations.dart'; +import '../../household/data/household_providers.dart'; +import '../../household/domain/household.dart'; import '../../plants/data/plants_provider.dart'; import '../application/due_tasks_provider.dart'; import '../domain/due_task.dart'; @@ -30,14 +32,18 @@ class TodayScreen extends ConsumerWidget { body = _TaskListView(tasks: tasks); } + final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member; + return Scaffold( appBar: AppBar(title: Text(l10n.todayTitle)), drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => context.push(AppRoutes.plantAdd), - icon: const Icon(Icons.add), - label: Text(l10n.addPlant), - ), + floatingActionButton: canEdit + ? FloatingActionButton.extended( + onPressed: () => context.push(AppRoutes.plantAdd), + icon: const Icon(Icons.add), + label: Text(l10n.addPlant), + ) + : null, body: body, ); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 814558b..c4e192d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -105,8 +105,37 @@ } }, "householdTitle": "Haushalt", - "householdPlaceholder": "Hier kannst du bald deinen Haushalt teilen, damit Familie oder Pflanzen-Sitter die Pflanzen mitversorgen können.", - "householdComingSoon": "Kommt in Version 2", + "householdMembers": "Mitglieder", + "roleMember": "Mitglied", + "roleSitter": "Pflanzen-Sitter", + "meLabel": "Ich", + "inviteTitle": "Einladen", + "inviteMember": "Mitglied einladen", + "inviteSitter": "Pflanzen-Sitter einladen", + "inviteExplanation": "Mitglieder haben volle Rechte. Pflanzen-Sitter sehen die Pläne und können Aufgaben abhaken, aber nichts ändern.", + "inviteCodeTitle": "Einladungscode", + "inviteCodeHint": "Gib diesen Code an die Person weiter. Sie trägt ihn in ihrer App unter „Haushalt“ ein. Der Code ist 7 Tage gültig und einmal verwendbar.", + "copyCode": "Code kopieren", + "codeCopied": "Code kopiert.", + "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.", + "joinSuccess": "Willkommen im Haushalt „{name}“!", + "@joinSuccess": { + "placeholders": { + "name": { "type": "String" } + } + }, + "joinErrorInvalid": "Dieser Code ist ungültig oder abgelaufen.", + "joinErrorUsed": "Dieser Code wurde bereits verwendet.", + "doneBy": " · von {name}", + "@doneBy": { + "placeholders": { + "name": { "type": "String" } + } + }, "settingsTitle": "Einstellungen", "settingsAppearance": "Erscheinungsbild", "themeSystem": "System", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 142bf28..31d9af3 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -364,17 +364,131 @@ abstract class AppLocalizations { /// **'Haushalt'** String get householdTitle; - /// No description provided for @householdPlaceholder. + /// No description provided for @householdMembers. /// /// In de, this message translates to: - /// **'Hier kannst du bald deinen Haushalt teilen, damit Familie oder Pflanzen-Sitter die Pflanzen mitversorgen können.'** - String get householdPlaceholder; + /// **'Mitglieder'** + String get householdMembers; - /// No description provided for @householdComingSoon. + /// No description provided for @roleMember. /// /// In de, this message translates to: - /// **'Kommt in Version 2'** - String get householdComingSoon; + /// **'Mitglied'** + String get roleMember; + + /// No description provided for @roleSitter. + /// + /// In de, this message translates to: + /// **'Pflanzen-Sitter'** + String get roleSitter; + + /// No description provided for @meLabel. + /// + /// In de, this message translates to: + /// **'Ich'** + String get meLabel; + + /// No description provided for @inviteTitle. + /// + /// In de, this message translates to: + /// **'Einladen'** + String get inviteTitle; + + /// No description provided for @inviteMember. + /// + /// In de, this message translates to: + /// **'Mitglied einladen'** + String get inviteMember; + + /// No description provided for @inviteSitter. + /// + /// In de, this message translates to: + /// **'Pflanzen-Sitter einladen'** + String get inviteSitter; + + /// No description provided for @inviteExplanation. + /// + /// In de, this message translates to: + /// **'Mitglieder haben volle Rechte. Pflanzen-Sitter sehen die Pläne und können Aufgaben abhaken, aber nichts ändern.'** + String get inviteExplanation; + + /// No description provided for @inviteCodeTitle. + /// + /// In de, this message translates to: + /// **'Einladungscode'** + String get inviteCodeTitle; + + /// No description provided for @inviteCodeHint. + /// + /// In de, this message translates to: + /// **'Gib diesen Code an die Person weiter. Sie trägt ihn in ihrer App unter „Haushalt“ ein. Der Code ist 7 Tage gültig und einmal verwendbar.'** + String get inviteCodeHint; + + /// No description provided for @copyCode. + /// + /// In de, this message translates to: + /// **'Code kopieren'** + String get copyCode; + + /// No description provided for @codeCopied. + /// + /// In de, this message translates to: + /// **'Code kopiert.'** + String get codeCopied; + + /// No description provided for @joinTitle. + /// + /// In de, this message translates to: + /// **'Haushalt beitreten'** + String get joinTitle; + + /// No description provided for @joinCodeLabel. + /// + /// In de, this message translates to: + /// **'Einladungscode eingeben'** + String get joinCodeLabel; + + /// No description provided for @joinButton. + /// + /// In de, this message translates to: + /// **'Beitreten'** + String get joinButton; + + /// No description provided for @joinWarningTitle. + /// + /// In de, this message translates to: + /// **'Haushalt wechseln?'** + 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.'** + String get joinWarningBody; + + /// No description provided for @joinSuccess. + /// + /// In de, this message translates to: + /// **'Willkommen im Haushalt „{name}“!'** + String joinSuccess(String name); + + /// No description provided for @joinErrorInvalid. + /// + /// In de, this message translates to: + /// **'Dieser Code ist ungültig oder abgelaufen.'** + String get joinErrorInvalid; + + /// No description provided for @joinErrorUsed. + /// + /// In de, this message translates to: + /// **'Dieser Code wurde bereits verwendet.'** + String get joinErrorUsed; + + /// No description provided for @doneBy. + /// + /// In de, this message translates to: + /// **' · von {name}'** + String doneBy(String name); /// No description provided for @settingsTitle. /// diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 4f24022..0bd2107 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -188,11 +188,74 @@ class AppLocalizationsDe extends AppLocalizations { String get householdTitle => 'Haushalt'; @override - String get householdPlaceholder => - 'Hier kannst du bald deinen Haushalt teilen, damit Familie oder Pflanzen-Sitter die Pflanzen mitversorgen können.'; + String get householdMembers => 'Mitglieder'; @override - String get householdComingSoon => 'Kommt in Version 2'; + String get roleMember => 'Mitglied'; + + @override + String get roleSitter => 'Pflanzen-Sitter'; + + @override + String get meLabel => 'Ich'; + + @override + String get inviteTitle => 'Einladen'; + + @override + String get inviteMember => 'Mitglied einladen'; + + @override + String get inviteSitter => 'Pflanzen-Sitter einladen'; + + @override + String get inviteExplanation => + 'Mitglieder haben volle Rechte. Pflanzen-Sitter sehen die Pläne und können Aufgaben abhaken, aber nichts ändern.'; + + @override + String get inviteCodeTitle => 'Einladungscode'; + + @override + String get inviteCodeHint => + 'Gib diesen Code an die Person weiter. Sie trägt ihn in ihrer App unter „Haushalt“ ein. Der Code ist 7 Tage gültig und einmal verwendbar.'; + + @override + String get copyCode => 'Code kopieren'; + + @override + String get codeCopied => 'Code kopiert.'; + + @override + String get joinTitle => 'Haushalt beitreten'; + + @override + String get joinCodeLabel => 'Einladungscode eingeben'; + + @override + String get joinButton => 'Beitreten'; + + @override + String get joinWarningTitle => 'Haushalt wechseln?'; + + @override + String get joinWarningBody => + 'Du trittst einem anderen Haushalt bei. Die Pflanzen deines bisherigen Haushalts siehst du danach nicht mehr.'; + + @override + String joinSuccess(String name) { + return 'Willkommen im Haushalt „$name“!'; + } + + @override + String get joinErrorInvalid => 'Dieser Code ist ungültig oder abgelaufen.'; + + @override + String get joinErrorUsed => 'Dieser Code wurde bereits verwendet.'; + + @override + String doneBy(String name) { + return ' · von $name'; + } @override String get settingsTitle => 'Einstellungen'; diff --git a/test/widget_test.dart b/test/widget_test.dart index aebf45a..33863b6 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -22,7 +22,7 @@ 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() async { +Future buildTestApp({String role = 'member'}) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -39,6 +39,9 @@ Future buildTestApp() async { await firestore.collection('households').doc('h1').set({ 'name': 'Mein Haushalt', 'memberUids': ['u1'], + 'members': { + 'u1': {'role': role, 'email': 'test@example.com'}, + }, }); final now = DateTime.now(); await firestore @@ -93,4 +96,15 @@ void main() { expect(find.text('Erledigt'), findsNothing); expect(find.text('Alles versorgt!'), findsOneWidget); }); + + testWidgets('Sitter kann abhaken, sieht aber keinen Anlegen-Button', + (tester) async { + await tester.pumpWidget(await buildTestApp(role: 'sitter')); + await tester.pumpAndSettle(); + + // Abhaken ist erlaubt … + expect(find.text('Erledigt'), findsWidgets); + // … aber Pflanzen anlegen nicht. + expect(find.text('Pflanze hinzufügen'), findsNothing); + }); }