From e81ce4854312240772a5a81f9f5d5128c22e68c8 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 19 Aug 2026 15:13:04 +0200 Subject: [PATCH] =?UTF-8?q?V3:=20N=C3=A4chstes=20F=C3=A4lligkeitsdatum=20b?= =?UTF-8?q?ei=20Gie=C3=9Fen/D=C3=BCngen/Umtopfen=20in=20Pflanzen-Detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nutzer vermisste beim Umtopf-Test eine Info, wann das nächste Umtopfen ansteht (Formular/Checkliste zeigten nur "alle X Monate"/"zuletzt am"). Berechnung aus due_tasks_provider public gemacht statt dupliziert. Co-Authored-By: Claude Sonnet 5 --- docs/handoff.md | 8 +++--- .../presentation/plant_detail_screen.dart | 27 ++++++++++--------- .../today/application/due_tasks_provider.dart | 23 ++++++++++------ lib/l10n/app_de.arb | 19 +++++++++++++ lib/l10n/generated/app_localizations.dart | 24 +++++++++++++++++ lib/l10n/generated/app_localizations_de.dart | 18 +++++++++++++ test/widget_test.dart | 12 +++++++++ 7 files changed, 107 insertions(+), 24 deletions(-) diff --git a/docs/handoff.md b/docs/handoff.md index 1e13479..b096fae 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -1,6 +1,6 @@ # Handoff: LeafItToMe (Pflanzenpflege-App) -Stand: 2026-07-24 (Session-Ende). Sprache mit dem Nutzer (Chris): **Deutsch**. +Stand: 2026-08-19 (Session-Ende). Sprache mit dem Nutzer (Chris): **Deutsch**. ## Projekt in einem Satz @@ -23,7 +23,7 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si - **Apple-Login: implementiert, Bug behoben, vom Nutzer bestätigt** (2026-07-23). Der Bug (neuer Apple-Account konnte nichts speichern) hatte zwei Schichten: Bootstrap hing am Login-Aufruf und wurde vom Router-Redirect überholt; UND die Push-Registrierung hatte das users-Doc ohne `householdId` vorab angelegt, weshalb ein Existenz-Check nicht reichte. Fix: reaktiver Bootstrap + `householdId`-Maßstab + merge-Writes; selbstheilend für kaputte Accounts. Zusätzlich beseitigt: Riverpod-3-Warnung „markNeedsBuild during build" (BootstrapGate als Stack-Overlay statt Baum-Austausch + `checklistWarmupProvider` in `app.dart` gegen das Pausieren unbeobachteter Provider). - **Google-Login: implementiert, deployt und auf Gerät getestet** (2026-07-23 implementiert, Gerätetest 2026-07-24 bestätigt; `firebase-einrichtung.md` Schritt 8; Debug-SHA-1/SHA-256 per `firebase apps:android:sha:create` hinterlegt — der SHA-Konsolen-Hinweis betrifft nur den nativen Flow und ist für unseren Browser-Flow irrelevant). - **V3 Baustein 1 — Krankheits-Diagnose per Foto: komplett, deployt, vom Nutzer bestätigt** („sieht richtig gut aus"). Function `diagnosePlant` + „Pflanze untersuchen" im Pflanzen-Detail (Kamera/Galerie → Ergebnis-Sheet mit Ampel-Icon, Behandlung, Vorbeugung, KI-Disclaimer; `diagnosis_sheet.dart` gemeinsam genutzt). Nachbesserungen aus Live-Tests, alle bestätigt: Art-Angabe ist unbestätigter Hinweis (`matchesSpecies=false` → Warnkasten „Foto zeigt andere Pflanze"), kein HTML/Markdown im Text, konsequentes Duzen. **Untersuchungshistorie** unter `households/{id}/plants/{id}/diagnoses` (Rules: read/create alle im Haushalt, delete nur member, update nie), auf eigener Seite `/plants/:id/diagnoses`; Button „Frühere Untersuchungen" im Detail ist bei leerer Historie deaktiviert. **Historie-Seite auf Gerät getestet und bestätigt** (2026-07-24). -- **V3 Baustein 2 — Umtopf-Erinnerungen: implementiert und deployt** (2026-07-23, Commit `81d990d`). Dritter Aufgabentyp `repotting`, als Einziger opt-in (`repottingIntervalMonths` optional) und in Kalender-Monaten (31. → Monatsletzter; Logik gespiegelt in `due_tasks_provider` und `reminders.ts`). Formular mit Intervall-Feld + „Zuletzt umgetopft"-Datum, Checkliste + Push („Umtopfen: …"), Sitter dürfen bestätigen. Rules + `sendDailyReminders` sind deployt. **Gerätetest steht aus.** +- **V3 Baustein 2 — Umtopf-Erinnerungen: implementiert und deployt** (2026-07-23, Commit `81d990d`). Dritter Aufgabentyp `repotting`, als Einziger opt-in (`repottingIntervalMonths` optional) und in Kalender-Monaten (31. → Monatsletzter; Logik gespiegelt in `due_tasks_provider` und `reminders.ts`). Formular mit Intervall-Feld + „Zuletzt umgetopft"-Datum, Checkliste + Push („Umtopfen: …"), Sitter dürfen bestätigen. Rules + `sendDailyReminders` sind deployt. **Gerätetest bestätigt** (2026-08-19): Stellplatz-Bewertung „super", Umtopf-Flow selbst funktionierte, aber Chris vermisste eine Anzeige, *wann* das nächste Umtopfen ansteht (das Formularfeld war nicht das Problem). Nachgebessert: Pflanzen-Detailseite zeigt jetzt bei Gießen/Düngen/Umtopfen zusätzlich zum letzten Datum das **nächste Fälligkeitsdatum** an (bzw. „heute fällig"). Berechnung dafür aus `due_tasks_provider.dart` public gemacht (`nextWateringDue`/`nextFertilizingDue`/`nextRepottingDue`) statt dupliziert. Neue l10n-Keys `nextWatering`/`nextFertilizing`/`nextRepotting`/`dueToday`. Zwei Tests ergänzt (Stellplatz- und Umtopf-Test prüfen jetzt auch die neue Anzeige). **Gerätetest für diese Nachbesserung steht noch aus.** - **V3 Baustein 3 — Stellplatz-Bewertung: implementiert und deployt** (2026-07-24, Functions `analyzeLocation`/`assessPlantFit` + Firestore-Rules live). Konzept mit dem Nutzer abgestimmt: Standort-Daten nur per Foto (keine Zusatzfragen zu Himmelsrichtung), Eignungs-Sterne ausschließlich auf Abruf (Kostenkontrolle, eigener Anthropic-Key). Zwei Functions `analyzeLocation` (Foto → Lichtkategorie + Freitext, gespeichert auf `PlantLocation`) und `assessPlantFit` (textbasiert, Pflegeprofil + Standort-Analyse → 1-5 Sterne + Begründung, gespeichert auf `Plant`). Neue Seite `LocationDetailScreen` (`/locations/:id`, erreichbar per Tap in der Stellplätze-Liste): Foto/Analyse-Bereich (nur Mitglieder) + Liste zugeordneter Pflanzen mit Sternen oder „Eignung prüfen"-Button (auch für Sitter, informativ). Pflanzen-Detail zeigt den Stellplatz jetzt kompakt mit Sternen und verlinkt dorthin. Veraltete Bewertungen (Pflanze verschoben oder Stellplatz neu analysiert) werden erkannt und als solche markiert statt falscher Sterne. Firestore-Rules erweitert (Sitter dürfen die vier Fit-Felder schreiben). Details/Begründung der Entscheidungen in `docs/architektur.md`. **Gerätetest steht aus** (frisch angelegte Functions können beim allerersten Aufruf HTTP 401 liefern, siehe Stolperfallen). - Testabdeckung: 16 Widget-Tests grün (u. a. Bootstrap-Heilung, Untersuchungshistorie bis ins Sheet, Umtopf-Flow Ende-zu-Ende, Stellplatz-Bewertung inkl. Veraltet-Erkennung), Analyzer sauber, `tsc` kompiliert, Android-Debug-Build ok. @@ -31,8 +31,8 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si ### 1. Ausstehende Gerätetests (Nutzer, bei Rückmeldung ggf. nachbessern) -- **Stellplatz-Bewertung:** Stellplatz öffnen → Foto aufnehmen → Licht-Analyse erscheint (Kategorie-Chip + Text); bei einer zugeordneten Pflanze „Eignung prüfen" → Sterne + Begründung erscheinen; Pflanze auf anderen Stellplatz verschieben → alte Sterne werden als veraltet markiert; Pflanzen-Detail zeigt den Stellplatz samt Sternen kompakt an und verlinkt zur Stellplatz-Seite. -- **Umtopf-Flow:** Pflanze bearbeiten → Intervall 12 Monate + „Zuletzt umgetopft" vor >1 Jahr → Aufgabe „… umtopfen" in Checkliste → Erledigt; Push-Text „Umtopfen: …" beim nächsten regulären Slot. +- **Nächstes-Fälligkeitsdatum auf der Pflanzen-Detailseite** (neu, 2026-08-19): Pflanze öffnen → bei Gießen/Düngen/Umtopfen erscheint jetzt zusätzlich zum letzten Datum eine zweite Zeile „Nächstes Gießen/Düngen/Umtopfen: …" (bzw. „heute fällig"). +- **Umtopf-Flow — Push-Text:** „Umtopfen: …" beim nächsten regulären Push-Slot (Checkliste + Bestätigen sind bereits bestätigt). ### 2. Backlog diff --git a/lib/features/plants/presentation/plant_detail_screen.dart b/lib/features/plants/presentation/plant_detail_screen.dart index 324a3bb..7059638 100644 --- a/lib/features/plants/presentation/plant_detail_screen.dart +++ b/lib/features/plants/presentation/plant_detail_screen.dart @@ -11,6 +11,7 @@ import '../../household/data/household_providers.dart'; import '../../household/domain/household.dart'; import '../../locations/data/locations_provider.dart'; import '../../locations/domain/plant_location.dart'; +import '../../today/application/due_tasks_provider.dart'; import '../data/plant_diagnosis_service.dart'; import '../data/plant_fit_service.dart'; import '../data/plants_provider.dart'; @@ -45,6 +46,11 @@ class PlantDetailScreen extends ConsumerWidget { String formatDate(DateTime? date) => date != null ? dateFormat.format(date) : l10n.never; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + String formatNextDue(DateTime date) => + date.isAfter(today) ? dateFormat.format(date) : l10n.dueToday; + final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member; return Scaffold( @@ -119,27 +125,24 @@ class PlantDetailScreen extends ConsumerWidget { _InfoTile( icon: Icons.water_drop, label: l10n.wateringEvery(plant.wateringIntervalDays), - value: l10n.lastWatered(formatDate(plant.lastWatered)) + - (plant.lastWateredBy != null - ? l10n.doneBy(plant.lastWateredBy!) - : ''), + value: '${l10n.lastWatered(formatDate(plant.lastWatered))}' + '${plant.lastWateredBy != null ? l10n.doneBy(plant.lastWateredBy!) : ''}' + '\n${l10n.nextWatering(formatNextDue(nextWateringDue(plant, today)!))}', ), _InfoTile( icon: Icons.compost, label: l10n.fertilizingEvery(plant.fertilizingIntervalDays), - value: l10n.lastFertilized(formatDate(plant.lastFertilized)) + - (plant.lastFertilizedBy != null - ? l10n.doneBy(plant.lastFertilizedBy!) - : ''), + value: '${l10n.lastFertilized(formatDate(plant.lastFertilized))}' + '${plant.lastFertilizedBy != null ? l10n.doneBy(plant.lastFertilizedBy!) : ''}' + '\n${l10n.nextFertilizing(formatNextDue(nextFertilizingDue(plant, today)!))}', ), if (plant.repottingIntervalMonths != null) _InfoTile( icon: Icons.yard, label: l10n.repottingEvery(plant.repottingIntervalMonths!), - value: l10n.lastRepotted(formatDate(plant.lastRepotted)) + - (plant.lastRepottedBy != null - ? l10n.doneBy(plant.lastRepottedBy!) - : ''), + value: '${l10n.lastRepotted(formatDate(plant.lastRepotted))}' + '${plant.lastRepottedBy != null ? l10n.doneBy(plant.lastRepottedBy!) : ''}' + '\n${l10n.nextRepotting(formatNextDue(nextRepottingDue(plant, today)!))}', ), if (plant.description.isNotEmpty) ...[ const Divider(height: 32), diff --git a/lib/features/today/application/due_tasks_provider.dart b/lib/features/today/application/due_tasks_provider.dart index db90a9d..9f713df 100644 --- a/lib/features/today/application/due_tasks_provider.dart +++ b/lib/features/today/application/due_tasks_provider.dart @@ -25,8 +25,16 @@ DateTime _addMonths(DateTime date, int months) { year, month, date.day > lastDayOfMonth ? lastDayOfMonth : date.day); } +/// Fälligkeit fürs Gießen. +DateTime? nextWateringDue(Plant plant, DateTime today) => + _nextDue(plant.lastWatered, plant.wateringIntervalDays, today); + +/// Fälligkeit fürs Düngen. +DateTime? nextFertilizingDue(Plant plant, DateTime today) => + _nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today); + /// Fälligkeit fürs Umtopfen — nur wenn ein Intervall gesetzt ist (opt-in). -DateTime? _nextRepotDue(Plant plant, DateTime today) { +DateTime? nextRepottingDue(Plant plant, DateTime today) { final months = plant.repottingIntervalMonths; if (months == null) return null; if (plant.lastRepotted == null) return today; @@ -35,7 +43,7 @@ DateTime? _nextRepotDue(Plant plant, DateTime today) { Iterable _tasksForPlant( Plant plant, Household household, DateTime today) sync* { - final watering = _nextDue(plant.lastWatered, plant.wateringIntervalDays, today); + final watering = nextWateringDue(plant, today); if (watering != null && !watering.isAfter(today)) { yield DueTask( plant: plant, @@ -45,8 +53,7 @@ Iterable _tasksForPlant( householdName: household.name, ); } - final fertilizing = - _nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today); + final fertilizing = nextFertilizingDue(plant, today); if (fertilizing != null && !fertilizing.isAfter(today)) { yield DueTask( plant: plant, @@ -56,7 +63,7 @@ Iterable _tasksForPlant( householdName: household.name, ); } - final repotting = _nextRepotDue(plant, today); + final repotting = nextRepottingDue(plant, today); if (repotting != null && !repotting.isAfter(today)) { yield DueTask( plant: plant, @@ -139,9 +146,9 @@ final nextDueDateProvider = Provider((ref) { ref.watch(householdPlantsProvider(household.id)).value ?? const []; for (final plant in plants) { for (final candidate in [ - _nextDue(plant.lastWatered, plant.wateringIntervalDays, today), - _nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today), - _nextRepotDue(plant, today), + nextWateringDue(plant, today), + nextFertilizingDue(plant, today), + nextRepottingDue(plant, today), ]) { if (candidate == null || !candidate.isAfter(today)) continue; if (next == null || candidate.isBefore(next)) next = candidate; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe9614d..5825cf0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -104,6 +104,25 @@ "date": { "type": "String" } } }, + "nextWatering": "Nächstes Gießen: {date}", + "@nextWatering": { + "placeholders": { + "date": { "type": "String" } + } + }, + "nextFertilizing": "Nächstes Düngen: {date}", + "@nextFertilizing": { + "placeholders": { + "date": { "type": "String" } + } + }, + "nextRepotting": "Nächstes Umtopfen: {date}", + "@nextRepotting": { + "placeholders": { + "date": { "type": "String" } + } + }, + "dueToday": "heute fällig", "never": "noch nie", "save": "Speichern", "cancel": "Abbrechen", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ad9bbe2..3849bd0 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -328,6 +328,30 @@ abstract class AppLocalizations { /// **'Zuletzt gedüngt: {date}'** String lastFertilized(String date); + /// No description provided for @nextWatering. + /// + /// In de, this message translates to: + /// **'Nächstes Gießen: {date}'** + String nextWatering(String date); + + /// No description provided for @nextFertilizing. + /// + /// In de, this message translates to: + /// **'Nächstes Düngen: {date}'** + String nextFertilizing(String date); + + /// No description provided for @nextRepotting. + /// + /// In de, this message translates to: + /// **'Nächstes Umtopfen: {date}'** + String nextRepotting(String date); + + /// No description provided for @dueToday. + /// + /// In de, this message translates to: + /// **'heute fällig'** + String get dueToday; + /// No description provided for @never. /// /// In de, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 9aa995b..47edebe 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -165,6 +165,24 @@ class AppLocalizationsDe extends AppLocalizations { return 'Zuletzt gedüngt: $date'; } + @override + String nextWatering(String date) { + return 'Nächstes Gießen: $date'; + } + + @override + String nextFertilizing(String date) { + return 'Nächstes Düngen: $date'; + } + + @override + String nextRepotting(String date) { + return 'Nächstes Umtopfen: $date'; + } + + @override + String get dueToday => 'heute fällig'; + @override String get never => 'noch nie'; diff --git a/test/widget_test.dart b/test/widget_test.dart index e7c6391..bbe4b0c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -521,6 +521,9 @@ void main() { await tester.tap(find.text('Monstera')); await tester.pumpAndSettle(); expect(find.textContaining('Fensterbank · ★★★★☆'), findsOneWidget); + // Nächste Fälligkeit wird zusätzlich zum letzten Datum angezeigt. + expect(find.textContaining('Nächstes Gießen:'), findsOneWidget); + expect(find.textContaining('Nächstes Düngen:'), findsOneWidget); // Antippen navigiert zur Stellplatz-Detailseite mit voller Analyse. await tester.tap(find.textContaining('Fensterbank · ★★★★☆')); @@ -763,6 +766,15 @@ void main() { final lastRepotted = (doc.data()?['lastRepotted'] as Timestamp).toDate(); expect(now.difference(lastRepotted).inDays, 0); expect(doc.data()?['lastRepottedBy'], 'test'); + + // Detailseite zeigt jetzt zusätzlich die nächste Fälligkeit an. + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Meine Pflanzen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Monstera')); + await tester.pumpAndSettle(); + expect(find.textContaining('Nächstes Umtopfen:'), findsOneWidget); }); testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async {