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
|
||||
│ └── 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: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<dynamic> stream) {
|
||||
_subscription = stream.listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
late final StreamSubscription<dynamic> _subscription;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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<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 '../../../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<List<PlantLocation>> {
|
||||
@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'),
|
||||
];
|
||||
}
|
||||
/// 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<List<PlantLocation>>((ref) {
|
||||
final householdId = ref.watch(householdIdProvider).value;
|
||||
if (householdId == null) return Stream.value(const []);
|
||||
|
||||
final locationsProvider =
|
||||
NotifierProvider<LocationsNotifier, List<PlantLocation>>(
|
||||
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<PlantLocation?, String?>((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<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) {
|
||||
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(
|
||||
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,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
);
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<Plant>> {
|
||||
@override
|
||||
List<Plant> 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<String, dynamic> 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, List<Plant>>(PlantsNotifier.new);
|
||||
Map<String, dynamic> _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<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 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<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) {
|
||||
ref.read(plantsProvider.notifier).removePlant(plant.id);
|
||||
ref.read(plantRepositoryProvider).removePlant(plant.id);
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,10 +54,12 @@ class _PlantFormScreenState extends ConsumerState<PlantFormScreen> {
|
|||
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<PlantFormScreen> {
|
|||
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<PlantFormScreen> {
|
|||
@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(
|
||||
|
|
|
|||
|
|
@ -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<Plant> 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}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Iterable<DueTask> _tasksForPlant(Plant plant, DateTime today) sync* {
|
|||
|
||||
/// Alle heute fälligen und überfälligen Aufgaben, Überfälliges zuerst.
|
||||
final dueTasksProvider = Provider<List<DueTask>>((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<List<DueTask>>((ref) {
|
|||
/// Das nächste zukünftige Fälligkeitsdatum – für den "Alles versorgt"-Screen
|
||||
/// ("Nächste Aufgabe: Freitag").
|
||||
final nextDueDateProvider = Provider<DateTime?>((ref) {
|
||||
final plants = ref.watch(plantsProvider);
|
||||
final plants = ref.watch(plantsProvider).value ?? const [];
|
||||
final today = _dateOnly(DateTime.now());
|
||||
|
||||
DateTime? next;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
168
pubspec.lock
168
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Widget> 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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue