Fix: Besitzer-Anzeige in Alt-Haushalten – eigene E-Mail wird nachgetragen

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>
This commit is contained in:
cschlaefke 2026-07-20 00:50:22 +02:00
parent ab0e47ff61
commit edc4ecf405
7 changed files with 106 additions and 9 deletions

View file

@ -96,6 +96,8 @@ invites/{code} → Einladung: householdId, Rolle; nur Erstelle
Beide biegen, falls nötig, den aktiven Zeiger (`users/{uid}.householdId`) des Betroffenen auf einen seiner übrigen Haushalte um; bleibt keiner übrig, legen sie ihm einen frischen eigenen Haushalt an — so steht die App nie ohne Haushalt da.
**E-Mail-Nachtrag für Alt-Haushalte:** Haushalte aus der V1-Zeit haben für ihren Ersteller keinen Eintrag in der `members`-Map (die kam erst mit V2) — die Besitzer-Anzeige blieb dadurch leer. Die App trägt deshalb beim Start die eigene E-Mail in allen eigenen Haushalten nach (`memberEntrySyncProvider`). Die Rules erlauben dafür eine eng begrenzte Ausnahme: Jedes Mitglied (auch Sitter) darf **genau seinen eigenen** `members`-Eintrag ändern, die eigene **Rolle aber nicht** — sonst könnte sich ein Sitter selbst zum Mitglied befördern. Bis der Betroffene seine App einmal geöffnet hat, zeigen andere Geräte „?“ statt der E-Mail.
Die Regeln in `firestore.rules` setzen das Haushalts-Prinzip durch:
- Dein **Nutzerprofil** (`users/{uid}`) kannst nur du selbst lesen/schreiben.

View file

@ -24,7 +24,7 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si
## Offen / Nächstes
1. V2.2 vom Nutzer testen lassen (Umbenennen, Austreten, Entfernen, Drawer-Wechsler, gruppierte Checkliste); Functions- und Rules-Deploy nötig.
1. V2.2 vom Nutzer weitgehend getestet und für gut befunden. Nachgebessert: (a) Invoker-Berechtigung fehlte bei den frisch angelegten Functions `leaveHousehold`/`removeMember` (Cloud-Run-401; Fix = gezielter Neu-Deploy durch den Nutzer), (b) Besitzer-Anzeige blieb bei Alt-Haushalten leer → E-Mail-Nachtrag beim App-Start (`memberEntrySyncProvider`) + Rules-Ausnahme für den eigenen members-Eintrag. **Rules-Deploy für (b) noch nötig**, danach Besitzer-Anzeige auf beiden Geräten prüfen (beide Apps einmal öffnen).
2. iOS-Push, sobald Apple Developer Account existiert.
3. **V3:** Krankheits-Diagnose per Foto, Stellplatz-Bewertung (Standort-Analyse pro Stellplatz + Eignungs-Sterne pro Pflanze — Zwei-Ebenen-Konzept siehe Memory), Umtopf-Erinnerungen.
4. **Backlog:** Button „KI-Zweitmeinung" bei unbefriedigendem PlantNet-Ergebnis; Push-Anzeige auch bei App im Vordergrund; App Check vor App-Store-Release; Englisch (`app_en.arb`).

View file

@ -51,13 +51,29 @@ service cloud.firestore {
// Vom Client aus dürfen volle Mitglieder nur den Namen ändern;
// jede Mitglieder-Verwaltung (Beitritt, Austritt, Entfernen) läuft
// ausschließlich über Cloud Functions mit Admin-Rechten.
// Ausnahme: Jeder darf seinen EIGENEN members-Eintrag pflegen (die App
// trägt so die E-Mail in Alt-Haushalten nach), die Rolle aber nicht
// ändern sonst könnte sich ein Sitter selbst befördern.
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'
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['name']);
&& (
(resource.data.get('members', {})
.get(request.auth.uid, {'role': 'member'})
.get('role', 'member') == 'member'
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['name']))
|| (request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['members'])
&& request.resource.data.members
.diff(resource.data.get('members', {}))
.affectedKeys()
.hasOnly([request.auth.uid])
&& request.resource.data.members[request.auth.uid]
.get('role', 'member')
== resource.data.get('members', {})
.get(request.auth.uid, {'role': 'member'})
.get('role', 'member'))
);
// Anlegen nur, wenn man sich selbst als Mitglied einträgt
// (passiert automatisch bei der Registrierung).
allow create: if signedIn()

View file

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart';
import 'core/settings/settings_provider.dart';
import 'core/theme/app_theme.dart';
import 'features/household/data/household_providers.dart';
import 'features/notifications/data/push_registration_service.dart';
import 'l10n/generated/app_localizations.dart';
@ -19,6 +20,9 @@ class LeafItToMeApp extends ConsumerWidget {
// Nutzer angemeldet ist (auch wenn der Login vor dem App-Start bestand).
ref.watch(pushSetupProvider);
// Trägt die eigene E-Mail in Alt-Haushalten nach (Besitzer-Anzeige).
ref.watch(memberEntrySyncProvider);
return MaterialApp.router(
routerConfig: router,
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,

View file

@ -2,6 +2,7 @@ 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';
@ -91,6 +92,36 @@ final myHouseholdsProvider = StreamProvider<List<Household>>((ref) {
});
});
/// 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;

View file

@ -77,6 +77,11 @@ class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
);
}
/// E-Mail mit Fallback: Alt-Einträge ohne E-Mail zeigen ?, bis die App
/// des Betroffenen sie nachgetragen hat (memberEntrySyncProvider).
String _labelFor(String? email) =>
(email == null || email.isEmpty) ? '?' : email;
Future<void> _switchTo(Household target) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
@ -277,8 +282,8 @@ class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
subtitle: Text(
myUid != null && entry.isOwner(myUid)
? l10n.ownerIsMe
: l10n.ownerIs(
entry.memberByUid(entry.ownerUid)?.email ?? '?'),
: l10n.ownerIs(_labelFor(
entry.memberByUid(entry.ownerUid)?.email)),
),
trailing: entry.id == household?.id
? Chip(label: Text(l10n.activeHouseholdLabel))
@ -303,7 +308,7 @@ class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
title: Text(
member.uid == myUid && member.email.isEmpty
? l10n.meLabel
: member.email,
: _labelFor(member.email),
),
subtitle: Text([
member.role == HouseholdRole.sitter

View file

@ -216,6 +216,45 @@ void main() {
expect(find.text('Mitglied entfernen?'), findsOneWidget);
});
testWidgets(
'Alt-Haushalt ohne members-Eintrag: eigene E-Mail wird nachgetragen',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'u1', email: 'test@example.com'),
);
final firestore = FakeFirebaseFirestore();
await firestore.collection('users').doc('u1').set({
'email': 'test@example.com',
'householdId': 'h1',
});
// V1-Zeit: kein ownerUid, keine members-Map nur memberUids.
await firestore.collection('households').doc('h1').set({
'name': 'Mein Haushalt',
'memberUids': ['u1'],
});
await tester.pumpWidget(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
));
await tester.pumpAndSettle();
final doc = await firestore.collection('households').doc('h1').get();
final members = doc.data()?['members'] as Map<String, dynamic>?;
expect((members?['u1'] as Map<String, dynamic>?)?['email'],
'test@example.com');
expect((members?['u1'] as Map<String, dynamic>?)?['role'], 'member');
});
testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async {
await tester.pumpWidget(await buildTestApp());
await tester.pumpAndSettle();