Firestore und Auth: echte Daten statt Demo, Login mit E-Mail+Passwort, Security Rules
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
4390949c7e
commit
53ee30c598
25 changed files with 1011 additions and 167 deletions
|
|
@ -14,6 +14,7 @@ lib/
|
||||||
│ ├── theme/app_theme.dart # Farbschema und Bedienbarkeits-Anpassungen
|
│ ├── theme/app_theme.dart # Farbschema und Bedienbarkeits-Anpassungen
|
||||||
│ └── widgets/app_drawer.dart# Das Hamburger-Menü
|
│ └── widgets/app_drawer.dart# Das Hamburger-Menü
|
||||||
├── features/ # Ein Ordner pro Fachlichkeit
|
├── features/ # Ein Ordner pro Fachlichkeit
|
||||||
|
│ ├── auth/ # Login/Registrierung + Haushalts-Bootstrap
|
||||||
│ ├── today/ # Tages-Checkliste (Start-Screen)
|
│ ├── today/ # Tages-Checkliste (Start-Screen)
|
||||||
│ ├── plants/ # Pflanzen: Liste, Profil, Anlegen/Bearbeiten
|
│ ├── plants/ # Pflanzen: Liste, Profil, Anlegen/Bearbeiten
|
||||||
│ ├── locations/ # Stellplätze
|
│ ├── locations/ # Stellplätze
|
||||||
|
|
@ -29,7 +30,7 @@ lib/
|
||||||
Jedes Feature folgt demselben Muster (nicht jedes braucht alle Schichten):
|
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").
|
- **`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.
|
- **`application/`** — Abgeleitete Zustände / Anwendungslogik, z. B. `dueTasksProvider`, der aus den Pflanzen die heute fälligen Aufgaben berechnet.
|
||||||
- **`presentation/`** — Die Screens und Widgets.
|
- **`presentation/`** — Die Screens und Widgets.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
## 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).
|
- 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.
|
- `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.
|
Der App-Code für Datenbank und Login ist fertig — zwei Schalter musst du in der Console einmalig umlegen:
|
||||||
- **Auth einrichten** — E-Mail+Passwort, Google, Apple (Apple-Login braucht den Apple Developer Account).
|
|
||||||
|
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).
|
- **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).
|
- **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
|
## Begriffe kurz erklärt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}}}}}}
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
4
firestore.indexes.json
Normal file
4
firestore.indexes.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"indexes": [],
|
||||||
|
"fieldOverrides": []
|
||||||
|
}
|
||||||
41
firestore.rules
Normal file
41
firestore.rules
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
lib/core/firebase/firebase_providers.dart
Normal file
16
lib/core/firebase/firebase_providers.dart
Normal file
|
|
@ -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<FirebaseAuth>((ref) => FirebaseAuth.instance);
|
||||||
|
|
||||||
|
final firestoreProvider = Provider<FirebaseFirestore>((ref) => FirebaseFirestore.instance);
|
||||||
|
|
||||||
|
/// Der aktuell angemeldete Nutzer (null = ausgeloggt), live aktualisiert.
|
||||||
|
final authStateProvider = StreamProvider<User?>((ref) {
|
||||||
|
return ref.watch(firebaseAuthProvider).authStateChanges();
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../features/auth/presentation/login_screen.dart';
|
||||||
import '../../features/household/presentation/household_screen.dart';
|
import '../../features/household/presentation/household_screen.dart';
|
||||||
import '../../features/locations/presentation/locations_screen.dart';
|
import '../../features/locations/presentation/locations_screen.dart';
|
||||||
import '../../features/plants/domain/plant.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/plants/presentation/plants_screen.dart';
|
||||||
import '../../features/settings/presentation/settings_screen.dart';
|
import '../../features/settings/presentation/settings_screen.dart';
|
||||||
import '../../features/today/presentation/today_screen.dart';
|
import '../../features/today/presentation/today_screen.dart';
|
||||||
|
import '../firebase/firebase_providers.dart';
|
||||||
|
|
||||||
/// Zentrale Routen-Definition. Die Pfade werden auch vom Drawer genutzt,
|
/// Zentrale Routen-Definition. Die Pfade werden auch vom Drawer genutzt,
|
||||||
/// deshalb hier als Konstanten.
|
/// deshalb hier als Konstanten.
|
||||||
abstract final class AppRoutes {
|
abstract final class AppRoutes {
|
||||||
static const today = '/';
|
static const today = '/';
|
||||||
|
static const login = '/login';
|
||||||
static const plants = '/plants';
|
static const plants = '/plants';
|
||||||
static const plantAdd = '/plants/add';
|
static const plantAdd = '/plants/add';
|
||||||
static const plantEdit = '/plants/edit';
|
static const plantEdit = '/plants/edit';
|
||||||
|
|
@ -22,10 +28,42 @@ abstract final class AppRoutes {
|
||||||
static const settings = '/settings';
|
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<dynamic> stream) {
|
||||||
|
_subscription = stream.listen((_) => notifyListeners());
|
||||||
|
}
|
||||||
|
|
||||||
|
late final StreamSubscription<dynamic> _subscription;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_subscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
return GoRouter(
|
final auth = ref.watch(firebaseAuthProvider);
|
||||||
|
final refreshListenable = _StreamListenable(auth.authStateChanges());
|
||||||
|
ref.onDispose(refreshListenable.dispose);
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
initialLocation: AppRoutes.today,
|
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: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.login,
|
||||||
|
builder: (context, state) => const LoginScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.today,
|
path: AppRoutes.today,
|
||||||
builder: (context, state) => const TodayScreen(),
|
builder: (context, state) => const TodayScreen(),
|
||||||
|
|
@ -62,4 +100,6 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
ref.onDispose(router.dispose);
|
||||||
|
return router;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
52
lib/features/auth/data/auth_repository.dart
Normal file
52
lib/features/auth/data/auth_repository.dart
Normal file
|
|
@ -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<void> 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<void> 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<void> signOut() => _auth.signOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
|
return AuthRepository(
|
||||||
|
ref.watch(firebaseAuthProvider),
|
||||||
|
ref.watch(firestoreProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
174
lib/features/auth/presentation/login_screen.dart
Normal file
174
lib/features/auth/presentation/login_screen.dart
Normal file
|
|
@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/features/household/data/household_providers.dart
Normal file
19
lib/features/household/data/household_providers.dart
Normal file
|
|
@ -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<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?);
|
||||||
|
});
|
||||||
|
|
@ -1,34 +1,63 @@
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
import '../domain/plant_location.dart';
|
||||||
|
|
||||||
/// In-Memory-Stellplätze mit Demo-Daten – wird später durch Firestore ersetzt.
|
/// Firestore-Anbindung der Stellplätze:
|
||||||
class LocationsNotifier extends Notifier<List<PlantLocation>> {
|
/// households/{householdId}/locations/{locationId}
|
||||||
@override
|
|
||||||
List<PlantLocation> build() {
|
|
||||||
return const [
|
|
||||||
PlantLocation(id: 'loc-wohnzimmer', name: 'Wohnzimmer'),
|
|
||||||
PlantLocation(id: 'loc-schlafzimmer', name: 'Schlafzimmer'),
|
|
||||||
PlantLocation(id: 'loc-kueche', name: 'Küche'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
void addLocation(String name) {
|
/// Alle Stellplätze des Haushalts, live aus Firestore.
|
||||||
final id = 'loc-${DateTime.now().millisecondsSinceEpoch}';
|
final locationsProvider = StreamProvider<List<PlantLocation>>((ref) {
|
||||||
state = [...state, PlantLocation(id: id, name: name)];
|
final householdId = ref.watch(householdIdProvider).value;
|
||||||
}
|
if (householdId == null) return Stream.value(const []);
|
||||||
}
|
|
||||||
|
|
||||||
final locationsProvider =
|
return ref
|
||||||
NotifierProvider<LocationsNotifier, List<PlantLocation>>(
|
.watch(firestoreProvider)
|
||||||
LocationsNotifier.new);
|
.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 =
|
final locationByIdProvider =
|
||||||
Provider.family<PlantLocation?, String?>((ref, id) {
|
Provider.family<PlantLocation?, String?>((ref, id) {
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
final locations = ref.watch(locationsProvider);
|
final locations = ref.watch(locationsProvider).value ?? const [];
|
||||||
for (final location in locations) {
|
for (final location in locations) {
|
||||||
if (location.id == id) return location;
|
if (location.id == id) return location;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class LocationRepository {
|
||||||
|
LocationRepository(this._firestore, this._householdIdGetter);
|
||||||
|
|
||||||
|
final FirebaseFirestore _firestore;
|
||||||
|
final String? Function() _householdIdGetter;
|
||||||
|
|
||||||
|
Future<void> 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<LocationRepository>((ref) {
|
||||||
|
return LocationRepository(
|
||||||
|
ref.watch(firestoreProvider),
|
||||||
|
() => ref.read(householdIdProvider).value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -38,32 +38,29 @@ class LocationsScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (name != null && name.trim().isNotEmpty) {
|
if (name != null && name.trim().isNotEmpty) {
|
||||||
ref.read(locationsProvider.notifier).addLocation(name.trim());
|
ref.read(locationRepositoryProvider).addLocation(name.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final locations = ref.watch(locationsProvider);
|
final locationsAsync = ref.watch(locationsProvider);
|
||||||
final plants = ref.watch(plantsProvider);
|
final locations = locationsAsync.value ?? const [];
|
||||||
|
final plants = ref.watch(plantsProvider).value ?? const [];
|
||||||
|
|
||||||
return Scaffold(
|
final Widget body;
|
||||||
appBar: AppBar(title: Text(l10n.locationsTitle)),
|
if (locationsAsync.isLoading && !locationsAsync.hasValue) {
|
||||||
drawer: const AppDrawer(),
|
body = const Center(child: CircularProgressIndicator());
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
} else if (locations.isEmpty) {
|
||||||
onPressed: () => _addLocation(context, ref),
|
body = Center(
|
||||||
icon: const Icon(Icons.add),
|
child: Text(
|
||||||
label: Text(l10n.addLocation),
|
l10n.locationsEmpty,
|
||||||
),
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
body: locations.isEmpty
|
),
|
||||||
? Center(
|
);
|
||||||
child: Text(
|
} else {
|
||||||
l10n.locationsEmpty,
|
body = ListView.separated(
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.separated(
|
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
|
||||||
itemCount: locations.length,
|
itemCount: locations.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
|
@ -81,7 +78,18 @@ class LocationsScreen extends ConsumerWidget {
|
||||||
subtitle: Text(l10n.locationPlantCount(plantCount)),
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,111 @@
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../today/domain/due_task.dart';
|
||||||
import '../domain/plant.dart';
|
import '../domain/plant.dart';
|
||||||
|
|
||||||
/// In-Memory-Datenhaltung mit Demo-Daten.
|
/// Firestore-Anbindung der Pflanzen:
|
||||||
///
|
/// households/{householdId}/plants/{plantId}
|
||||||
/// Wird im Firebase-Block durch ein Firestore-Repository ersetzt –
|
|
||||||
/// die Provider-Schnittstelle nach außen bleibt dabei gleich.
|
|
||||||
class PlantsNotifier extends Notifier<List<Plant>> {
|
|
||||||
@override
|
|
||||||
List<Plant> build() {
|
|
||||||
final today = DateTime.now();
|
|
||||||
DateTime daysAgo(int days) =>
|
|
||||||
DateTime(today.year, today.month, today.day - days);
|
|
||||||
|
|
||||||
return [
|
Plant _plantFromDoc(String id, Map<String, dynamic> data) {
|
||||||
Plant(
|
return Plant(
|
||||||
id: 'demo-1',
|
id: id,
|
||||||
nickname: 'Monstera',
|
nickname: data['nickname'] as String? ?? '',
|
||||||
species: 'Monstera deliciosa (Fensterblatt)',
|
species: data['species'] as String? ?? '',
|
||||||
locationId: 'loc-wohnzimmer',
|
locationId: data['locationId'] as String?,
|
||||||
description:
|
description: data['description'] as String? ?? '',
|
||||||
'Beliebte Zimmerpflanze mit großen, geschlitzten Blättern. Mag helle Standorte ohne direkte Mittagssonne.',
|
careNotes: data['careNotes'] as String? ?? '',
|
||||||
careNotes:
|
wateringIntervalDays: (data['wateringIntervalDays'] as num?)?.toInt() ?? 7,
|
||||||
'Erde zwischen den Wassergaben leicht antrocknen lassen. Staunässe vermeiden.',
|
fertilizingIntervalDays:
|
||||||
wateringIntervalDays: 7,
|
(data['fertilizingIntervalDays'] as num?)?.toInt() ?? 28,
|
||||||
fertilizingIntervalDays: 28,
|
lastWatered: (data['lastWatered'] as Timestamp?)?.toDate(),
|
||||||
lastWatered: daysAgo(8),
|
lastFertilized: (data['lastFertilized'] as Timestamp?)?.toDate(),
|
||||||
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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final plantsProvider =
|
Map<String, dynamic> _plantToMap(Plant plant) {
|
||||||
NotifierProvider<PlantsNotifier, List<Plant>>(PlantsNotifier.new);
|
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<List<Plant>>((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<Plant?, String>((ref, id) {
|
final plantByIdProvider = Provider.family<Plant?, String>((ref, id) {
|
||||||
final plants = ref.watch(plantsProvider);
|
final plants = ref.watch(plantsProvider).value ?? const [];
|
||||||
for (final plant in plants) {
|
for (final plant in plants) {
|
||||||
if (plant.id == id) return plant;
|
if (plant.id == id) return plant;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class PlantRepository {
|
||||||
|
PlantRepository(this._firestore, this._householdIdGetter);
|
||||||
|
|
||||||
|
final FirebaseFirestore _firestore;
|
||||||
|
final String? Function() _householdIdGetter;
|
||||||
|
|
||||||
|
CollectionReference<Map<String, dynamic>> 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<void> addPlant(Plant plant) => _plants.add(_plantToMap(plant));
|
||||||
|
|
||||||
|
Future<void> updatePlant(Plant plant) =>
|
||||||
|
_plants.doc(plant.id).update(_plantToMap(plant));
|
||||||
|
|
||||||
|
Future<void> 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<void> 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<PlantRepository>((ref) {
|
||||||
|
return PlantRepository(
|
||||||
|
ref.watch(firestoreProvider),
|
||||||
|
() => ref.read(householdIdProvider).value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class PlantDetailScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed == true && context.mounted) {
|
if (confirmed == true && context.mounted) {
|
||||||
ref.read(plantsProvider.notifier).removePlant(plant.id);
|
ref.read(plantRepositoryProvider).removePlant(plant.id);
|
||||||
context.pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,12 @@ class _PlantFormScreenState extends ConsumerState<PlantFormScreen> {
|
||||||
void _save() {
|
void _save() {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
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;
|
final existing = widget.existing;
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
notifier.updatePlant(existing.copyWith(
|
repository.updatePlant(existing.copyWith(
|
||||||
nickname: _nicknameController.text.trim(),
|
nickname: _nicknameController.text.trim(),
|
||||||
species: _speciesController.text.trim(),
|
species: _speciesController.text.trim(),
|
||||||
locationId: () => _locationId,
|
locationId: () => _locationId,
|
||||||
|
|
@ -65,8 +67,8 @@ class _PlantFormScreenState extends ConsumerState<PlantFormScreen> {
|
||||||
fertilizingIntervalDays: int.parse(_fertilizingController.text.trim()),
|
fertilizingIntervalDays: int.parse(_fertilizingController.text.trim()),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
notifier.addPlant(Plant(
|
repository.addPlant(Plant(
|
||||||
id: 'plant-${DateTime.now().millisecondsSinceEpoch}',
|
id: '', // wird von Firestore vergeben
|
||||||
nickname: _nicknameController.text.trim(),
|
nickname: _nicknameController.text.trim(),
|
||||||
species: _speciesController.text.trim(),
|
species: _speciesController.text.trim(),
|
||||||
locationId: _locationId,
|
locationId: _locationId,
|
||||||
|
|
@ -93,7 +95,7 @@ class _PlantFormScreenState extends ConsumerState<PlantFormScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final locations = ref.watch(locationsProvider);
|
final locations = ref.watch(locationsProvider).value ?? const [];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../../../core/widgets/app_drawer.dart';
|
||||||
import '../../../l10n/generated/app_localizations.dart';
|
import '../../../l10n/generated/app_localizations.dart';
|
||||||
import '../../locations/data/locations_provider.dart';
|
import '../../locations/data/locations_provider.dart';
|
||||||
import '../data/plants_provider.dart';
|
import '../data/plants_provider.dart';
|
||||||
|
import '../domain/plant.dart';
|
||||||
|
|
||||||
class PlantsScreen extends ConsumerWidget {
|
class PlantsScreen extends ConsumerWidget {
|
||||||
const PlantsScreen({super.key});
|
const PlantsScreen({super.key});
|
||||||
|
|
@ -14,7 +15,17 @@ class PlantsScreen extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = AppLocalizations.of(context);
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(l10n.plantsTitle)),
|
appBar: AppBar(title: Text(l10n.plantsTitle)),
|
||||||
|
|
@ -24,9 +35,12 @@ class PlantsScreen extends ConsumerWidget {
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(l10n.addPlant),
|
label: Text(l10n.addPlant),
|
||||||
),
|
),
|
||||||
body: plants.isEmpty
|
body: body,
|
||||||
? _EmptyView(l10n: l10n)
|
);
|
||||||
: ListView.separated(
|
}
|
||||||
|
|
||||||
|
Widget _plantList(BuildContext context, WidgetRef ref, List<Plant> plants) {
|
||||||
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
|
||||||
itemCount: plants.length,
|
itemCount: plants.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
|
@ -53,7 +67,6 @@ class PlantsScreen extends ConsumerWidget {
|
||||||
onTap: () => context.push('/plants/${plant.id}'),
|
onTap: () => context.push('/plants/${plant.id}'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/firebase/firebase_providers.dart';
|
||||||
import '../../../core/settings/settings_provider.dart';
|
import '../../../core/settings/settings_provider.dart';
|
||||||
import '../../../core/widgets/app_drawer.dart';
|
import '../../../core/widgets/app_drawer.dart';
|
||||||
import '../../../l10n/generated/app_localizations.dart';
|
import '../../../l10n/generated/app_localizations.dart';
|
||||||
|
import '../../auth/data/auth_repository.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerWidget {
|
class SettingsScreen extends ConsumerWidget {
|
||||||
const SettingsScreen({super.key});
|
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(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ Iterable<DueTask> _tasksForPlant(Plant plant, DateTime today) sync* {
|
||||||
|
|
||||||
/// Alle heute fälligen und überfälligen Aufgaben, Überfälliges zuerst.
|
/// Alle heute fälligen und überfälligen Aufgaben, Überfälliges zuerst.
|
||||||
final dueTasksProvider = Provider<List<DueTask>>((ref) {
|
final dueTasksProvider = Provider<List<DueTask>>((ref) {
|
||||||
final plants = ref.watch(plantsProvider);
|
final plants = ref.watch(plantsProvider).value ?? const [];
|
||||||
final today = _dateOnly(DateTime.now());
|
final today = _dateOnly(DateTime.now());
|
||||||
|
|
||||||
final tasks = [
|
final tasks = [
|
||||||
|
|
@ -44,7 +44,7 @@ final dueTasksProvider = Provider<List<DueTask>>((ref) {
|
||||||
/// Das nächste zukünftige Fälligkeitsdatum – für den "Alles versorgt"-Screen
|
/// Das nächste zukünftige Fälligkeitsdatum – für den "Alles versorgt"-Screen
|
||||||
/// ("Nächste Aufgabe: Freitag").
|
/// ("Nächste Aufgabe: Freitag").
|
||||||
final nextDueDateProvider = Provider<DateTime?>((ref) {
|
final nextDueDateProvider = Provider<DateTime?>((ref) {
|
||||||
final plants = ref.watch(plantsProvider);
|
final plants = ref.watch(plantsProvider).value ?? const [];
|
||||||
final today = _dateOnly(DateTime.now());
|
final today = _dateOnly(DateTime.now());
|
||||||
|
|
||||||
DateTime? next;
|
DateTime? next;
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,18 @@ class TodayScreen extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final plantsAsync = ref.watch(plantsProvider);
|
||||||
final tasks = ref.watch(dueTasksProvider);
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(l10n.todayTitle)),
|
appBar: AppBar(title: Text(l10n.todayTitle)),
|
||||||
drawer: const AppDrawer(),
|
drawer: const AppDrawer(),
|
||||||
|
|
@ -28,7 +38,7 @@ class TodayScreen extends ConsumerWidget {
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(l10n.addPlant),
|
label: Text(l10n.addPlant),
|
||||||
),
|
),
|
||||||
body: tasks.isEmpty ? const _AllDoneView() : _TaskListView(tasks: tasks),
|
body: body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +121,7 @@ class _TaskCard extends ConsumerWidget {
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref
|
ref
|
||||||
.read(plantsProvider.notifier)
|
.read(plantRepositoryProvider)
|
||||||
.confirmTask(task.plant.id, task.type);
|
.confirmTask(task.plant.id, task.type);
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
..hideCurrentSnackBar()
|
..hideCurrentSnackBar()
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,19 @@
|
||||||
"textSizeXLarge": "Sehr groß",
|
"textSizeXLarge": "Sehr groß",
|
||||||
"settingsHighContrast": "Hoher Kontrast",
|
"settingsHighContrast": "Hoher Kontrast",
|
||||||
"settingsReminderTime": "Erinnerungszeit",
|
"settingsReminderTime": "Erinnerungszeit",
|
||||||
"settingsReminderTimeHint": "Zu dieser Uhrzeit erinnert dich Planty an offene Aufgaben.",
|
"settingsReminderTimeHint": "Zu dieser Uhrzeit erinnert dich LeafItToMe an offene Aufgaben.",
|
||||||
"demoDataBanner": "Demo-Daten – werden durch deine echten Pflanzen ersetzt"
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -445,14 +445,92 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @settingsReminderTimeHint.
|
/// No description provided for @settingsReminderTimeHint.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// 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;
|
String get settingsReminderTimeHint;
|
||||||
|
|
||||||
/// No description provided for @demoDataBanner.
|
/// No description provided for @settingsAccount.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
/// **'Demo-Daten – werden durch deine echten Pflanzen ersetzt'**
|
/// **'Konto'**
|
||||||
String get demoDataBanner;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -229,9 +229,52 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsReminderTimeHint =>
|
String get settingsReminderTimeHint =>
|
||||||
'Zu dieser Uhrzeit erinnert dich Planty an offene Aufgaben.';
|
'Zu dieser Uhrzeit erinnert dich LeafItToMe an offene Aufgaben.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get demoDataBanner =>
|
String get settingsAccount => 'Konto';
|
||||||
'Demo-Daten – werden durch deine echten Pflanzen ersetzt';
|
|
||||||
|
@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.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
168
pubspec.lock
168
pubspec.lock
|
|
@ -9,6 +9,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "91.0.0"
|
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:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -17,6 +25,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.4.1"
|
version: "8.4.1"
|
||||||
|
antlr4:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: antlr4
|
||||||
|
sha256: "752b4a6e4ad97953652a2b2bbf5377f46c94b579d3372b50080c7e5858234a05"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.13.2"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -41,6 +57,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
cel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cel
|
||||||
|
sha256: "51d77e16424d41b5fdb0a239be4c8a0550d4dd3f952801d35375ddd90cfb49da"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.4+1"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -65,6 +89,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -105,6 +153,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -113,6 +177,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
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:
|
ffi:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -129,6 +209,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -221,6 +333,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.3.0"
|
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:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -293,6 +413,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
|
logger:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logger
|
||||||
|
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -333,6 +461,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -397,6 +541,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -421,6 +573,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.2"
|
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:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ dependencies:
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
intl: any
|
intl: any
|
||||||
firebase_core: ^4.12.1
|
firebase_core: ^4.12.1
|
||||||
|
firebase_auth: ^6.5.6
|
||||||
|
cloud_firestore: ^6.7.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -52,6 +54,8 @@ dev_dependencies:
|
||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
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
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
|
||||||
|
|
@ -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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:leafittome/app.dart';
|
import 'package:leafittome/app.dart';
|
||||||
|
import 'package:leafittome/core/firebase/firebase_providers.dart';
|
||||||
import 'package:leafittome/core/settings/settings_provider.dart';
|
import 'package:leafittome/core/settings/settings_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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<Widget> buildTestApp() async {
|
Future<Widget> buildTestApp() async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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(
|
return ProviderScope(
|
||||||
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
|
overrides: [
|
||||||
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||||
|
firebaseAuthProvider.overrideWithValue(auth),
|
||||||
|
firestoreProvider.overrideWithValue(firestore),
|
||||||
|
],
|
||||||
child: const LeafItToMeApp(),
|
child: const LeafItToMeApp(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
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.pumpWidget(await buildTestApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Demo-Daten enthalten überfällige Aufgaben → Checkliste sichtbar.
|
|
||||||
expect(find.text('Heute'), findsOneWidget);
|
expect(find.text('Heute'), findsOneWidget);
|
||||||
expect(find.textContaining('offene Aufgabe'), 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.pumpWidget(await buildTestApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final doneButtons = find.text('Erledigt');
|
await tester.tap(find.text('Erledigt').first);
|
||||||
final countBefore = tester.widgetList(doneButtons).length;
|
|
||||||
expect(countBefore, greaterThan(0));
|
|
||||||
|
|
||||||
await tester.tap(doneButtons.first);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(tester.widgetList(find.text('Erledigt')).length, countBefore - 1);
|
expect(find.text('Erledigt'), findsNothing);
|
||||||
|
expect(find.text('Alles versorgt!'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue