Fix: Reaktiver Haushalts-Bootstrap + BootstrapGate (Apple-Login legte keinen Haushalt an)

Der Bootstrap hing an signInWithApple und lief erst, nachdem der Router
den Nutzer schon in die App geleitet hatte — Fehler blieben unsichtbar,
der Account blieb ohne users-Doc/Haushalt und alle Writes scheiterten an
den Rules. Jetzt beobachtet householdBootstrapProvider das users-Dokument
und legt Profil + Haushalt reaktiv an (alle Login-Wege); das BootstrapGate
hält die App so lange zurück und macht Fehler mit „Erneut versuchen"
sichtbar. Bestehende kaputte Accounts heilen beim nächsten Start selbst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-07-23 17:49:49 +02:00
parent 3673256105
commit 5ed3a68e45
10 changed files with 255 additions and 22 deletions

View file

@ -48,6 +48,7 @@ Jedes Feature folgt demselben Muster (nicht jedes braucht alle Schichten):
- **Haushaltszentriert gedacht:** Pflanzen, Stellplätze und Aufgaben gehören dem *Haushalt*, nicht einem Nutzer. In Firestore wird das `households/{id}/plants/{id}` usw. — so ist V2 (Sharing) nur „weiteres Mitglied hinzufügen", keine Datenmigration.
- **Mehrere Haushalte pro Nutzer:** Quelle der Wahrheit für die Mitgliedschaft ist das Feld `memberUids` der Haushalte (`myHouseholdsProvider` fragt sie per `arrayContains` ab). `users/{uid}.householdId` ist nur noch der Zeiger auf den gerade **aktiven** Haushalt — Wechseln heißt: diesen Zeiger umstellen (Haushalts-Screen „Meine Haushalte“ oder Wechsler im Menü). Der aktive Haushalt bestimmt Pflanzen-/Stellplatz-Listen und wohin neue Pflanzen wandern; die **Tages-Checkliste und die Sammel-Push aggregieren dagegen über alle Haushalte** (Checkliste mit Zwischenüberschriften, `DueTask` trägt dafür seine Haushalts-Herkunft). Jeder Haushalt hat einen **Besitzer** (`ownerUid`): nur er entfernt Mitglieder (`removeMember`-Function), er kann nicht austreten; alle anderen können per `leaveHousehold`-Function austreten. Umbenennen dürfen volle Mitglieder direkt (Rules lassen am Haushalts-Dokument clientseitig nur noch `name` zu).
- **`null` bei `lastWatered` heißt „noch nie"** → Aufgabe ist sofort fällig. So braucht eine neu angelegte Pflanze keine Sonderbehandlung.
- **Haushalts-Bootstrap läuft reaktiv, nicht am Login-Aufruf:** Beim ersten Login (egal ob E-Mail-Registrierung, Apple oder künftig Google) müssen Profil-Dokument (`users/{uid}`) und ein eigener Haushalt angelegt werden — ohne sie lehnen die Rules jeden Schreibzugriff ab. Statt den Bootstrap an die einzelnen Login-Methoden zu hängen (fehleranfällig: der Router leitet schon bei „angemeldet“ weiter, ein Bootstrap-Fehler danach wäre unsichtbar), beobachtet `householdBootstrapProvider` (auth/data) das `users`-Dokument und legt beides an, sobald „angemeldet, aber kein Profil“ eintritt. Das `BootstrapGate` in `app.dart` zeigt so lange einen Warte-Bildschirm und macht Fehler mit „Erneut versuchen“ sichtbar.
## Übersetzungen (i18n)

View file

@ -27,24 +27,26 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si
## Offen / Nächstes
### 🔴 AKTIVER BUG (hier weitermachen): Apple-Login legt keinen Haushalt an
### 🟡 Apple-Login-Bug: Fix implementiert, wartet auf Gerätetest durch den Nutzer
**Symptom:** Nutzer hat Apple-Login vollständig konfiguriert (Apple-Portal Services-ID + Sign-in-Key, Firebase-Console Apple-Provider, Xcode „Sign in with Apple"-Capability) — **Anmeldung klappt**. Aber: Mit dem **neuen Apple-Account** schlägt das **Anlegen einer Pflanze** mit generischer Meldung „kann nicht gespeichert werden" fehl (`saveError`, aus dem `catch (_)` in `plant_form_screen.dart:154`).
**Der Bug:** Mit einem neuen Apple-Account schlug das Anlegen einer Pflanze fehl („Speichern fehlgeschlagen"). Ursache (per Code-Analyse bestätigt, Firestore-Sichtung stand aus): Der Haushalts-Bootstrap hing an `signInWithApple` **nach** `signInWithProvider` — der Router leitet aber schon bei „angemeldet" weiter (redirect prüft nur `currentUser != null`). Wirft der Bootstrap oder wird die App vorher beendet, bleibt der Nutzer dauerhaft ohne `users`-Doc + Haushalt, und die (korrekten) Rules lehnen jeden Schreibzugriff ab.
**Leithypothese (noch nicht bestätigt):** Beim Apple-Login wurde **kein Haushalt angelegt**. Mechanismus: `signInWithProvider` meldet „angemeldet" → `authStateChanges` feuert → der Router (`app_router.dart`, redirect prüft nur `currentUser != null`, **nicht** ob ein Haushalt existiert) schiebt sofort in die App. Der Bootstrap (`_bootstrapHouseholdIfNeeded`) läuft erst **danach** in `signInWithApple`. Wirft er, wird der Fehler zwar in `_signInWithApple` (login_screen) gefangen und als `authErrorAppleFailed` gezeigt — aber der Nutzer ist bereits eingeloggt und weggeroutet, sieht die Meldung also faktisch nicht. Ohne Haushalt ist `householdId` null bzw. der Haushalt fehlt in `memberUids` → jeder Schreibzugriff (Storage-Foto-Upload **und** Firestore-Plant-Write) wird von den Rules abgelehnt. **Die Rules selbst sind geprüft und korrekt** (Plant-`create` verlangt nur `isMember && roleOf=='member'`, exakt was der Bootstrap schreibt) — Problem liegt an fehlenden Bootstrap-Daten, nicht an den Rules.
**Der Fix (2026-07-23 implementiert, Option „reaktiver Bootstrap"):**
- `householdBootstrapProvider` (`lib/features/auth/data/household_bootstrap_provider.dart`): beobachtet das `users/{uid}`-Dokument; bei „angemeldet, aber kein Profil" legt er Profil + Haushalt an. Deckt **alle** Login-Wege ab (auch künftig Google).
- `BootstrapGate` (`lib/features/auth/presentation/bootstrap_gate.dart`, eingehängt in `app.dart` builder): hält die App zurück, bis das Profil existiert; zeigt Warte-Bildschirm bzw. Fehler mit „Erneut versuchen".
- `AuthRepository`: Bootstrap jetzt öffentlich (`ensureHouseholdBootstrap`, idempotent + In-Flight-Schutz); `signInWithApple` ruft ihn nicht mehr selbst auf.
- **Selbstheilend:** Der bereits kaputte Apple-Account wird beim nächsten App-Start automatisch repariert (Gate sieht „kein Profil" → legt Haushalt an).
- Analyzer grün, alle 9 Tests grün (neuer Test: „Erster Login ohne Profil → Haushalt wird reaktiv angelegt").
**Warteschritt (auf Nutzer-Antwort, dann entscheiden):** Nutzer sollte prüfen und melden:
- **A)** App → Menü → Haushalt: wird für den Apple-Account ein Haushalt angezeigt (Mitglied/Besitzer) oder leer?
- **B)** Firebase-Konsole → Firestore: existiert `users/{uid}` für den Apple-Account mit Feld `householdId`? Falls ja, hat `households/{id}.memberUids` die UID + `members`-Map mit `role:"member"`?
- **C)** Hatte die Pflanze ein **Foto** (trennt Storage- von Firestore-Fehler)?
**Wenn Hypothese bestätigt (Haushalt fehlt) → Fix-Richtung:** Bootstrap **race-/schluck-sicher** machen, statt ihn nur an `signInWithApple` zu hängen. Optionen: (a) reaktiver Bootstrap — ein Provider/Guard, der bei „eingeloggt aber kein `users`-Doc" den Haushalt anlegt und bis dahin einen Lade-/Redirect-Zustand zeigt (deckt auch künftige Provider ab); oder (b) Router-Redirect zusätzlich an „Haushalt vorhanden" koppeln + Bootstrap-Fehler sichtbar machen. Vorschlag (a) bevorzugen. Danach mit frischem Apple-Account (unter <https://appleid.apple.com> Freigabe für „LeafItToMe" entfernen → nächster Login ist wieder „erster Login") Ende-zu-Ende testen.
**Wenn Haushalt existiert (Hypothese falsch):** dann liegt es doch an Rules/IAM — Storage-`isMember` macht `firestore.get()` (braucht das bereits erteilte IAM-Recht) bzw. Firestore-Plant-`create`. Dann konkret den fehlschlagenden Schritt (Storage vs. Firestore, via Frage C) isolieren.
**Was der Nutzer jetzt testen sollte:**
1. App auf dem iPhone neu bauen/starten, mit dem **bestehenden Apple-Account** anmelden → kurzer „Dein Haushalt wird eingerichtet …"-Bildschirm, danach sollte Pflanze-Anlegen (mit Foto!) funktionieren.
2. Optional Härtetest „echter Erst-Login": unter <https://appleid.apple.com> die Freigabe für „LeafItToMe" entfernen → nächster Login ist wieder „erster Login" → Ende-zu-Ende inkl. Pflanze mit Foto.
3. Falls es **weiter** fehlschlägt, obwohl der Haushalt angezeigt wird (Menü → Haushalt): dann liegt es doch an Storage-Rules/IAM — dann isolieren, ob Foto-Upload (Storage) oder Pflanze-Schreiben (Firestore) scheitert.
### Zuletzt erledigt
- **2026-07-23:** Apple-Login **implementiert und committet** (`signInWithApple` via eingebautem `AppleAuthProvider`, „oder"-Trenner + Button im Login, iOS-Entitlement `com.apple.developer.applesignin`, l10n-Strings, Doku `firebase-einrichtung.md` Schritt 7). Analyzer + alle 8 Tests grün. **Konfiguration vom Nutzer erledigt, Login funktioniert — aber der obige Bug ist offen.**
- **2026-07-23 (2):** Reaktiver Haushalts-Bootstrap + BootstrapGate implementiert (Fix für den Apple-Login-Bug, siehe oben); Architektur-Doku um die Entscheidung ergänzt.
- **2026-07-23 (1):** Apple-Login **implementiert und committet** (`signInWithApple` via eingebautem `AppleAuthProvider`, „oder"-Trenner + Button im Login, iOS-Entitlement `com.apple.developer.applesignin`, l10n-Strings, Doku `firebase-einrichtung.md` Schritt 7). Konfiguration vom Nutzer erledigt, Login funktioniert.
- **2026-07-20:** iOS-Push eingerichtet (Apple Developer Account freigeschaltet, APNs/Firebase/Xcode). V1 damit auch auf iOS vollständig.
### Weitere Kandidaten (nach dem Bug)

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/auth/presentation/bootstrap_gate.dart';
import 'features/household/data/household_providers.dart';
import 'features/notifications/data/push_registration_service.dart';
import 'l10n/generated/app_localizations.dart';
@ -45,7 +46,9 @@ class LeafItToMeApp extends ConsumerWidget {
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(systemScale * settings.textSize.factor),
),
child: child!,
// Lässt frisch angemeldete Nutzer erst in die App, wenn Profil +
// Haushalt existieren (legt beides bei Bedarf an).
child: BootstrapGate(child: child!),
);
},
);

View file

@ -21,26 +21,38 @@ class AuthRepository {
email: email,
password: password,
);
await _bootstrapHouseholdIfNeeded(credential.user!.uid, email);
await ensureHouseholdBootstrap(credential.user!.uid, email);
}
/// Anmeldung mit Apple. Nativer Dialog auf iOS, Web-Flow (über den
/// Firebase-Auth-Handler) auf Android beides über den eingebauten
/// [AppleAuthProvider], daher ohne Zusatz-Paket. Beim **ersten** Login
/// existiert noch kein Firestore-Profil, deshalb hängt der Haushalts-
/// Bootstrap hier dran (Apple legt nur den Auth-Nutzer an).
/// [AppleAuthProvider], daher ohne Zusatz-Paket.
///
/// Der Haushalts-Bootstrap hängt bewusst NICHT mehr hier dran: sobald
/// signInWithProvider zurückkehrt, feuert authStateChanges und der Router
/// leitet in die App weiter ein danach geworfener Bootstrap-Fehler wäre
/// auf dem Login-Screen unsichtbar und ließe den Nutzer ohne Haushalt
/// zurück. Stattdessen übernimmt der reaktive householdBootstrapProvider.
Future<void> signInWithApple() async {
final provider = AppleAuthProvider()
..addScope('email')
..addScope('name');
final credential = await _auth.signInWithProvider(provider);
final user = credential.user!;
await _bootstrapHouseholdIfNeeded(user.uid, user.email);
await _auth.signInWithProvider(provider);
}
Future<void>? _bootstrapInFlight;
/// Legt Profil-Dokument und einen eigenen Haushalt an, falls für [uid]
/// noch keins existiert. Idempotent mehrfaches Anmelden mit Apple oder
/// eine erneute Registrierung überschreibt einen bestehenden Haushalt nie.
/// noch keins existiert. Idempotent und gegen parallele Aufrufe geschützt
/// (Registrierung und reaktiver Bootstrap können sich überlappen)
/// ein bestehender Haushalt wird nie überschrieben.
Future<void> ensureHouseholdBootstrap(String uid, String? email) {
return _bootstrapInFlight ??=
_bootstrapHouseholdIfNeeded(uid, email).whenComplete(() {
_bootstrapInFlight = null;
});
}
/// [email] kann bei E-Mail verbergen die Apple-Relay-Adresse sein.
Future<void> _bootstrapHouseholdIfNeeded(String uid, String? email) async {
final userRef = _firestore.collection('users').doc(uid);

View file

@ -0,0 +1,71 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/firebase/firebase_providers.dart';
import 'auth_repository.dart';
/// users/{uid}-Dokument des angemeldeten Nutzers, live (null = ausgeloggt).
///
/// Existiert das Dokument nicht (erster Login), stößt
/// [householdBootstrapProvider] den Haushalts-Bootstrap an.
final userProfileProvider =
StreamProvider<DocumentSnapshot<Map<String, dynamic>>?>((ref) {
final user = ref.watch(authStateProvider).value;
if (user == null) return Stream.value(null);
return ref
.watch(firestoreProvider)
.collection('users')
.doc(user.uid)
.snapshots();
});
/// Reaktiver Haushalts-Bootstrap: legt Profil + eigenen Haushalt an, sobald
/// ein Nutzer angemeldet ist, für den noch kein users-Dokument existiert.
///
/// Deckt alle Login-Wege ab (E-Mail-Registrierung, Apple, künftig Google)
/// unabhängig davon, wann der Router weiterleitet. Das BootstrapGate hält
/// die App zurück, bis das Profil da ist, und zeigt Fehler samt
/// Erneut versuchen an.
class HouseholdBootstrap extends Notifier<AsyncValue<void>> {
@override
AsyncValue<void> build() {
final user = ref.watch(authStateProvider).value;
final profile = ref.watch(userProfileProvider).value;
// Nur aktiv, wenn der Snapshot sicher zum aktuellen Nutzer gehört und
// das Profil fehlt bei Kontowechsel hält .value kurz den alten Stand.
if (user == null ||
profile == null ||
profile.id != user.uid ||
profile.exists) {
return const AsyncValue.data(null);
}
_run(user);
return const AsyncValue.loading();
}
Future<void> _run(User user) async {
try {
await ref
.read(authRepositoryProvider)
.ensureHouseholdBootstrap(user.uid, user.email);
// Kein Erfolgs-State nötig: das neue users-Dokument kommt über den
// Snapshot-Stream herein und baut diesen Notifier als fertig neu.
} catch (error, stackTrace) {
if (!ref.mounted) return;
state = AsyncValue.error(error, stackTrace);
}
}
/// Nach einem Fehler erneut versuchen (Knopf im BootstrapGate).
void retry() {
final user = ref.read(authStateProvider).value;
if (user == null) return;
state = const AsyncValue.loading();
_run(user);
}
}
final householdBootstrapProvider =
NotifierProvider<HouseholdBootstrap, AsyncValue<void>>(
HouseholdBootstrap.new);

View file

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/firebase/firebase_providers.dart';
import '../../../l10n/generated/app_localizations.dart';
import '../data/household_bootstrap_provider.dart';
/// Hält die App zurück, solange zum angemeldeten Nutzer noch kein
/// users-Dokument (und damit kein Haushalt) existiert.
///
/// Beim ersten Login (z. B. mit Apple) legt [householdBootstrapProvider]
/// beides an; bis dahin gibt es einen Warte-Bildschirm. Schlägt der
/// Bootstrap fehl, wird der Fehler hier sichtbar vorher konnte man in
/// der App landen, deren Schreibzugriffe dann alle an den Rules scheiterten.
class BootstrapGate extends ConsumerWidget {
const BootstrapGate({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authStateProvider).value;
if (user == null) return child; // Login-Screen übernimmt.
final profile = ref.watch(userProfileProvider).value;
final profileReady =
profile != null && profile.id == user.uid && profile.exists;
if (profileReady) return child;
final bootstrap = ref.watch(householdBootstrapProvider);
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold(
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: bootstrap.hasError
? Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off,
size: 48, color: theme.colorScheme.error),
const SizedBox(height: 16),
Text(l10n.bootstrapFailed, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref
.read(householdBootstrapProvider.notifier)
.retry(),
child: Text(l10n.bootstrapRetry),
),
],
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(l10n.bootstrapPreparing,
textAlign: TextAlign.center),
],
),
),
),
),
);
}
}

View file

@ -212,6 +212,9 @@
"authErrorWeakPassword": "Das Passwort muss mindestens 6 Zeichen lang sein.",
"authErrorInvalidEmail": "Bitte gib eine gültige E-Mail-Adresse ein.",
"authErrorGeneric": "Das hat leider nicht geklappt. Bitte versuche es erneut.",
"bootstrapPreparing": "Dein Haushalt wird eingerichtet …",
"bootstrapFailed": "Dein Haushalt konnte nicht eingerichtet werden. Bitte prüfe deine Internetverbindung und versuche es erneut.",
"bootstrapRetry": "Erneut versuchen",
"takePhoto": "Foto aufnehmen",
"fromGallery": "Aus der Galerie",
"recognizing": "Pflanze wird erkannt …",

View file

@ -766,6 +766,24 @@ abstract class AppLocalizations {
/// **'Das hat leider nicht geklappt. Bitte versuche es erneut.'**
String get authErrorGeneric;
/// No description provided for @bootstrapPreparing.
///
/// In de, this message translates to:
/// **'Dein Haushalt wird eingerichtet …'**
String get bootstrapPreparing;
/// No description provided for @bootstrapFailed.
///
/// In de, this message translates to:
/// **'Dein Haushalt konnte nicht eingerichtet werden. Bitte prüfe deine Internetverbindung und versuche es erneut.'**
String get bootstrapFailed;
/// No description provided for @bootstrapRetry.
///
/// In de, this message translates to:
/// **'Erneut versuchen'**
String get bootstrapRetry;
/// No description provided for @takePhoto.
///
/// In de, this message translates to:

View file

@ -414,6 +414,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get authErrorGeneric =>
'Das hat leider nicht geklappt. Bitte versuche es erneut.';
@override
String get bootstrapPreparing => 'Dein Haushalt wird eingerichtet …';
@override
String get bootstrapFailed =>
'Dein Haushalt konnte nicht eingerichtet werden. Bitte prüfe deine Internetverbindung und versuche es erneut.';
@override
String get bootstrapRetry => 'Erneut versuchen';
@override
String get takePhoto => 'Foto aufnehmen';

View file

@ -255,6 +255,50 @@ void main() {
expect((members?['u1'] as Map<String, dynamic>?)?['role'], 'member');
});
testWidgets(
'Erster Login ohne Profil (z. B. Apple): Haushalt wird reaktiv angelegt',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
// Angemeldet (wie nach signInWithProvider), aber Firestore ist leer
// genau der Zustand, in dem Pflanzen-Speichern an den Rules scheiterte.
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'apple1', email: 'apple@example.com'),
);
final firestore = FakeFirebaseFirestore();
await tester.pumpWidget(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
));
await tester.pumpAndSettle();
// Das BootstrapGate hat Profil + Haushalt angelegt
final userDoc = await firestore.collection('users').doc('apple1').get();
expect(userDoc.exists, isTrue);
final householdId = userDoc.data()?['householdId'] as String?;
expect(householdId, isNotNull);
final household =
await firestore.collection('households').doc(householdId).get();
expect(household.data()?['ownerUid'], 'apple1');
expect(household.data()?['memberUids'], ['apple1']);
expect(
((household.data()?['members'] as Map<String, dynamic>)['apple1']
as Map<String, dynamic>)['role'],
'member');
// und die App ist normal nutzbar (Heute-Screen, keine Aufgaben).
expect(find.text('Heute'), findsOneWidget);
expect(find.text('Alles versorgt!'), findsOneWidget);
});
testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async {
await tester.pumpWidget(await buildTestApp());
await tester.pumpAndSettle();