V3: Nächstes Fälligkeitsdatum bei Gießen/Düngen/Umtopfen in Pflanzen-Detail

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 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-08-19 15:13:04 +02:00
parent 5e39ec8118
commit e81ce48543
7 changed files with 107 additions and 24 deletions

View file

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

View file

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

View file

@ -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<DueTask> _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<DueTask> _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<DueTask> _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<DateTime?>((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;

View file

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

View file

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

View file

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

View file

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