From 53ee30c5987546e798b63ba47c7b83a38a692e50 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Sat, 18 Jul 2026 22:53:09 +0200 Subject: [PATCH] Firestore und Auth: echte Daten statt Demo, Login mit E-Mail+Passwort, Security Rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Datenmodell: users/{uid}, households/{id} mit memberUids, plants/locations als Subcollections - StreamProvider liefern Live-Daten, Repositories kapseln Schreibzugriffe - Login-Screen mit Registrierung (legt Haushalt automatisch an), Logout in Einstellungen - Router-Redirect bei Login/Logout, firestore.rules mit Haushalts-Prinzip - Tests auf firebase_auth_mocks + fake_cloud_firestore umgestellt - Verifiziert: analyze/test grün, Android-Debug-Build erfolgreich Co-Authored-By: Claude Fable 5 --- docs/architektur.md | 3 +- docs/firebase-einrichtung.md | 38 +++- firebase.json | 35 +++- firestore.indexes.json | 4 + firestore.rules | 41 ++++ lib/core/firebase/firebase_providers.dart | 16 ++ lib/core/router/app_router.dart | 42 +++- lib/features/auth/data/auth_repository.dart | 52 +++++ .../auth/presentation/login_screen.dart | 174 +++++++++++++++++ .../household/data/household_providers.dart | 19 ++ .../locations/data/locations_provider.dart | 67 +++++-- .../presentation/locations_screen.dart | 48 +++-- lib/features/plants/data/plants_provider.dart | 183 +++++++++--------- .../presentation/plant_detail_screen.dart | 2 +- .../presentation/plant_form_screen.dart | 12 +- .../plants/presentation/plants_screen.dart | 23 ++- .../presentation/settings_screen.dart | 14 ++ .../today/application/due_tasks_provider.dart | 4 +- .../today/presentation/today_screen.dart | 14 +- lib/l10n/app_de.arb | 17 +- lib/l10n/generated/app_localizations.dart | 86 +++++++- lib/l10n/generated/app_localizations_de.dart | 49 ++++- pubspec.lock | 168 ++++++++++++++++ pubspec.yaml | 4 + test/widget_test.dart | 63 +++++- 25 files changed, 1011 insertions(+), 167 deletions(-) create mode 100644 firestore.indexes.json create mode 100644 firestore.rules create mode 100644 lib/core/firebase/firebase_providers.dart create mode 100644 lib/features/auth/data/auth_repository.dart create mode 100644 lib/features/auth/presentation/login_screen.dart create mode 100644 lib/features/household/data/household_providers.dart diff --git a/docs/architektur.md b/docs/architektur.md index 2f1b43a..bbef1fc 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -14,6 +14,7 @@ lib/ │ ├── theme/app_theme.dart # Farbschema und Bedienbarkeits-Anpassungen │ └── widgets/app_drawer.dart# Das Hamburger-Menü ├── features/ # Ein Ordner pro Fachlichkeit +│ ├── auth/ # Login/Registrierung + Haushalts-Bootstrap │ ├── today/ # Tages-Checkliste (Start-Screen) │ ├── plants/ # Pflanzen: Liste, Profil, Anlegen/Bearbeiten │ ├── locations/ # Stellplätze @@ -29,7 +30,7 @@ lib/ Jedes Feature folgt demselben Muster (nicht jedes braucht alle Schichten): - **`domain/`** — Die fachlichen Modelle (`Plant`, `DueTask`, `PlantLocation`). Reines Dart, keine Flutter- oder Firebase-Abhängigkeiten. Hier steckt die Fachlogik (z. B. „Fälligkeit = letzte Erledigung + Intervall"). -- **`data/`** — Datenhaltung. Aktuell In-Memory-Notifier mit Demo-Daten; im Firebase-Block werden sie durch Firestore-Anbindungen ersetzt. **Wichtig:** Die Provider-Schnittstelle nach außen bleibt gleich — die Screens merken vom Austausch nichts. +- **`data/`** — Datenhaltung: `StreamProvider` liefern die Live-Daten aus Firestore (Echtzeit-Sync im Haushalt), `Repository`-Klassen kapseln die Schreibzugriffe (anlegen, ändern, bestätigen). In Tests werden Auth und Firestore per Provider-Override durch Mocks ersetzt (`firebase_auth_mocks`, `fake_cloud_firestore`). - **`application/`** — Abgeleitete Zustände / Anwendungslogik, z. B. `dueTasksProvider`, der aus den Pflanzen die heute fälligen Aufgaben berechnet. - **`presentation/`** — Die Screens und Widgets. diff --git a/docs/firebase-einrichtung.md b/docs/firebase-einrichtung.md index f48e642..c5fbf8f 100644 --- a/docs/firebase-einrichtung.md +++ b/docs/firebase-einrichtung.md @@ -2,7 +2,7 @@ Firebase ist Googles „Backend als Dienst": Es liefert uns Login (**Auth**), Datenbank mit Echtzeit-Sync (**Firestore**), Foto-Speicher (**Storage**), serverseitigen Code (**Cloud Functions**) und Push-Nachrichten (**FCM**) — ohne dass wir einen eigenen Server betreiben. -> **Stand:** Schritt 1–3 sind erledigt (18.07.2026): Projekt `leaf-it-to-me-app` existiert, die iOS- und Android-App sind registriert, `lib/firebase_options.dart` ist erzeugt und Firebase wird beim App-Start initialisiert. Die App-Daten (Pflanzen etc.) laufen noch über Demo-Daten — Firestore folgt im nächsten Block. Dieses Dokument wird mit jedem Baustein erweitert. +> **Stand:** Schritt 1–3 erledigt, Blaze aktiv (18.07.2026). Der Code für Firestore (Pflanzen, Stellplätze, Haushalte) und Login (E-Mail+Passwort) ist eingebaut — **offen ist Schritt 4** (Firestore-Datenbank anlegen + E-Mail-Login aktivieren, machst du in der Console) und danach das Rules-Deployment. Dieses Dokument wird mit jedem Baustein erweitert. ## Was wofür? (Überblick) @@ -59,12 +59,42 @@ flutterfire configure - In den Android-Gradle-Dateien wurde das `google-services`-Plugin eingetragen (verarbeitet die JSON-Datei beim Build). - `lib/main.dart` ruft beim Start `Firebase.initializeApp(...)` auf — ab jetzt können wir nach und nach Auth, Firestore, Storage und FCM andocken. -## Schritt 4 und folgende (kommen mit den nächsten Blöcken) +## Schritt 4: Firestore und Login aktivieren (machst du, ~5 Minuten) -- **Firestore-Datenmodell + Sicherheitsregeln** — wer darf was lesen/schreiben (Haushalts-Mitgliedschaft), Doku folgt beim Firestore-Block. -- **Auth einrichten** — E-Mail+Passwort, Google, Apple (Apple-Login braucht den Apple Developer Account). +Der App-Code für Datenbank und Login ist fertig — zwei Schalter musst du in der Console einmalig umlegen: + +1. **Firestore-Datenbank anlegen:** Firebase Console → *Build → Firestore Database* → **„Datenbank erstellen"** → Standort **`eur3` (Europa)** wählen → **Produktionsmodus** (die strengen Startregeln werden gleich durch unsere eigenen ersetzt). Das aktiviert auch die Firestore-API des Projekts. +2. **E-Mail/Passwort-Login aktivieren:** Firebase Console → *Build → Authentication* → **„Jetzt starten"** → Tab *Sign-in method* → **E-Mail/Passwort** aktivieren. (Google- und Apple-Login rüsten wir in einem späteren Block nach — Apple braucht den Developer Account.) + +Danach werden die Sicherheitsregeln aus dem Repo deployt (macht Claude per CLI): + +```bash +firebase deploy --only firestore --project leaf-it-to-me-app +``` + +## Das Datenmodell und die Sicherheitsregeln (zum Verständnis) + +``` +users/{uid} → E-Mail, householdId (nur der Nutzer selbst) +households/{id} → Name, memberUids [Liste der Mitglieder] +households/{id}/plants/{id} → Pflanze: Art, Intervalle, lastWatered, ... +households/{id}/locations/{id} → Stellplatz: Name +``` + +Die Regeln in `firestore.rules` setzen das Haushalts-Prinzip durch: + +- Dein **Nutzerprofil** (`users/{uid}`) kannst nur du selbst lesen/schreiben. +- Einen **Haushalt** sieht und ändert nur, wer in dessen `memberUids` steht. Anlegen darf man einen Haushalt nur, wenn man sich dabei selbst als Mitglied einträgt (passiert automatisch bei der Registrierung). +- **Alle Unterdaten** (Pflanzen, Stellplätze) erben diese Regel: voller Zugriff für Mitglieder, für niemanden sonst. V2 (Sitter einladen) wird damit nur „weitere UID in `memberUids` aufnehmen". + +Wichtig zu verstehen: Die App spricht direkt mit Firestore — die Regeln laufen **auf Googles Servern** und sind die eigentliche Zugriffskontrolle. Selbst eine manipulierte App könnte fremde Haushalte nicht lesen. + +## Schritt 5 und folgende (kommen mit den nächsten Blöcken) + +- **Storage** — Pflanzen-Fotos speichern (kommt mit dem Foto-/KI-Block). - **Cloud Functions** — PlantNet-/Claude-Anbindung und die geplante Erinnerungs-Function (läuft alle 15 Minuten). - **FCM** — Push-Einrichtung inkl. APNs-Schlüssel für iOS (aus dem Apple Developer Account). +- **Google-/Apple-Login** — zusätzlich zu E-Mail/Passwort. ## Begriffe kurz erklärt diff --git a/firebase.json b/firebase.json index 5067faa..1525815 100644 --- a/firebase.json +++ b/firebase.json @@ -1 +1,34 @@ -{"flutter":{"platforms":{"android":{"default":{"projectId":"leaf-it-to-me-app","appId":"1:724282636354:android:db0671eaf1218c414195db","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"leaf-it-to-me-app","appId":"1:724282636354:ios:7c2855f20b2ba5ba4195db","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"leaf-it-to-me-app","configurations":{"android":"1:724282636354:android:db0671eaf1218c414195db","ios":"1:724282636354:ios:7c2855f20b2ba5ba4195db"}}}}}} \ No newline at end of file +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "flutter": { + "platforms": { + "android": { + "default": { + "projectId": "leaf-it-to-me-app", + "appId": "1:724282636354:android:db0671eaf1218c414195db", + "fileOutput": "android/app/google-services.json" + } + }, + "ios": { + "default": { + "projectId": "leaf-it-to-me-app", + "appId": "1:724282636354:ios:7c2855f20b2ba5ba4195db", + "uploadDebugSymbols": false, + "fileOutput": "ios/Runner/GoogleService-Info.plist" + } + }, + "dart": { + "lib/firebase_options.dart": { + "projectId": "leaf-it-to-me-app", + "configurations": { + "android": "1:724282636354:android:db0671eaf1218c414195db", + "ios": "1:724282636354:ios:7c2855f20b2ba5ba4195db" + } + } + } + } + } +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..415027e --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..60e2ee1 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,41 @@ +rules_version = '2'; + +// Sicherheitsregeln für LeafItToMe. +// +// Grundprinzip: Alle App-Daten hängen an einem Haushalt. Lesen und Schreiben +// darf nur, wer im Feld `memberUids` des Haushalts steht. Das eigene +// Nutzer-Dokument (users/{uid}) darf nur der Nutzer selbst sehen. +service cloud.firestore { + match /databases/{database}/documents { + + function signedIn() { + return request.auth != null; + } + + function isMember(householdId) { + return signedIn() + && request.auth.uid in get(/databases/$(database)/documents/households/$(householdId)).data.memberUids; + } + + // Eigenes Nutzerprofil (enthält u. a. die householdId). + match /users/{uid} { + allow read, write: if signedIn() && request.auth.uid == uid; + } + + match /households/{householdId} { + // Lesen/Ändern nur für Mitglieder. + allow read, update: if signedIn() && request.auth.uid in resource.data.memberUids; + // Anlegen nur, wenn man sich selbst als Mitglied einträgt + // (passiert automatisch bei der Registrierung). + allow create: if signedIn() && request.auth.uid in request.resource.data.memberUids; + // Löschen von Haushalten ist bewusst nicht erlaubt. + allow delete: if false; + + // Alle Unterdaten des Haushalts (plants, locations, ...): + // voller Zugriff für Mitglieder, niemand sonst. + match /{document=**} { + allow read, write: if isMember(householdId); + } + } + } +} diff --git a/lib/core/firebase/firebase_providers.dart b/lib/core/firebase/firebase_providers.dart new file mode 100644 index 0000000..49d6a33 --- /dev/null +++ b/lib/core/firebase/firebase_providers.dart @@ -0,0 +1,16 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Zentrale Zugriffe auf die Firebase-Dienste. +/// +/// Screens und Repositories nutzen ausschließlich diese Provider — +/// Tests überschreiben sie mit Mocks (MockFirebaseAuth, FakeFirebaseFirestore). +final firebaseAuthProvider = Provider((ref) => FirebaseAuth.instance); + +final firestoreProvider = Provider((ref) => FirebaseFirestore.instance); + +/// Der aktuell angemeldete Nutzer (null = ausgeloggt), live aktualisiert. +final authStateProvider = StreamProvider((ref) { + return ref.watch(firebaseAuthProvider).authStateChanges(); +}); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1431a42..ed24662 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../features/auth/presentation/login_screen.dart'; import '../../features/household/presentation/household_screen.dart'; import '../../features/locations/presentation/locations_screen.dart'; import '../../features/plants/domain/plant.dart'; @@ -9,11 +13,13 @@ import '../../features/plants/presentation/plant_form_screen.dart'; import '../../features/plants/presentation/plants_screen.dart'; import '../../features/settings/presentation/settings_screen.dart'; import '../../features/today/presentation/today_screen.dart'; +import '../firebase/firebase_providers.dart'; /// Zentrale Routen-Definition. Die Pfade werden auch vom Drawer genutzt, /// deshalb hier als Konstanten. abstract final class AppRoutes { static const today = '/'; + static const login = '/login'; static const plants = '/plants'; static const plantAdd = '/plants/add'; static const plantEdit = '/plants/edit'; @@ -22,10 +28,42 @@ abstract final class AppRoutes { static const settings = '/settings'; } +/// Übersetzt einen Stream in ein Listenable, damit der Router bei +/// Login/Logout sofort neu auswertet, wohin umgeleitet werden muss. +class _StreamListenable extends ChangeNotifier { + _StreamListenable(Stream stream) { + _subscription = stream.listen((_) => notifyListeners()); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} + final appRouterProvider = Provider((ref) { - return GoRouter( + final auth = ref.watch(firebaseAuthProvider); + final refreshListenable = _StreamListenable(auth.authStateChanges()); + ref.onDispose(refreshListenable.dispose); + + final router = GoRouter( initialLocation: AppRoutes.today, + refreshListenable: refreshListenable, + redirect: (context, state) { + final loggedIn = auth.currentUser != null; + final onLoginScreen = state.matchedLocation == AppRoutes.login; + if (!loggedIn) return onLoginScreen ? null : AppRoutes.login; + if (onLoginScreen) return AppRoutes.today; + return null; + }, routes: [ + GoRoute( + path: AppRoutes.login, + builder: (context, state) => const LoginScreen(), + ), GoRoute( path: AppRoutes.today, builder: (context, state) => const TodayScreen(), @@ -62,4 +100,6 @@ final appRouterProvider = Provider((ref) { ), ], ); + ref.onDispose(router.dispose); + return router; }); diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart new file mode 100644 index 0000000..168912d --- /dev/null +++ b/lib/features/auth/data/auth_repository.dart @@ -0,0 +1,52 @@ +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'; + +/// Anmeldung, Registrierung und der Haushalts-Bootstrap. +class AuthRepository { + AuthRepository(this._auth, this._firestore); + + final FirebaseAuth _auth; + final FirebaseFirestore _firestore; + + Future signIn({required String email, required String password}) async { + await _auth.signInWithEmailAndPassword(email: email, password: password); + } + + /// Registrierung: legt Nutzer, persönliches Profil-Dokument und einen + /// neuen Haushalt in einem Rutsch an. Der Nutzer ist automatisch Mitglied + /// seines eigenen Haushalts (V2 fügt per Einladung weitere hinzu). + Future signUp({required String email, required String password}) async { + final credential = await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + final uid = credential.user!.uid; + + final householdRef = _firestore.collection('households').doc(); + final userRef = _firestore.collection('users').doc(uid); + final batch = _firestore.batch(); + batch.set(householdRef, { + 'name': 'Mein Haushalt', + 'memberUids': [uid], + 'createdAt': FieldValue.serverTimestamp(), + }); + batch.set(userRef, { + 'email': email, + 'householdId': householdRef.id, + 'createdAt': FieldValue.serverTimestamp(), + }); + await batch.commit(); + } + + Future signOut() => _auth.signOut(); +} + +final authRepositoryProvider = Provider((ref) { + return AuthRepository( + ref.watch(firebaseAuthProvider), + ref.watch(firestoreProvider), + ); +}); diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart new file mode 100644 index 0000000..c89d676 --- /dev/null +++ b/lib/features/auth/presentation/login_screen.dart @@ -0,0 +1,174 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../data/auth_repository.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isSignUp = false; + bool _busy = false; + String? _errorText; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + String _messageForCode(String code, AppLocalizations l10n) { + return switch (code) { + 'invalid-credential' || + 'user-not-found' || + 'wrong-password' => + l10n.authErrorInvalidCredential, + 'email-already-in-use' => l10n.authErrorEmailInUse, + 'weak-password' => l10n.authErrorWeakPassword, + 'invalid-email' => l10n.authErrorInvalidEmail, + _ => l10n.authErrorGeneric, + }; + } + + Future _submit() async { + final l10n = AppLocalizations.of(context); + if (!_formKey.currentState!.validate()) return; + + setState(() { + _busy = true; + _errorText = null; + }); + try { + final repo = ref.read(authRepositoryProvider); + final email = _emailController.text.trim(); + final password = _passwordController.text; + if (_isSignUp) { + await repo.signUp(email: email, password: password); + } else { + await repo.signIn(email: email, password: password); + } + // Weiterleitung übernimmt der Router (redirect bei Auth-Änderung). + } on FirebaseAuthException catch (e) { + setState(() => _errorText = _messageForCode(e.code, l10n)); + } catch (_) { + setState(() => _errorText = l10n.authErrorGeneric); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.eco, size: 64, color: theme.colorScheme.primary), + const SizedBox(height: 12), + Text( + l10n.appTitle, + style: theme.textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + l10n.loginSubtitle, + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextFormField( + controller: _emailController, + decoration: InputDecoration( + labelText: l10n.emailLabel, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.mail_outline), + ), + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.next, + validator: (value) => + (value == null || value.trim().isEmpty) + ? l10n.requiredField + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: InputDecoration( + labelText: l10n.passwordLabel, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.lock_outline), + ), + obscureText: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (value) => (value == null || value.isEmpty) + ? l10n.requiredField + : null, + ), + if (_errorText != null) ...[ + const SizedBox(height: 16), + Text( + _errorText!, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _busy ? null : _submit, + child: _busy + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _isSignUp ? l10n.signUpButton : l10n.signInButton), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _isSignUp = !_isSignUp; + _errorText = null; + }), + child: Text(_isSignUp + ? l10n.switchToSignIn + : l10n.switchToSignUp), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/household/data/household_providers.dart b/lib/features/household/data/household_providers.dart new file mode 100644 index 0000000..8166b86 --- /dev/null +++ b/lib/features/household/data/household_providers.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/firebase/firebase_providers.dart'; + +/// Die Haushalts-ID des angemeldeten Nutzers (aus users/{uid}.householdId). +/// +/// null, solange niemand angemeldet ist oder das Profil noch lädt. +/// Alle Daten-Provider (Pflanzen, Stellplätze) hängen hieran. +final householdIdProvider = StreamProvider((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?); +}); diff --git a/lib/features/locations/data/locations_provider.dart b/lib/features/locations/data/locations_provider.dart index 8a5df5a..d7f01a8 100644 --- a/lib/features/locations/data/locations_provider.dart +++ b/lib/features/locations/data/locations_provider.dart @@ -1,34 +1,63 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/firebase/firebase_providers.dart'; +import '../../household/data/household_providers.dart'; import '../domain/plant_location.dart'; -/// In-Memory-Stellplätze mit Demo-Daten – wird später durch Firestore ersetzt. -class LocationsNotifier extends Notifier> { - @override - List build() { - return const [ - PlantLocation(id: 'loc-wohnzimmer', name: 'Wohnzimmer'), - PlantLocation(id: 'loc-schlafzimmer', name: 'Schlafzimmer'), - PlantLocation(id: 'loc-kueche', name: 'Küche'), - ]; - } +/// Firestore-Anbindung der Stellplätze: +/// households/{householdId}/locations/{locationId} - void addLocation(String name) { - final id = 'loc-${DateTime.now().millisecondsSinceEpoch}'; - state = [...state, PlantLocation(id: id, name: name)]; - } -} +/// Alle Stellplätze des Haushalts, live aus Firestore. +final locationsProvider = StreamProvider>((ref) { + final householdId = ref.watch(householdIdProvider).value; + if (householdId == null) return Stream.value(const []); -final locationsProvider = - NotifierProvider>( - LocationsNotifier.new); + return ref + .watch(firestoreProvider) + .collection('households') + .doc(householdId) + .collection('locations') + .orderBy('name') + .snapshots() + .map((snapshot) => [ + for (final doc in snapshot.docs) + PlantLocation(id: doc.id, name: doc.data()['name'] as String? ?? ''), + ]); +}); final locationByIdProvider = Provider.family((ref, id) { if (id == null) return null; - final locations = ref.watch(locationsProvider); + final locations = ref.watch(locationsProvider).value ?? const []; for (final location in locations) { if (location.id == id) return location; } return null; }); + +class LocationRepository { + LocationRepository(this._firestore, this._householdIdGetter); + + final FirebaseFirestore _firestore; + final String? Function() _householdIdGetter; + + Future addLocation(String name) { + final householdId = _householdIdGetter(); + if (householdId == null) { + throw StateError('Kein Haushalt geladen – Aktion nicht möglich.'); + } + return _firestore + .collection('households') + .doc(householdId) + .collection('locations') + .add({'name': name}); + } +} + +final locationRepositoryProvider = Provider((ref) { + return LocationRepository( + ref.watch(firestoreProvider), + () => ref.read(householdIdProvider).value, + ); +}); diff --git a/lib/features/locations/presentation/locations_screen.dart b/lib/features/locations/presentation/locations_screen.dart index fe243e9..bb5d3d6 100644 --- a/lib/features/locations/presentation/locations_screen.dart +++ b/lib/features/locations/presentation/locations_screen.dart @@ -38,32 +38,29 @@ class LocationsScreen extends ConsumerWidget { ), ); if (name != null && name.trim().isNotEmpty) { - ref.read(locationsProvider.notifier).addLocation(name.trim()); + ref.read(locationRepositoryProvider).addLocation(name.trim()); } } @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); - final locations = ref.watch(locationsProvider); - final plants = ref.watch(plantsProvider); + final locationsAsync = ref.watch(locationsProvider); + final locations = locationsAsync.value ?? const []; + final plants = ref.watch(plantsProvider).value ?? const []; - return Scaffold( - appBar: AppBar(title: Text(l10n.locationsTitle)), - drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _addLocation(context, ref), - icon: const Icon(Icons.add), - label: Text(l10n.addLocation), - ), - body: locations.isEmpty - ? Center( - child: Text( - l10n.locationsEmpty, - style: Theme.of(context).textTheme.bodyLarge, - ), - ) - : ListView.separated( + final Widget body; + if (locationsAsync.isLoading && !locationsAsync.hasValue) { + body = const Center(child: CircularProgressIndicator()); + } else if (locations.isEmpty) { + body = Center( + child: Text( + l10n.locationsEmpty, + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } else { + body = ListView.separated( padding: const EdgeInsets.fromLTRB(0, 8, 0, 96), itemCount: locations.length, separatorBuilder: (_, _) => const Divider(height: 1), @@ -81,7 +78,18 @@ class LocationsScreen extends ConsumerWidget { subtitle: Text(l10n.locationPlantCount(plantCount)), ); }, - ), + ); + } + + return Scaffold( + appBar: AppBar(title: Text(l10n.locationsTitle)), + drawer: const AppDrawer(), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _addLocation(context, ref), + icon: const Icon(Icons.add), + label: Text(l10n.addLocation), + ), + body: body, ); } } diff --git a/lib/features/plants/data/plants_provider.dart b/lib/features/plants/data/plants_provider.dart index 83212a6..77274cd 100644 --- a/lib/features/plants/data/plants_provider.dart +++ b/lib/features/plants/data/plants_provider.dart @@ -1,102 +1,111 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/firebase/firebase_providers.dart'; +import '../../household/data/household_providers.dart'; import '../../today/domain/due_task.dart'; import '../domain/plant.dart'; -/// In-Memory-Datenhaltung mit Demo-Daten. -/// -/// Wird im Firebase-Block durch ein Firestore-Repository ersetzt – -/// die Provider-Schnittstelle nach außen bleibt dabei gleich. -class PlantsNotifier extends Notifier> { - @override - List build() { - final today = DateTime.now(); - DateTime daysAgo(int days) => - DateTime(today.year, today.month, today.day - days); +/// Firestore-Anbindung der Pflanzen: +/// households/{householdId}/plants/{plantId} - return [ - Plant( - id: 'demo-1', - nickname: 'Monstera', - species: 'Monstera deliciosa (Fensterblatt)', - locationId: 'loc-wohnzimmer', - description: - 'Beliebte Zimmerpflanze mit großen, geschlitzten Blättern. Mag helle Standorte ohne direkte Mittagssonne.', - careNotes: - 'Erde zwischen den Wassergaben leicht antrocknen lassen. Staunässe vermeiden.', - wateringIntervalDays: 7, - fertilizingIntervalDays: 28, - lastWatered: daysAgo(8), - lastFertilized: daysAgo(14), - ), - Plant( - id: 'demo-2', - nickname: 'Bogenhanf', - species: 'Sansevieria trifasciata', - locationId: 'loc-schlafzimmer', - description: - 'Sehr pflegeleichte Pflanze mit aufrechten, festen Blättern. Verzeiht auch mal vergessenes Gießen.', - careNotes: 'Nur sparsam gießen, verträgt Trockenheit gut.', - wateringIntervalDays: 14, - fertilizingIntervalDays: 42, - lastWatered: daysAgo(14), - lastFertilized: daysAgo(20), - ), - Plant( - id: 'demo-3', - nickname: 'Orchidee', - species: 'Phalaenopsis (Schmetterlingsorchidee)', - locationId: 'loc-kueche', - description: - 'Klassische Fensterbank-Orchidee mit langen Blütenrispen. Mag helle, warme Plätze.', - careNotes: 'Einmal pro Woche tauchen statt gießen. Kein Wasser im Herz der Pflanze stehen lassen.', - wateringIntervalDays: 7, - fertilizingIntervalDays: 21, - lastWatered: daysAgo(3), - lastFertilized: daysAgo(10), - ), - ]; - } - - void addPlant(Plant plant) { - state = [...state, plant]; - } - - void updatePlant(Plant updated) { - state = [ - for (final plant in state) - if (plant.id == updated.id) updated else plant, - ]; - } - - void removePlant(String plantId) { - state = state.where((plant) => plant.id != plantId).toList(); - } - - /// Bestätigt eine Aufgabe: setzt das Erledigungsdatum, wodurch sich die - /// nächste Fälligkeit automatisch neu berechnet (Intervall-Modell). - void confirmTask(String plantId, CareTaskType type, {DateTime? when}) { - final now = when ?? DateTime.now(); - state = [ - for (final plant in state) - if (plant.id == plantId) - switch (type) { - CareTaskType.watering => plant.copyWith(lastWatered: now), - CareTaskType.fertilizing => plant.copyWith(lastFertilized: now), - } - else - plant, - ]; - } +Plant _plantFromDoc(String id, Map data) { + return Plant( + id: id, + nickname: data['nickname'] as String? ?? '', + species: data['species'] as String? ?? '', + locationId: data['locationId'] as String?, + description: data['description'] as String? ?? '', + careNotes: data['careNotes'] as String? ?? '', + wateringIntervalDays: (data['wateringIntervalDays'] as num?)?.toInt() ?? 7, + fertilizingIntervalDays: + (data['fertilizingIntervalDays'] as num?)?.toInt() ?? 28, + lastWatered: (data['lastWatered'] as Timestamp?)?.toDate(), + lastFertilized: (data['lastFertilized'] as Timestamp?)?.toDate(), + ); } -final plantsProvider = - NotifierProvider>(PlantsNotifier.new); +Map _plantToMap(Plant plant) { + return { + 'nickname': plant.nickname, + 'species': plant.species, + 'locationId': plant.locationId, + 'description': plant.description, + 'careNotes': plant.careNotes, + 'wateringIntervalDays': plant.wateringIntervalDays, + 'fertilizingIntervalDays': plant.fertilizingIntervalDays, + 'lastWatered': + plant.lastWatered != null ? Timestamp.fromDate(plant.lastWatered!) : null, + 'lastFertilized': plant.lastFertilized != null + ? Timestamp.fromDate(plant.lastFertilized!) + : null, + }; +} + +/// Alle Pflanzen des Haushalts, live aus Firestore. +/// Leere Liste, solange kein Haushalt geladen ist (z. B. direkt nach Login). +final plantsProvider = StreamProvider>((ref) { + final householdId = ref.watch(householdIdProvider).value; + if (householdId == null) return Stream.value(const []); + + return ref + .watch(firestoreProvider) + .collection('households') + .doc(householdId) + .collection('plants') + .orderBy('nickname') + .snapshots() + .map((snapshot) => [ + for (final doc in snapshot.docs) _plantFromDoc(doc.id, doc.data()), + ]); +}); final plantByIdProvider = Provider.family((ref, id) { - final plants = ref.watch(plantsProvider); + final plants = ref.watch(plantsProvider).value ?? const []; for (final plant in plants) { if (plant.id == id) return plant; } return null; }); + +class PlantRepository { + PlantRepository(this._firestore, this._householdIdGetter); + + final FirebaseFirestore _firestore; + final String? Function() _householdIdGetter; + + CollectionReference> get _plants { + final householdId = _householdIdGetter(); + if (householdId == null) { + throw StateError('Kein Haushalt geladen – Aktion nicht möglich.'); + } + return _firestore + .collection('households') + .doc(householdId) + .collection('plants'); + } + + Future addPlant(Plant plant) => _plants.add(_plantToMap(plant)); + + Future updatePlant(Plant plant) => + _plants.doc(plant.id).update(_plantToMap(plant)); + + Future removePlant(String plantId) => _plants.doc(plantId).delete(); + + /// Bestätigt eine Aufgabe: setzt das Erledigungsdatum, wodurch sich die + /// nächste Fälligkeit automatisch neu berechnet (Intervall-Modell). + Future confirmTask(String plantId, CareTaskType type) { + final field = switch (type) { + CareTaskType.watering => 'lastWatered', + CareTaskType.fertilizing => 'lastFertilized', + }; + return _plants.doc(plantId).update({field: Timestamp.now()}); + } +} + +final plantRepositoryProvider = Provider((ref) { + return PlantRepository( + ref.watch(firestoreProvider), + () => ref.read(householdIdProvider).value, + ); +}); diff --git a/lib/features/plants/presentation/plant_detail_screen.dart b/lib/features/plants/presentation/plant_detail_screen.dart index e0f15d5..b526843 100644 --- a/lib/features/plants/presentation/plant_detail_screen.dart +++ b/lib/features/plants/presentation/plant_detail_screen.dart @@ -59,7 +59,7 @@ class PlantDetailScreen extends ConsumerWidget { ), ); if (confirmed == true && context.mounted) { - ref.read(plantsProvider.notifier).removePlant(plant.id); + ref.read(plantRepositoryProvider).removePlant(plant.id); context.pop(); } }, diff --git a/lib/features/plants/presentation/plant_form_screen.dart b/lib/features/plants/presentation/plant_form_screen.dart index 17cc9cf..4a432b4 100644 --- a/lib/features/plants/presentation/plant_form_screen.dart +++ b/lib/features/plants/presentation/plant_form_screen.dart @@ -54,10 +54,12 @@ class _PlantFormScreenState extends ConsumerState { void _save() { if (!_formKey.currentState!.validate()) return; - final notifier = ref.read(plantsProvider.notifier); + // Firestore puffert Schreibzugriffe lokal (Offline-Persistenz) – + // wir müssen hier nicht auf den Server warten. + final repository = ref.read(plantRepositoryProvider); final existing = widget.existing; if (existing != null) { - notifier.updatePlant(existing.copyWith( + repository.updatePlant(existing.copyWith( nickname: _nicknameController.text.trim(), species: _speciesController.text.trim(), locationId: () => _locationId, @@ -65,8 +67,8 @@ class _PlantFormScreenState extends ConsumerState { fertilizingIntervalDays: int.parse(_fertilizingController.text.trim()), )); } else { - notifier.addPlant(Plant( - id: 'plant-${DateTime.now().millisecondsSinceEpoch}', + repository.addPlant(Plant( + id: '', // wird von Firestore vergeben nickname: _nicknameController.text.trim(), species: _speciesController.text.trim(), locationId: _locationId, @@ -93,7 +95,7 @@ class _PlantFormScreenState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final locations = ref.watch(locationsProvider); + final locations = ref.watch(locationsProvider).value ?? const []; return Scaffold( appBar: AppBar( diff --git a/lib/features/plants/presentation/plants_screen.dart b/lib/features/plants/presentation/plants_screen.dart index 270100c..adf2d30 100644 --- a/lib/features/plants/presentation/plants_screen.dart +++ b/lib/features/plants/presentation/plants_screen.dart @@ -7,6 +7,7 @@ import '../../../core/widgets/app_drawer.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../locations/data/locations_provider.dart'; import '../data/plants_provider.dart'; +import '../domain/plant.dart'; class PlantsScreen extends ConsumerWidget { const PlantsScreen({super.key}); @@ -14,7 +15,17 @@ class PlantsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); - final plants = ref.watch(plantsProvider); + final plantsAsync = ref.watch(plantsProvider); + final plants = plantsAsync.value ?? const []; + + final Widget body; + if (plantsAsync.isLoading && !plantsAsync.hasValue) { + body = const Center(child: CircularProgressIndicator()); + } else if (plants.isEmpty) { + body = _EmptyView(l10n: l10n); + } else { + body = _plantList(context, ref, plants); + } return Scaffold( appBar: AppBar(title: Text(l10n.plantsTitle)), @@ -24,9 +35,12 @@ class PlantsScreen extends ConsumerWidget { icon: const Icon(Icons.add), label: Text(l10n.addPlant), ), - body: plants.isEmpty - ? _EmptyView(l10n: l10n) - : ListView.separated( + body: body, + ); + } + + Widget _plantList(BuildContext context, WidgetRef ref, List plants) { + return ListView.separated( padding: const EdgeInsets.fromLTRB(0, 8, 0, 96), itemCount: plants.length, separatorBuilder: (_, _) => const Divider(height: 1), @@ -53,7 +67,6 @@ class PlantsScreen extends ConsumerWidget { onTap: () => context.push('/plants/${plant.id}'), ); }, - ), ); } } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 79a80e7..704ba43 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/firebase/firebase_providers.dart'; import '../../../core/settings/settings_provider.dart'; import '../../../core/widgets/app_drawer.dart'; import '../../../l10n/generated/app_localizations.dart'; +import '../../auth/data/auth_repository.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -92,6 +94,18 @@ class SettingsScreen extends ConsumerWidget { } }, ), + const Divider(height: 32), + _SectionTitle(l10n.settingsAccount), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person), + title: Text( + ref.watch(authStateProvider).value?.email ?? '', + ), + subtitle: Text(l10n.logout), + trailing: const Icon(Icons.logout), + onTap: () => ref.read(authRepositoryProvider).signOut(), + ), ], ), ); diff --git a/lib/features/today/application/due_tasks_provider.dart b/lib/features/today/application/due_tasks_provider.dart index 0d26640..2b5a053 100644 --- a/lib/features/today/application/due_tasks_provider.dart +++ b/lib/features/today/application/due_tasks_provider.dart @@ -27,7 +27,7 @@ Iterable _tasksForPlant(Plant plant, DateTime today) sync* { /// Alle heute fälligen und überfälligen Aufgaben, Überfälliges zuerst. final dueTasksProvider = Provider>((ref) { - final plants = ref.watch(plantsProvider); + final plants = ref.watch(plantsProvider).value ?? const []; final today = _dateOnly(DateTime.now()); final tasks = [ @@ -44,7 +44,7 @@ final dueTasksProvider = Provider>((ref) { /// Das nächste zukünftige Fälligkeitsdatum – für den "Alles versorgt"-Screen /// ("Nächste Aufgabe: Freitag"). final nextDueDateProvider = Provider((ref) { - final plants = ref.watch(plantsProvider); + final plants = ref.watch(plantsProvider).value ?? const []; final today = _dateOnly(DateTime.now()); DateTime? next; diff --git a/lib/features/today/presentation/today_screen.dart b/lib/features/today/presentation/today_screen.dart index 147a64f..93927ae 100644 --- a/lib/features/today/presentation/today_screen.dart +++ b/lib/features/today/presentation/today_screen.dart @@ -18,8 +18,18 @@ class TodayScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); + final plantsAsync = ref.watch(plantsProvider); final tasks = ref.watch(dueTasksProvider); + final Widget body; + if (plantsAsync.isLoading && !plantsAsync.hasValue) { + body = const Center(child: CircularProgressIndicator()); + } else if (tasks.isEmpty) { + body = const _AllDoneView(); + } else { + body = _TaskListView(tasks: tasks); + } + return Scaffold( appBar: AppBar(title: Text(l10n.todayTitle)), drawer: const AppDrawer(), @@ -28,7 +38,7 @@ class TodayScreen extends ConsumerWidget { icon: const Icon(Icons.add), label: Text(l10n.addPlant), ), - body: tasks.isEmpty ? const _AllDoneView() : _TaskListView(tasks: tasks), + body: body, ); } } @@ -111,7 +121,7 @@ class _TaskCard extends ConsumerWidget { FilledButton.icon( onPressed: () { ref - .read(plantsProvider.notifier) + .read(plantRepositoryProvider) .confirmTask(task.plant.id, task.type); ScaffoldMessenger.of(context) ..hideCurrentSnackBar() diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 78d3a8b..b91c77a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -118,6 +118,19 @@ "textSizeXLarge": "Sehr groß", "settingsHighContrast": "Hoher Kontrast", "settingsReminderTime": "Erinnerungszeit", - "settingsReminderTimeHint": "Zu dieser Uhrzeit erinnert dich Planty an offene Aufgaben.", - "demoDataBanner": "Demo-Daten – werden durch deine echten Pflanzen ersetzt" + "settingsReminderTimeHint": "Zu dieser Uhrzeit erinnert dich LeafItToMe an offene Aufgaben.", + "settingsAccount": "Konto", + "logout": "Abmelden", + "loginSubtitle": "Melde dich an, um deine Pflanzen zu versorgen.", + "emailLabel": "E-Mail-Adresse", + "passwordLabel": "Passwort", + "signInButton": "Anmelden", + "signUpButton": "Konto erstellen", + "switchToSignUp": "Neu hier? Konto erstellen", + "switchToSignIn": "Du hast schon ein Konto? Anmelden", + "authErrorInvalidCredential": "E-Mail-Adresse oder Passwort ist falsch.", + "authErrorEmailInUse": "Mit dieser E-Mail-Adresse gibt es bereits ein Konto.", + "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." } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 4fdbfe8..efff5d4 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -445,14 +445,92 @@ abstract class AppLocalizations { /// No description provided for @settingsReminderTimeHint. /// /// In de, this message translates to: - /// **'Zu dieser Uhrzeit erinnert dich Planty an offene Aufgaben.'** + /// **'Zu dieser Uhrzeit erinnert dich LeafItToMe an offene Aufgaben.'** String get settingsReminderTimeHint; - /// No description provided for @demoDataBanner. + /// No description provided for @settingsAccount. /// /// In de, this message translates to: - /// **'Demo-Daten – werden durch deine echten Pflanzen ersetzt'** - String get demoDataBanner; + /// **'Konto'** + String get settingsAccount; + + /// No description provided for @logout. + /// + /// In de, this message translates to: + /// **'Abmelden'** + String get logout; + + /// No description provided for @loginSubtitle. + /// + /// In de, this message translates to: + /// **'Melde dich an, um deine Pflanzen zu versorgen.'** + String get loginSubtitle; + + /// No description provided for @emailLabel. + /// + /// In de, this message translates to: + /// **'E-Mail-Adresse'** + String get emailLabel; + + /// No description provided for @passwordLabel. + /// + /// In de, this message translates to: + /// **'Passwort'** + String get passwordLabel; + + /// No description provided for @signInButton. + /// + /// In de, this message translates to: + /// **'Anmelden'** + String get signInButton; + + /// No description provided for @signUpButton. + /// + /// In de, this message translates to: + /// **'Konto erstellen'** + String get signUpButton; + + /// No description provided for @switchToSignUp. + /// + /// In de, this message translates to: + /// **'Neu hier? Konto erstellen'** + String get switchToSignUp; + + /// No description provided for @switchToSignIn. + /// + /// In de, this message translates to: + /// **'Du hast schon ein Konto? Anmelden'** + String get switchToSignIn; + + /// No description provided for @authErrorInvalidCredential. + /// + /// In de, this message translates to: + /// **'E-Mail-Adresse oder Passwort ist falsch.'** + String get authErrorInvalidCredential; + + /// No description provided for @authErrorEmailInUse. + /// + /// In de, this message translates to: + /// **'Mit dieser E-Mail-Adresse gibt es bereits ein Konto.'** + String get authErrorEmailInUse; + + /// No description provided for @authErrorWeakPassword. + /// + /// In de, this message translates to: + /// **'Das Passwort muss mindestens 6 Zeichen lang sein.'** + String get authErrorWeakPassword; + + /// No description provided for @authErrorInvalidEmail. + /// + /// In de, this message translates to: + /// **'Bitte gib eine gültige E-Mail-Adresse ein.'** + String get authErrorInvalidEmail; + + /// No description provided for @authErrorGeneric. + /// + /// In de, this message translates to: + /// **'Das hat leider nicht geklappt. Bitte versuche es erneut.'** + String get authErrorGeneric; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index ddced7e..244d305 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -229,9 +229,52 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settingsReminderTimeHint => - 'Zu dieser Uhrzeit erinnert dich Planty an offene Aufgaben.'; + 'Zu dieser Uhrzeit erinnert dich LeafItToMe an offene Aufgaben.'; @override - String get demoDataBanner => - 'Demo-Daten – werden durch deine echten Pflanzen ersetzt'; + String get settingsAccount => 'Konto'; + + @override + String get logout => 'Abmelden'; + + @override + String get loginSubtitle => 'Melde dich an, um deine Pflanzen zu versorgen.'; + + @override + String get emailLabel => 'E-Mail-Adresse'; + + @override + String get passwordLabel => 'Passwort'; + + @override + String get signInButton => 'Anmelden'; + + @override + String get signUpButton => 'Konto erstellen'; + + @override + String get switchToSignUp => 'Neu hier? Konto erstellen'; + + @override + String get switchToSignIn => 'Du hast schon ein Konto? Anmelden'; + + @override + String get authErrorInvalidCredential => + 'E-Mail-Adresse oder Passwort ist falsch.'; + + @override + String get authErrorEmailInUse => + 'Mit dieser E-Mail-Adresse gibt es bereits ein Konto.'; + + @override + String get authErrorWeakPassword => + 'Das Passwort muss mindestens 6 Zeichen lang sein.'; + + @override + String get authErrorInvalidEmail => + 'Bitte gib eine gültige E-Mail-Adresse ein.'; + + @override + String get authErrorGeneric => + 'Das hat leider nicht geklappt. Bitte versuche es erneut.'; } diff --git a/pubspec.lock b/pubspec.lock index 70cbf8c..4fbda11 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "91.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c" + url: "https://pub.dev" + source: hosted + version: "1.3.75" analyzer: dependency: transitive description: @@ -17,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.4.1" + antlr4: + dependency: transitive + description: + name: antlr4 + sha256: "752b4a6e4ad97953652a2b2bbf5377f46c94b579d3372b50080c7e5858234a05" + url: "https://pub.dev" + source: hosted + version: "4.13.2" args: dependency: transitive description: @@ -41,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cel: + dependency: transitive + description: + name: cel + sha256: "51d77e16424d41b5fdb0a239be4c8a0550d4dd3f952801d35375ddd90cfb49da" + url: "https://pub.dev" + source: hosted + version: "0.5.4+1" characters: dependency: transitive description: @@ -65,6 +89,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + sha256: e494387f1cd15e4f1c935e14a9cb884c97b8847a41364fabfaa912958a2ea842 + url: "https://pub.dev" + source: hosted + version: "6.7.1" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: bc8c479a829c1abdfa4741aa2f3a3242918e0bebd3eb50e53d9dde9b303d4330 + url: "https://pub.dev" + source: hosted + version: "8.0.5" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: cb237b3bf3cff4778ec962a8a9449ae79e831d7b4369e0ad9f5a2b6dfbd84569 + url: "https://pub.dev" + source: hosted + version: "5.7.1" collection: dependency: transitive description: @@ -105,6 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe + url: "https://pub.dev" + source: hosted + version: "3.4.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -113,6 +177,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + fake_cloud_firestore: + dependency: "direct dev" + description: + name: fake_cloud_firestore + sha256: c2ce1f828e5840c2f212584d496dbae0cfc94848daa84112e51a0722358aa24a + url: "https://pub.dev" + source: hosted + version: "4.2.0" + fake_firebase_security_rules: + dependency: transitive + description: + name: fake_firebase_security_rules + sha256: "6af54bedfd6985451a9735f2cfac91ffe0128bfc92bf15e8544cd56c6941d6cc" + url: "https://pub.dev" + source: hosted + version: "0.5.4" ffi: dependency: transitive description: @@ -129,6 +209,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: "0ced04a58f0d08bb01435771069fdee06e54d85321f2a8c9dc428857098731c8" + url: "https://pub.dev" + source: hosted + version: "6.5.6" + firebase_auth_mocks: + dependency: "direct dev" + description: + name: firebase_auth_mocks + sha256: "98628921966dab6ae097d447799b2f8c6e252b8adc0f4a9fe4b67ce5c165e5fb" + url: "https://pub.dev" + source: hosted + version: "0.15.2" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: a02aa6edb07fb5676eeea47590ff19d916c0239706ec2a35b1544cc7af44b63a + url: "https://pub.dev" + source: hosted + version: "9.0.5" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: "8daa9f7665f76c6e23a2e68cabaa7f6693e1949eef3ee678712544b3835ce437" + url: "https://pub.dev" + source: hosted + version: "6.2.5" firebase_core: dependency: "direct main" description: @@ -221,6 +333,14 @@ packages: url: "https://pub.dev" source: hosted version: "17.3.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -293,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" logging: dependency: transitive description: @@ -333,6 +461,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mock_exceptions: + dependency: transitive + description: + name: mock_exceptions + sha256: "6e3e623712d2c6106ffe9e14732912522b565ddaa82a8dcee6cd4441b5984056" + url: "https://pub.dev" + source: hosted + version: "0.8.2" + more: + dependency: transitive + description: + name: more + sha256: e252628d2183cc09539b686abfbd9d8302675959b89a2a8146f5f4baca6ac5ba + url: "https://pub.dev" + source: hosted + version: "4.7.0" node_preamble: dependency: transitive description: @@ -397,6 +541,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: @@ -421,6 +573,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.2" + rx: + dependency: transitive + description: + name: rx + sha256: "3c819c80915138089c517e0d78f462792c5a2de05189466ab38bee7b6a8a330f" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d2fcddd..3718d2e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: shared_preferences: ^2.5.5 intl: any firebase_core: ^4.12.1 + firebase_auth: ^6.5.6 + cloud_firestore: ^6.7.1 dev_dependencies: flutter_test: @@ -52,6 +54,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + fake_cloud_firestore: ^4.2.0 + firebase_auth_mocks: ^0.15.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/widget_test.dart b/test/widget_test.dart index ea175b4..add7f53 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,40 +1,83 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leafittome/app.dart'; +import 'package:leafittome/core/firebase/firebase_providers.dart'; import 'package:leafittome/core/settings/settings_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Baut die App mit gemocktem Firebase: angemeldeter Nutzer 'u1' im Haushalt +/// 'h1' mit einer Monstera, deren Gießen seit einem Tag überfällig ist. Future buildTestApp() 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', + }); + await firestore.collection('households').doc('h1').set({ + 'name': 'Mein Haushalt', + 'memberUids': ['u1'], + }); + final now = DateTime.now(); + await firestore + .collection('households') + .doc('h1') + .collection('plants') + .add({ + 'nickname': 'Monstera', + 'species': 'Monstera deliciosa', + 'locationId': null, + 'description': '', + 'careNotes': '', + 'wateringIntervalDays': 7, + 'fertilizingIntervalDays': 28, + 'lastWatered': + Timestamp.fromDate(now.subtract(const Duration(days: 8))), + 'lastFertilized': + Timestamp.fromDate(now.subtract(const Duration(days: 3))), + }); + return ProviderScope( - overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + firebaseAuthProvider.overrideWithValue(auth), + firestoreProvider.overrideWithValue(firestore), + ], child: const LeafItToMeApp(), ); } void main() { - testWidgets('App startet mit der Heute-Checkliste', (tester) async { + testWidgets('Angemeldeter Nutzer sieht die Heute-Checkliste mit fälliger Aufgabe', + (tester) async { await tester.pumpWidget(await buildTestApp()); await tester.pumpAndSettle(); - // Demo-Daten enthalten überfällige Aufgaben → Checkliste sichtbar. expect(find.text('Heute'), findsOneWidget); expect(find.textContaining('offene Aufgabe'), findsOneWidget); + expect(find.textContaining('Monstera gießen'), findsOneWidget); }); - testWidgets('Aufgabe bestätigen entfernt sie aus der Liste', (tester) async { + testWidgets('Aufgabe bestätigen schreibt nach Firestore und leert die Liste', + (tester) async { await tester.pumpWidget(await buildTestApp()); await tester.pumpAndSettle(); - final doneButtons = find.text('Erledigt'); - final countBefore = tester.widgetList(doneButtons).length; - expect(countBefore, greaterThan(0)); - - await tester.tap(doneButtons.first); + await tester.tap(find.text('Erledigt').first); await tester.pumpAndSettle(); - expect(tester.widgetList(find.text('Erledigt')).length, countBefore - 1); + expect(find.text('Erledigt'), findsNothing); + expect(find.text('Alles versorgt!'), findsOneWidget); }); }