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:
cschlaefke 2026-07-18 22:53:09 +02:00
parent 4390949c7e
commit 53ee30c598
25 changed files with 1011 additions and 167 deletions

View file

@ -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.

View file

@ -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 13 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 13 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

View file

@ -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
View file

@ -0,0 +1,4 @@
{
"indexes": [],
"fieldOverrides": []
}

41
firestore.rules Normal file
View 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);
}
}
}
}

View 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();
});

View file

@ -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;
});

View 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),
);
});

View 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),
),
],
),
),
),
),
),
),
);
}
}

View 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?);
});

View file

@ -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,
);
});

View file

@ -38,32 +38,29 @@ class LocationsScreen extends ConsumerWidget {
),
);
if (name != null && name.trim().isNotEmpty) {
ref.read(locationsProvider.notifier).addLocation(name.trim());
ref.read(locationRepositoryProvider).addLocation(name.trim());
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final locations = ref.watch(locationsProvider);
final plants = ref.watch(plantsProvider);
final locationsAsync = ref.watch(locationsProvider);
final locations = locationsAsync.value ?? const [];
final plants = ref.watch(plantsProvider).value ?? const [];
return Scaffold(
appBar: AppBar(title: Text(l10n.locationsTitle)),
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _addLocation(context, ref),
icon: const Icon(Icons.add),
label: Text(l10n.addLocation),
),
body: locations.isEmpty
? Center(
child: Text(
l10n.locationsEmpty,
style: Theme.of(context).textTheme.bodyLarge,
),
)
: ListView.separated(
final Widget body;
if (locationsAsync.isLoading && !locationsAsync.hasValue) {
body = const Center(child: CircularProgressIndicator());
} else if (locations.isEmpty) {
body = Center(
child: Text(
l10n.locationsEmpty,
style: Theme.of(context).textTheme.bodyLarge,
),
);
} else {
body = ListView.separated(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
itemCount: locations.length,
separatorBuilder: (_, _) => const Divider(height: 1),
@ -81,7 +78,18 @@ class LocationsScreen extends ConsumerWidget {
subtitle: Text(l10n.locationPlantCount(plantCount)),
);
},
),
);
}
return Scaffold(
appBar: AppBar(title: Text(l10n.locationsTitle)),
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _addLocation(context, ref),
icon: const Icon(Icons.add),
label: Text(l10n.addLocation),
),
body: body,
);
}
}

View file

@ -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,
);
});

View file

@ -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();
}
},

View file

@ -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(

View file

@ -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}'),
);
},
),
);
}
}

View file

@ -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(),
),
],
),
);

View file

@ -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;

View file

@ -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()

View file

@ -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."
}

View file

@ -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

View file

@ -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.';
}

View file

@ -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:

View file

@ -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

View file

@ -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);
});
}