- ownerUid je Haushalt (Alt-Haushalte: erster memberUids-Eintrag = Ersteller); Besitzer-Anzeige in Mitglieder- und Haushaltsliste - Haushalt umbenennen (Dialog im Haushalts-Screen); Rules erlauben Clients am Haushalts-Dokument nur noch das Namensfeld - Cloud Functions leaveHousehold (selbst austreten, auch als Sitter) und removeMember (nur Besitzer); beide biegen den aktiven Zeiger des Betroffenen um und legen notfalls einen frischen eigenen Haushalt an - Haushalts-Wechsler im Drawer (PopupMenu, ab zwei Haushalten) - Tages-Checkliste und nächste Fälligkeit aggregieren über alle Haushalte, mit Zwischenüberschrift je Haushalt; Bestätigen schreibt in den Herkunfts-Haushalt der Aufgabe - Widget-Tests: Gruppierung, Besitzer-Rechte, Umbenennen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
6.7 KiB
Dart
189 lines
6.7 KiB
Dart
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 ID des **aktiven** Haushalts (aus users/{uid}.householdId).
|
||
///
|
||
/// Man kann Mitglied mehrerer Haushalte sein ([myHouseholdsProvider]);
|
||
/// dieser Zeiger bestimmt, welcher davon gerade in der App angezeigt wird.
|
||
/// null, solange niemand angemeldet ist oder das Profil noch lädt.
|
||
/// Alle Daten-Provider (Pflanzen, Stellplätze) hängen hieran.
|
||
final householdIdProvider = StreamProvider<String?>((ref) {
|
||
final user = ref.watch(authStateProvider).value;
|
||
if (user == null) return Stream.value(null);
|
||
|
||
return ref
|
||
.watch(firestoreProvider)
|
||
.collection('users')
|
||
.doc(user.uid)
|
||
.snapshots()
|
||
.map((snapshot) => snapshot.data()?['householdId'] as String?);
|
||
});
|
||
|
||
Household _householdFromData(String id, Map<String, dynamic> data) {
|
||
final membersMap = (data['members'] as Map<String, dynamic>?) ?? {};
|
||
final memberUids = (data['memberUids'] as List<dynamic>?) ?? [];
|
||
return Household(
|
||
id: id,
|
||
name: data['name'] as String? ?? 'Haushalt',
|
||
// Alt-Haushalte ohne ownerUid: Der Ersteller steht immer an erster
|
||
// Stelle von memberUids (Beitretende werden hinten angehängt).
|
||
ownerUid: data['ownerUid'] as String? ??
|
||
(memberUids.isNotEmpty ? memberUids.first as String : ''),
|
||
members: [
|
||
for (final uid in memberUids.cast<String>())
|
||
HouseholdMember(
|
||
uid: uid,
|
||
role: ((membersMap[uid] as Map<String, dynamic>?)?['role']
|
||
as String?) ==
|
||
'sitter'
|
||
? HouseholdRole.sitter
|
||
: HouseholdRole.member,
|
||
email: (membersMap[uid] as Map<String, dynamic>?)?['email']
|
||
as String? ??
|
||
'',
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Der aktive Haushalt inkl. Mitgliedern und Rollen, live aus Firestore.
|
||
final householdProvider = StreamProvider<Household?>((ref) {
|
||
final householdId = ref.watch(householdIdProvider).value;
|
||
if (householdId == null) return Stream.value(null);
|
||
|
||
return ref
|
||
.watch(firestoreProvider)
|
||
.collection('households')
|
||
.doc(householdId)
|
||
.snapshots()
|
||
.map((snapshot) {
|
||
final data = snapshot.data();
|
||
if (data == null) return null;
|
||
return _householdFromData(snapshot.id, data);
|
||
});
|
||
});
|
||
|
||
/// Alle Haushalte, in denen der angemeldete Nutzer Mitglied ist.
|
||
///
|
||
/// Quelle der Wahrheit ist das Feld `memberUids` der Haushalte selbst –
|
||
/// wer per Einladung beitritt, bleibt in seinen bisherigen Haushalten
|
||
/// Mitglied und kann jederzeit zurückwechseln.
|
||
final myHouseholdsProvider = StreamProvider<List<Household>>((ref) {
|
||
final user = ref.watch(authStateProvider).value;
|
||
if (user == null) return Stream.value(const []);
|
||
|
||
return ref
|
||
.watch(firestoreProvider)
|
||
.collection('households')
|
||
.where('memberUids', arrayContains: user.uid)
|
||
.snapshots()
|
||
.map((snapshot) {
|
||
final households = [
|
||
for (final doc in snapshot.docs) _householdFromData(doc.id, doc.data()),
|
||
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||
return households;
|
||
});
|
||
});
|
||
|
||
/// 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._functionsGetter, this._uidGetter);
|
||
|
||
final FirebaseFirestore _firestore;
|
||
// Lazy, damit Widget-Tests ohne initialisiertes Firebase auskommen –
|
||
// FirebaseFunctions.instanceFor wirft sonst schon beim Erzeugen.
|
||
final FirebaseFunctions Function() _functionsGetter;
|
||
final String? Function() _uidGetter;
|
||
|
||
static const _codeAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||
|
||
/// 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;
|
||
}
|
||
|
||
/// Macht einen anderen Haushalt zum aktiven (users/{uid}.householdId).
|
||
/// Die Mitgliedschaft in allen anderen Haushalten bleibt bestehen.
|
||
Future<void> switchHousehold(String householdId) async {
|
||
final uid = _uidGetter();
|
||
if (uid == null) return;
|
||
await _firestore
|
||
.collection('users')
|
||
.doc(uid)
|
||
.set({'householdId': householdId}, SetOptions(merge: true));
|
||
}
|
||
|
||
/// Löst einen Code ein und wechselt in den zugehörigen Haushalt.
|
||
/// Gibt den Namen des neuen Haushalts zurück.
|
||
Future<String> joinHousehold(String code) async {
|
||
final callable = _functionsGetter().httpsCallable('joinHousehold');
|
||
final response =
|
||
await callable.call<Map<String, dynamic>>({'code': code.trim()});
|
||
return response.data['householdName'] as String? ?? 'Haushalt';
|
||
}
|
||
|
||
/// Benennt einen Haushalt um (Rules: nur volle Mitglieder, nur das
|
||
/// Namensfeld).
|
||
Future<void> renameHousehold(String householdId, String name) {
|
||
return _firestore
|
||
.collection('households')
|
||
.doc(householdId)
|
||
.update({'name': name});
|
||
}
|
||
|
||
/// Tritt aus einem Haushalt aus, dem man per Einladung beigetreten ist.
|
||
/// Läuft als Cloud Function, weil auch Sitter austreten dürfen, die laut
|
||
/// Rules das Haushalts-Dokument nicht ändern können.
|
||
Future<void> leaveHousehold(String householdId) async {
|
||
final callable = _functionsGetter().httpsCallable('leaveHousehold');
|
||
await callable.call<Map<String, dynamic>>({'householdId': householdId});
|
||
}
|
||
|
||
/// Entfernt ein Mitglied aus dem Haushalt (nur der Besitzer).
|
||
Future<void> removeMember({
|
||
required String householdId,
|
||
required String memberUid,
|
||
}) async {
|
||
final callable = _functionsGetter().httpsCallable('removeMember');
|
||
await callable.call<Map<String, dynamic>>({
|
||
'householdId': householdId,
|
||
'memberUid': memberUid,
|
||
});
|
||
}
|
||
}
|
||
|
||
final householdRepositoryProvider = Provider<HouseholdRepository>((ref) {
|
||
return HouseholdRepository(
|
||
ref.watch(firestoreProvider),
|
||
() => FirebaseFunctions.instanceFor(region: 'europe-west3'),
|
||
() => ref.read(firebaseAuthProvider).currentUser?.uid,
|
||
);
|
||
});
|