V2: Haushalt teilen – Einladungscodes, Mitglieder- und Sitter-Rolle, sichtbare Bestätigungen
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
9abd3de351
commit
1b2c880c89
19 changed files with 802 additions and 67 deletions
|
|
@ -4,7 +4,7 @@ App zur Verwaltung und Pflege von Pflanzen — für den eigenen Haushalt und Pfl
|
||||||
|
|
||||||
## Status
|
## 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
|
## Roadmap
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,12 +75,16 @@ firebase deploy --only firestore --project leaf-it-to-me-app
|
||||||
## Das Datenmodell und die Sicherheitsregeln (zum Verständnis)
|
## Das Datenmodell und die Sicherheitsregeln (zum Verständnis)
|
||||||
|
|
||||||
```
|
```
|
||||||
users/{uid} → E-Mail, householdId (nur der Nutzer selbst)
|
users/{uid} → E-Mail, householdId, fcmTokens, reminderTime (nur der Nutzer selbst)
|
||||||
households/{id} → Name, memberUids [Liste der Mitglieder]
|
households/{id} → Name, memberUids, members {uid → Rolle+E-Mail}
|
||||||
households/{id}/plants/{id} → Pflanze: Art, Intervalle, lastWatered, ...
|
households/{id}/plants/{id} → Pflanze: Art, Intervalle, lastWatered(+By), ...
|
||||||
households/{id}/locations/{id} → Stellplatz: Name
|
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:
|
Die Regeln in `firestore.rules` setzen das Haushalts-Prinzip durch:
|
||||||
|
|
||||||
- Dein **Nutzerprofil** (`users/{uid}`) kannst nur du selbst lesen/schreiben.
|
- Dein **Nutzerprofil** (`users/{uid}`) kannst nur du selbst lesen/schreiben.
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ rules_version = '2';
|
||||||
|
|
||||||
// Sicherheitsregeln für LeafItToMe.
|
// Sicherheitsregeln für LeafItToMe.
|
||||||
//
|
//
|
||||||
// Grundprinzip: Alle App-Daten hängen an einem Haushalt. Lesen und Schreiben
|
// Grundprinzip: Alle App-Daten hängen an einem Haushalt. Lesen darf nur,
|
||||||
// darf nur, wer im Feld `memberUids` des Haushalts steht. Das eigene
|
// wer im Feld `memberUids` des Haushalts steht. Schreiben hängt zusätzlich
|
||||||
// Nutzer-Dokument (users/{uid}) darf nur der Nutzer selbst sehen.
|
// 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 {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
|
@ -12,9 +13,22 @@ service cloud.firestore {
|
||||||
return request.auth != null;
|
return request.auth != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function householdData(householdId) {
|
||||||
|
return get(/databases/$(database)/documents/households/$(householdId)).data;
|
||||||
|
}
|
||||||
|
|
||||||
function isMember(householdId) {
|
function isMember(householdId) {
|
||||||
return signedIn()
|
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).
|
// Eigenes Nutzerprofil (enthält u. a. die householdId).
|
||||||
|
|
@ -22,19 +36,47 @@ service cloud.firestore {
|
||||||
allow read, write: if signedIn() && request.auth.uid == uid;
|
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} {
|
match /households/{householdId} {
|
||||||
// Lesen/Ändern nur für Mitglieder.
|
allow read: if signedIn() && request.auth.uid in resource.data.memberUids;
|
||||||
allow read, update: 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
|
// Anlegen nur, wenn man sich selbst als Mitglied einträgt
|
||||||
// (passiert automatisch bei der Registrierung).
|
// (passiert automatisch bei der Registrierung).
|
||||||
allow create: if signedIn() && request.auth.uid in request.resource.data.memberUids;
|
allow create: if signedIn()
|
||||||
// Löschen von Haushalten ist bewusst nicht erlaubt.
|
&& request.auth.uid in request.resource.data.memberUids;
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
|
|
||||||
// Alle Unterdaten des Haushalts (plants, locations, ...):
|
match /plants/{plantId} {
|
||||||
// voller Zugriff für Mitglieder, niemand sonst.
|
allow read: if isMember(householdId);
|
||||||
match /{document=**} {
|
allow create, delete: if isMember(householdId)
|
||||||
allow read, write: 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
87
functions/src/household.ts
Normal file
87
functions/src/household.ts
Normal file
|
|
@ -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",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -5,6 +5,7 @@ import * as admin from "firebase-admin";
|
||||||
admin.initializeApp();
|
admin.initializeApp();
|
||||||
|
|
||||||
export {sendDailyReminders} from "./reminders";
|
export {sendDailyReminders} from "./reminders";
|
||||||
|
export {joinHousehold} from "./household";
|
||||||
|
|
||||||
// Secrets liegen im Google Secret Manager (firebase functions:secrets:set),
|
// Secrets liegen im Google Secret Manager (firebase functions:secrets:set),
|
||||||
// niemals im Code oder in der App.
|
// niemals im Code oder in der App.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ class AuthRepository {
|
||||||
batch.set(householdRef, {
|
batch.set(householdRef, {
|
||||||
'name': 'Mein Haushalt',
|
'name': 'Mein Haushalt',
|
||||||
'memberUids': [uid],
|
'memberUids': [uid],
|
||||||
|
'members': {
|
||||||
|
uid: {
|
||||||
|
'role': 'member',
|
||||||
|
'email': email,
|
||||||
|
'joinedAt': FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
},
|
||||||
'createdAt': FieldValue.serverTimestamp(),
|
'createdAt': FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
batch.set(userRef, {
|
batch.set(userRef, {
|
||||||
|
|
|
||||||
|
|
@ -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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/firebase/firebase_providers.dart';
|
import '../../../core/firebase/firebase_providers.dart';
|
||||||
|
import '../domain/household.dart';
|
||||||
|
|
||||||
/// Die Haushalts-ID des angemeldeten Nutzers (aus users/{uid}.householdId).
|
/// Die Haushalts-ID des angemeldeten Nutzers (aus users/{uid}.householdId).
|
||||||
///
|
///
|
||||||
|
|
@ -17,3 +22,94 @@ final householdIdProvider = StreamProvider<String?>((ref) {
|
||||||
.snapshots()
|
.snapshots()
|
||||||
.map((snapshot) => snapshot.data()?['householdId'] as String?);
|
.map((snapshot) => snapshot.data()?['householdId'] as String?);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Der komplette Haushalt inkl. Mitgliedern und Rollen, live aus Firestore.
|
||||||
|
final householdProvider = StreamProvider<Household?>((ref) {
|
||||||
|
final householdId = ref.watch(householdIdProvider).value;
|
||||||
|
if (householdId == null) return Stream.value(null);
|
||||||
|
|
||||||
|
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<String, dynamic>?) ?? {};
|
||||||
|
final memberUids = (data['memberUids'] as List<dynamic>?) ?? [];
|
||||||
|
return Household(
|
||||||
|
id: snapshot.id,
|
||||||
|
name: data['name'] as String? ?? 'Haushalt',
|
||||||
|
members: [
|
||||||
|
for (final uid in memberUids.cast<String>())
|
||||||
|
HouseholdMember(
|
||||||
|
uid: uid,
|
||||||
|
role: ((membersMap[uid]
|
||||||
|
as Map<String, dynamic>?)?['role'] as String?) ==
|
||||||
|
'sitter'
|
||||||
|
? HouseholdRole.sitter
|
||||||
|
: HouseholdRole.member,
|
||||||
|
email: (membersMap[uid] as Map<String, dynamic>?)?['email']
|
||||||
|
as String? ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Rolle des angemeldeten Nutzers im aktuellen Haushalt.
|
||||||
|
final myRoleProvider = Provider<HouseholdRole>((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<String> 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<String> joinHousehold(String code) async {
|
||||||
|
final callable = _functions.httpsCallable('joinHousehold');
|
||||||
|
final response =
|
||||||
|
await callable.call<Map<String, dynamic>>({'code': code.trim()});
|
||||||
|
return response.data['householdName'] as String? ?? 'Haushalt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final householdRepositoryProvider = Provider<HouseholdRepository>((ref) {
|
||||||
|
return HouseholdRepository(
|
||||||
|
ref.watch(firestoreProvider),
|
||||||
|
FirebaseFunctions.instanceFor(region: 'europe-west3'),
|
||||||
|
() => ref.read(firebaseAuthProvider).currentUser?.uid,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
42
lib/features/household/domain/household.dart
Normal file
42
lib/features/household/domain/household.dart
Normal file
|
|
@ -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<HouseholdMember> members;
|
||||||
|
|
||||||
|
HouseholdRole roleOf(String uid) {
|
||||||
|
for (final member in members) {
|
||||||
|
if (member.uid == uid) return member.role;
|
||||||
|
}
|
||||||
|
return HouseholdRole.member;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,39 +1,220 @@
|
||||||
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
import 'package:flutter/material.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 '../../../core/widgets/app_drawer.dart';
|
||||||
import '../../../l10n/generated/app_localizations.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,
|
/// Haushalt: Mitglieder mit Rollen, Einladen per Code, Beitreten per Code.
|
||||||
/// Sitter-Rolle.
|
class HouseholdScreen extends ConsumerStatefulWidget {
|
||||||
class HouseholdScreen extends StatelessWidget {
|
|
||||||
const HouseholdScreen({super.key});
|
const HouseholdScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<HouseholdScreen> createState() => _HouseholdScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
|
||||||
|
final _codeController = TextEditingController();
|
||||||
|
bool _joining = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_codeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void>(
|
||||||
|
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<void> _join() async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final code = _codeController.text.trim();
|
||||||
|
if (code.isEmpty) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(l10n.householdTitle)),
|
appBar: AppBar(title: Text(household?.name ?? l10n.householdTitle)),
|
||||||
drawer: const AppDrawer(),
|
drawer: const AppDrawer(),
|
||||||
body: Center(
|
body: ListView(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(16),
|
||||||
padding: const EdgeInsets.all(32),
|
children: [
|
||||||
child: Column(
|
Text(l10n.householdMembers, style: theme.textTheme.titleMedium),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const SizedBox(height: 8),
|
||||||
children: [
|
if (household != null)
|
||||||
Icon(Icons.group, size: 72, color: theme.colorScheme.primary),
|
for (final member in household.members)
|
||||||
const SizedBox(height: 16),
|
ListTile(
|
||||||
Text(
|
contentPadding: EdgeInsets.zero,
|
||||||
l10n.householdPlaceholder,
|
leading: CircleAvatar(
|
||||||
style: theme.textTheme.bodyLarge,
|
child: Icon(member.role == HouseholdRole.sitter
|
||||||
textAlign: TextAlign.center,
|
? 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),
|
if (myRole == HouseholdRole.member) ...[
|
||||||
Chip(label: Text(l10n.householdComingSoon)),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/widgets/app_drawer.dart';
|
import '../../../core/widgets/app_drawer.dart';
|
||||||
import '../../../l10n/generated/app_localizations.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 '../../plants/data/plants_provider.dart';
|
||||||
import '../data/locations_provider.dart';
|
import '../data/locations_provider.dart';
|
||||||
|
|
||||||
|
|
@ -81,14 +83,18 @@ class LocationsScreen extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(l10n.locationsTitle)),
|
appBar: AppBar(title: Text(l10n.locationsTitle)),
|
||||||
drawer: const AppDrawer(),
|
drawer: const AppDrawer(),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: canEdit
|
||||||
onPressed: () => _addLocation(context, ref),
|
? FloatingActionButton.extended(
|
||||||
icon: const Icon(Icons.add),
|
onPressed: () => _addLocation(context, ref),
|
||||||
label: Text(l10n.addLocation),
|
icon: const Icon(Icons.add),
|
||||||
),
|
label: Text(l10n.addLocation),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
body: body,
|
body: body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ Plant _plantFromDoc(String id, Map<String, dynamic> data) {
|
||||||
(data['fertilizingIntervalDays'] as num?)?.toInt() ?? 28,
|
(data['fertilizingIntervalDays'] as num?)?.toInt() ?? 28,
|
||||||
lastWatered: (data['lastWatered'] as Timestamp?)?.toDate(),
|
lastWatered: (data['lastWatered'] as Timestamp?)?.toDate(),
|
||||||
lastFertilized: (data['lastFertilized'] as Timestamp?)?.toDate(),
|
lastFertilized: (data['lastFertilized'] as Timestamp?)?.toDate(),
|
||||||
|
lastWateredBy: data['lastWateredBy'] as String?,
|
||||||
|
lastFertilizedBy: data['lastFertilizedBy'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +43,8 @@ Map<String, dynamic> _plantToMap(Plant plant) {
|
||||||
'lastFertilized': plant.lastFertilized != null
|
'lastFertilized': plant.lastFertilized != null
|
||||||
? Timestamp.fromDate(plant.lastFertilized!)
|
? Timestamp.fromDate(plant.lastFertilized!)
|
||||||
: null,
|
: null,
|
||||||
|
'lastWateredBy': plant.lastWateredBy,
|
||||||
|
'lastFertilizedBy': plant.lastFertilizedBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,11 +75,14 @@ final plantByIdProvider = Provider.family<Plant?, String>((ref, id) {
|
||||||
});
|
});
|
||||||
|
|
||||||
class PlantRepository {
|
class PlantRepository {
|
||||||
PlantRepository(this._firestore, this._householdIdGetter);
|
PlantRepository(this._firestore, this._householdIdGetter, this._userLabelGetter);
|
||||||
|
|
||||||
final FirebaseFirestore _firestore;
|
final FirebaseFirestore _firestore;
|
||||||
final String? Function() _householdIdGetter;
|
final String? Function() _householdIdGetter;
|
||||||
|
|
||||||
|
/// Anzeigename des angemeldeten Nutzers für "bestätigt von".
|
||||||
|
final String? Function() _userLabelGetter;
|
||||||
|
|
||||||
CollectionReference<Map<String, dynamic>> get _plants {
|
CollectionReference<Map<String, dynamic>> get _plants {
|
||||||
final householdId = _householdIdGetter();
|
final householdId = _householdIdGetter();
|
||||||
if (householdId == null) {
|
if (householdId == null) {
|
||||||
|
|
@ -101,7 +108,10 @@ class PlantRepository {
|
||||||
CareTaskType.watering => 'lastWatered',
|
CareTaskType.watering => 'lastWatered',
|
||||||
CareTaskType.fertilizing => 'lastFertilized',
|
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<PlantRepository>((ref) {
|
||||||
return PlantRepository(
|
return PlantRepository(
|
||||||
ref.watch(firestoreProvider),
|
ref.watch(firestoreProvider),
|
||||||
() => ref.read(householdIdProvider).value,
|
() => ref.read(householdIdProvider).value,
|
||||||
|
() {
|
||||||
|
final email = ref.read(firebaseAuthProvider).currentUser?.email;
|
||||||
|
// "chris@example.com" → "chris" als Anzeigename.
|
||||||
|
return email?.split('@').first;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ class Plant {
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
this.lastWatered,
|
this.lastWatered,
|
||||||
this.lastFertilized,
|
this.lastFertilized,
|
||||||
|
this.lastWateredBy,
|
||||||
|
this.lastFertilizedBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -47,6 +49,10 @@ class Plant {
|
||||||
final DateTime? lastWatered;
|
final DateTime? lastWatered;
|
||||||
final DateTime? lastFertilized;
|
final DateTime? lastFertilized;
|
||||||
|
|
||||||
|
/// Wer zuletzt bestätigt hat (Anzeigename) – für den Haushalt sichtbar.
|
||||||
|
final String? lastWateredBy;
|
||||||
|
final String? lastFertilizedBy;
|
||||||
|
|
||||||
Plant copyWith({
|
Plant copyWith({
|
||||||
String? nickname,
|
String? nickname,
|
||||||
String? species,
|
String? species,
|
||||||
|
|
@ -58,6 +64,8 @@ class Plant {
|
||||||
int? fertilizingIntervalDays,
|
int? fertilizingIntervalDays,
|
||||||
DateTime? lastWatered,
|
DateTime? lastWatered,
|
||||||
DateTime? lastFertilized,
|
DateTime? lastFertilized,
|
||||||
|
String? lastWateredBy,
|
||||||
|
String? lastFertilizedBy,
|
||||||
}) {
|
}) {
|
||||||
return Plant(
|
return Plant(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -72,6 +80,8 @@ class Plant {
|
||||||
fertilizingIntervalDays ?? this.fertilizingIntervalDays,
|
fertilizingIntervalDays ?? this.fertilizingIntervalDays,
|
||||||
lastWatered: lastWatered ?? this.lastWatered,
|
lastWatered: lastWatered ?? this.lastWatered,
|
||||||
lastFertilized: lastFertilized ?? this.lastFertilized,
|
lastFertilized: lastFertilized ?? this.lastFertilized,
|
||||||
|
lastWateredBy: lastWateredBy ?? this.lastWateredBy,
|
||||||
|
lastFertilizedBy: lastFertilizedBy ?? this.lastFertilizedBy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../l10n/generated/app_localizations.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 '../../locations/data/locations_provider.dart';
|
||||||
import '../data/plants_provider.dart';
|
import '../data/plants_provider.dart';
|
||||||
|
|
||||||
|
|
@ -29,10 +31,13 @@ class PlantDetailScreen extends ConsumerWidget {
|
||||||
String formatDate(DateTime? date) =>
|
String formatDate(DateTime? date) =>
|
||||||
date != null ? dateFormat.format(date) : l10n.never;
|
date != null ? dateFormat.format(date) : l10n.never;
|
||||||
|
|
||||||
|
final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(plant.nickname),
|
title: Text(plant.nickname),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (canEdit) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
tooltip: l10n.plantDetailTitle,
|
tooltip: l10n.plantDetailTitle,
|
||||||
|
|
@ -64,6 +69,7 @@ class PlantDetailScreen extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
|
@ -94,12 +100,18 @@ class PlantDetailScreen extends ConsumerWidget {
|
||||||
_InfoTile(
|
_InfoTile(
|
||||||
icon: Icons.water_drop,
|
icon: Icons.water_drop,
|
||||||
label: l10n.wateringEvery(plant.wateringIntervalDays),
|
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(
|
_InfoTile(
|
||||||
icon: Icons.compost,
|
icon: Icons.compost,
|
||||||
label: l10n.fertilizingEvery(plant.fertilizingIntervalDays),
|
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) ...[
|
if (plant.description.isNotEmpty) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/widgets/app_drawer.dart';
|
import '../../../core/widgets/app_drawer.dart';
|
||||||
import '../../../l10n/generated/app_localizations.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 '../../locations/data/locations_provider.dart';
|
||||||
import '../data/plants_provider.dart';
|
import '../data/plants_provider.dart';
|
||||||
import '../domain/plant.dart';
|
import '../domain/plant.dart';
|
||||||
|
|
@ -27,14 +29,18 @@ class PlantsScreen extends ConsumerWidget {
|
||||||
body = _plantList(context, ref, plants);
|
body = _plantList(context, ref, plants);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(l10n.plantsTitle)),
|
appBar: AppBar(title: Text(l10n.plantsTitle)),
|
||||||
drawer: const AppDrawer(),
|
drawer: const AppDrawer(),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: canEdit
|
||||||
onPressed: () => context.push(AppRoutes.plantAdd),
|
? FloatingActionButton.extended(
|
||||||
icon: const Icon(Icons.add),
|
onPressed: () => context.push(AppRoutes.plantAdd),
|
||||||
label: Text(l10n.addPlant),
|
icon: const Icon(Icons.add),
|
||||||
),
|
label: Text(l10n.addPlant),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
body: body,
|
body: body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import 'package:intl/intl.dart';
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/widgets/app_drawer.dart';
|
import '../../../core/widgets/app_drawer.dart';
|
||||||
import '../../../l10n/generated/app_localizations.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 '../../plants/data/plants_provider.dart';
|
||||||
import '../application/due_tasks_provider.dart';
|
import '../application/due_tasks_provider.dart';
|
||||||
import '../domain/due_task.dart';
|
import '../domain/due_task.dart';
|
||||||
|
|
@ -30,14 +32,18 @@ class TodayScreen extends ConsumerWidget {
|
||||||
body = _TaskListView(tasks: tasks);
|
body = _TaskListView(tasks: tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(l10n.todayTitle)),
|
appBar: AppBar(title: Text(l10n.todayTitle)),
|
||||||
drawer: const AppDrawer(),
|
drawer: const AppDrawer(),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: canEdit
|
||||||
onPressed: () => context.push(AppRoutes.plantAdd),
|
? FloatingActionButton.extended(
|
||||||
icon: const Icon(Icons.add),
|
onPressed: () => context.push(AppRoutes.plantAdd),
|
||||||
label: Text(l10n.addPlant),
|
icon: const Icon(Icons.add),
|
||||||
),
|
label: Text(l10n.addPlant),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
body: body,
|
body: body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,37 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"householdTitle": "Haushalt",
|
"householdTitle": "Haushalt",
|
||||||
"householdPlaceholder": "Hier kannst du bald deinen Haushalt teilen, damit Familie oder Pflanzen-Sitter die Pflanzen mitversorgen können.",
|
"householdMembers": "Mitglieder",
|
||||||
"householdComingSoon": "Kommt in Version 2",
|
"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",
|
"settingsTitle": "Einstellungen",
|
||||||
"settingsAppearance": "Erscheinungsbild",
|
"settingsAppearance": "Erscheinungsbild",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
|
|
|
||||||
|
|
@ -364,17 +364,131 @@ abstract class AppLocalizations {
|
||||||
/// **'Haushalt'**
|
/// **'Haushalt'**
|
||||||
String get householdTitle;
|
String get householdTitle;
|
||||||
|
|
||||||
/// No description provided for @householdPlaceholder.
|
/// No description provided for @householdMembers.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
/// **'Hier kannst du bald deinen Haushalt teilen, damit Familie oder Pflanzen-Sitter die Pflanzen mitversorgen können.'**
|
/// **'Mitglieder'**
|
||||||
String get householdPlaceholder;
|
String get householdMembers;
|
||||||
|
|
||||||
/// No description provided for @householdComingSoon.
|
/// No description provided for @roleMember.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
/// **'Kommt in Version 2'**
|
/// **'Mitglied'**
|
||||||
String get householdComingSoon;
|
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.
|
/// No description provided for @settingsTitle.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -188,11 +188,74 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get householdTitle => 'Haushalt';
|
String get householdTitle => 'Haushalt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get householdPlaceholder =>
|
String get householdMembers => 'Mitglieder';
|
||||||
'Hier kannst du bald deinen Haushalt teilen, damit Familie oder Pflanzen-Sitter die Pflanzen mitversorgen können.';
|
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get settingsTitle => 'Einstellungen';
|
String get settingsTitle => 'Einstellungen';
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class _FakePushRegistrationService implements PushRegistrationService {
|
||||||
|
|
||||||
/// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' im Haushalt
|
/// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' im Haushalt
|
||||||
/// 'h1' mit einer Monstera, deren Gießen seit einem Tag überfällig ist.
|
/// 'h1' mit einer Monstera, deren Gießen seit einem Tag überfällig ist.
|
||||||
Future<Widget> buildTestApp() async {
|
Future<Widget> buildTestApp({String role = 'member'}) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
|
@ -39,6 +39,9 @@ Future<Widget> buildTestApp() async {
|
||||||
await firestore.collection('households').doc('h1').set({
|
await firestore.collection('households').doc('h1').set({
|
||||||
'name': 'Mein Haushalt',
|
'name': 'Mein Haushalt',
|
||||||
'memberUids': ['u1'],
|
'memberUids': ['u1'],
|
||||||
|
'members': {
|
||||||
|
'u1': {'role': role, 'email': 'test@example.com'},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
await firestore
|
await firestore
|
||||||
|
|
@ -93,4 +96,15 @@ void main() {
|
||||||
expect(find.text('Erledigt'), findsNothing);
|
expect(find.text('Erledigt'), findsNothing);
|
||||||
expect(find.text('Alles versorgt!'), findsOneWidget);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue