Haushalte aus der V1-Zeit haben keinen members-Eintrag für ihren Ersteller, die Besitzer-Anzeige blieb dadurch leer. Die App trägt die eigene E-Mail jetzt beim Start in allen eigenen Haushalten nach; die Rules erlauben dafür nur den eigenen members-Eintrag ohne Rollenwechsel. Bis dahin zeigt die UI „?" statt einer leeren Stelle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
220 lines
8 KiB
Dart
220 lines
8 KiB
Dart
import 'dart:math';
|
||
|
||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||
import 'package:cloud_functions/cloud_functions.dart';
|
||
import 'package:flutter/foundation.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;
|
||
});
|
||
});
|
||
|
||
/// Trägt die eigene E-Mail in der members-Map aller eigenen Haushalte nach.
|
||
///
|
||
/// Haushalte aus der V1-Zeit haben für ihren Ersteller keinen members-Eintrag
|
||
/// (die Map kam erst mit V2) – dadurch blieb die Besitzer-Anzeige leer.
|
||
/// Läuft beim App-Start mit, sobald die Haushalte geladen sind; die Rules
|
||
/// erlauben jedem genau die Pflege des eigenen Eintrags (ohne Rollenwechsel).
|
||
/// Wird in app.dart per ref.watch aktiv gehalten.
|
||
final memberEntrySyncProvider = Provider<void>((ref) {
|
||
final user = ref.watch(authStateProvider).value;
|
||
final email = user?.email;
|
||
if (user == null || email == null || email.isEmpty) return;
|
||
|
||
final households = ref.watch(myHouseholdsProvider).value ?? const [];
|
||
for (final household in households) {
|
||
final me = household.memberByUid(user.uid);
|
||
if (me != null && me.email.isNotEmpty) continue;
|
||
ref
|
||
.read(firestoreProvider)
|
||
.collection('households')
|
||
.doc(household.id)
|
||
.update({
|
||
'members.${user.uid}.email': email,
|
||
'members.${user.uid}.role': (me?.role ?? HouseholdRole.member).name,
|
||
}).catchError((Object error) {
|
||
// Nur loggen – eine fehlende Anzeige darf die App nicht stören.
|
||
debugPrint('E-Mail-Nachtrag für ${household.id} fehlgeschlagen: $error');
|
||
});
|
||
}
|
||
});
|
||
|
||
/// 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,
|
||
);
|
||
});
|