diff --git a/docs/architektur.md b/docs/architektur.md index f6eea04..ff64a0e 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -44,7 +44,7 @@ Jedes Feature folgt demselben Muster (nicht jedes braucht alle Schichten): ## Kernentscheidungen im fachlichen Modell -- **Aufgaben werden nicht gespeichert, sondern berechnet.** Es gibt keine „Task-Tabelle": Aus `lastWatered + wateringIntervalDays` ergibt sich die Fälligkeit. Eine Bestätigung setzt nur `lastWatered` neu — dadurch kann nichts inkonsistent werden, und Überfälliges „wandert" automatisch mit. +- **Aufgaben werden nicht gespeichert, sondern berechnet.** Es gibt keine „Task-Tabelle": Aus `lastWatered + wateringIntervalDays` ergibt sich die Fälligkeit. Eine Bestätigung setzt nur `lastWatered` neu — dadurch kann nichts inkonsistent werden, und Überfälliges „wandert" automatisch mit. Seit V3 gibt es zusätzlich **Umtopfen** als dritten Aufgabentyp — als Einziger opt-in (`repottingIntervalMonths` ist optional; nur wer es setzt, bekommt Erinnerungen) und mit Intervall in Kalender-**Monaten** statt Tagen (ein 31. wird auf den Monatsletzten gekürzt; Logik gespiegelt in App `due_tasks_provider` und Function `reminders.ts`). - **Haushaltszentriert gedacht:** Pflanzen, Stellplätze und Aufgaben gehören dem *Haushalt*, nicht einem Nutzer. In Firestore wird das `households/{id}/plants/{id}` usw. — so ist V2 (Sharing) nur „weiteres Mitglied hinzufügen", keine Datenmigration. - **Mehrere Haushalte pro Nutzer:** Quelle der Wahrheit für die Mitgliedschaft ist das Feld `memberUids` der Haushalte (`myHouseholdsProvider` fragt sie per `arrayContains` ab). `users/{uid}.householdId` ist nur noch der Zeiger auf den gerade **aktiven** Haushalt — Wechseln heißt: diesen Zeiger umstellen (Haushalts-Screen „Meine Haushalte“ oder Wechsler im Menü). Der aktive Haushalt bestimmt Pflanzen-/Stellplatz-Listen und wohin neue Pflanzen wandern; die **Tages-Checkliste und die Sammel-Push aggregieren dagegen über alle Haushalte** (Checkliste mit Zwischenüberschriften, `DueTask` trägt dafür seine Haushalts-Herkunft). Jeder Haushalt hat einen **Besitzer** (`ownerUid`): nur er entfernt Mitglieder (`removeMember`-Function), er kann nicht austreten; alle anderen können per `leaveHousehold`-Function austreten. Umbenennen dürfen volle Mitglieder direkt (Rules lassen am Haushalts-Dokument clientseitig nur noch `name` zu). - **`null` bei `lastWatered` heißt „noch nie"** → Aufgabe ist sofort fällig. So braucht eine neu angelegte Pflanze keine Sonderbehandlung. diff --git a/docs/handoff.md b/docs/handoff.md index 5894c4f..7114122 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -44,7 +44,15 @@ Rules deployt (2026-07-23). **Auf Nutzerwunsch ausgelagert:** Die Historie liegt **Noch zu tun (Nutzer):** App neu bauen und die ausgelagerte Historie-Seite einmal durchklicken (Button → Liste → Eintrag → Sheet; Löschen). -**Danach V3 weiter:** Stellplatz-Bewertung (Zwei-Ebenen-Konzept), Umtopf-Erinnerungen. +### 🟡 V3 Baustein 2 — Umtopf-Erinnerungen: implementiert, wartet auf Deploys + Gerätetest + +**2026-07-23 implementiert:** Dritter Aufgabentyp `repotting` im Intervall-Modell — als Einziger **opt-in** (`repottingIntervalMonths` optional am Pflanzenprofil, leer = keine Erinnerung) und in Kalender-**Monaten**. Formular: „Umtopf-Intervall (Monate)" + Datums-Auswahl „Zuletzt umgetopft" (erscheint nur bei gesetztem Intervall; Intervall leeren verwirft auch das Datum). Detail zeigt die Umtopf-Zeile nur bei gesetztem Intervall. Checkliste/Sammel-Push enthalten Umtopf-Aufgaben (`reminders.ts` gespiegelt, „Umtopfen: …" im Push-Text); Bestätigen schreibt `lastRepotted`/`lastRepottedBy`; Sitter dürfen bestätigen (Rules-Allowlist erweitert). Monats-Arithmetik kürzt den 31. auf den Monatsletzten. Analyzer, 13 Tests (neu: Ende-zu-Ende-Test Checkliste→Bestätigen), tsc, Debug-APK grün. + +**Noch zu tun (Nutzer, Deploys sind in der Session blockiert):** +1. `firebase deploy --only firestore,functions:sendDailyReminders --project leaf-it-to-me-app` +2. Gerätetest: Pflanze bearbeiten → Intervall z. B. 12 Monate + „Zuletzt umgetopft" vor >12 Monaten setzen → Aufgabe „… umtopfen" in der Checkliste; Erledigt-Bestätigung; Sammel-Push müsste „Umtopfen: …" enthalten (nächster regulärer Push-Slot). + +**Danach V3 weiter:** Stellplatz-Bewertung (Zwei-Ebenen-Konzept — Konzept vor Implementierung mit Nutzer abstimmen). ### 🟡 Google-Login: implementiert, wartet auf Konsolen-Schalter + Gerätetest diff --git a/firestore.rules b/firestore.rules index fcf10b3..9895d6e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -89,7 +89,8 @@ service cloud.firestore { && (roleOf(householdId) == 'member' || request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['lastWatered', 'lastWateredBy', - 'lastFertilized', 'lastFertilizedBy'])); + 'lastFertilized', 'lastFertilizedBy', + 'lastRepotted', 'lastRepottedBy'])); // Untersuchungshistorie (KI-Diagnosen). Speichern und lesen darf // jede*r im Haushalt (auch Sitter — die dürfen den Check nutzen); diff --git a/functions/src/reminders.ts b/functions/src/reminders.ts index 992d836..c6c0dc3 100644 --- a/functions/src/reminders.ts +++ b/functions/src/reminders.ts @@ -63,9 +63,19 @@ function daysBetween(a: LocalDate, b: LocalDate): number { interface DueSummary { watering: string[]; fertilizing: string[]; + repotting: string[]; overdueCount: number; } +/** Kalendermonate addieren; der 31. wird auf den Monatsletzten gekürzt. */ +function addMonths(d: LocalDate, months: number): LocalDate { + const total = d.month - 1 + months; + const year = d.year + Math.floor(total / 12); + const month = (total % 12) + 1; + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return {year, month, day: Math.min(d.day, lastDay)}; +} + /** Fällige Aufgaben des Haushalts nach dem Intervall-Modell berechnen. */ async function collectDueTasks( db: admin.firestore.Firestore, @@ -79,7 +89,12 @@ async function collectDueTasks( .collection("plants") .get(); - const summary: DueSummary = {watering: [], fertilizing: [], overdueCount: 0}; + const summary: DueSummary = { + watering: [], + fertilizing: [], + repotting: [], + overdueCount: 0, + }; for (const doc of snapshot.docs) { const plant = doc.data(); const nickname = (plant.nickname as string) ?? "Pflanze"; @@ -112,6 +127,28 @@ async function collectDueTasks( (plant.fertilizingIntervalDays as number) ?? 28, summary.fertilizing ); + + // Umtopfen ist opt-in (Intervall in Monaten); Fälligkeit über + // Kalendermonate, gespiegelt zur App-Logik in due_tasks_provider. + const repotMonths = plant.repottingIntervalMonths as number | undefined; + if (repotMonths) { + const lastRepotted = plant.lastRepotted as + | admin.firestore.Timestamp + | undefined; + let daysOverdue: number | null; + if (!lastRepotted) { + daysOverdue = 0; // noch nie umgetopft → heute fällig + } else { + const lastLocal = inTimezone(lastRepotted.toDate(), timezone); + const due = addMonths(lastLocal, repotMonths); + const overdue = daysBetween(due, today); + daysOverdue = overdue >= 0 ? overdue : null; + } + if (daysOverdue !== null) { + summary.repotting.push(nickname); + if (daysOverdue > 0) summary.overdueCount += 1; + } + } } return summary; } @@ -128,6 +165,9 @@ function buildBody(summary: DueSummary): string { if (summary.fertilizing.length > 0) { parts.push(`Düngen: ${list(summary.fertilizing)}`); } + if (summary.repotting.length > 0) { + parts.push(`Umtopfen: ${list(summary.repotting)}`); + } let body = parts.join(" · "); if (summary.overdueCount > 0) { body += ` (${summary.overdueCount} überfällig)`; @@ -184,6 +224,7 @@ export const sendDailyReminders = onSchedule( const summary: DueSummary = { watering: [], fertilizing: [], + repotting: [], overdueCount: 0, }; for (const householdDoc of memberships.docs) { @@ -195,9 +236,13 @@ export const sendDailyReminders = onSchedule( ); summary.watering.push(...part.watering); summary.fertilizing.push(...part.fertilizing); + summary.repotting.push(...part.repotting); summary.overdueCount += part.overdueCount; } - const taskCount = summary.watering.length + summary.fertilizing.length; + const taskCount = + summary.watering.length + + summary.fertilizing.length + + summary.repotting.length; logger.info("Erinnerungs-Slot erreicht", { uid: userDoc.id, reminderTime, diff --git a/lib/features/plants/data/plants_provider.dart b/lib/features/plants/data/plants_provider.dart index 2e87110..cfc00d4 100644 --- a/lib/features/plants/data/plants_provider.dart +++ b/lib/features/plants/data/plants_provider.dart @@ -25,6 +25,10 @@ Plant _plantFromDoc(String id, Map data) { lastFertilized: (data['lastFertilized'] as Timestamp?)?.toDate(), lastWateredBy: data['lastWateredBy'] as String?, lastFertilizedBy: data['lastFertilizedBy'] as String?, + repottingIntervalMonths: + (data['repottingIntervalMonths'] as num?)?.toInt(), + lastRepotted: (data['lastRepotted'] as Timestamp?)?.toDate(), + lastRepottedBy: data['lastRepottedBy'] as String?, ); } @@ -45,6 +49,11 @@ Map _plantToMap(Plant plant) { : null, 'lastWateredBy': plant.lastWateredBy, 'lastFertilizedBy': plant.lastFertilizedBy, + 'repottingIntervalMonths': plant.repottingIntervalMonths, + 'lastRepotted': plant.lastRepotted != null + ? Timestamp.fromDate(plant.lastRepotted!) + : null, + 'lastRepottedBy': plant.lastRepottedBy, }; } @@ -116,6 +125,7 @@ class PlantRepository { final field = switch (type) { CareTaskType.watering => 'lastWatered', CareTaskType.fertilizing => 'lastFertilized', + CareTaskType.repotting => 'lastRepotted', }; return _firestore .collection('households') diff --git a/lib/features/plants/domain/plant.dart b/lib/features/plants/domain/plant.dart index e1d66c1..b5444d2 100644 --- a/lib/features/plants/domain/plant.dart +++ b/lib/features/plants/domain/plant.dart @@ -21,6 +21,9 @@ class Plant { this.lastFertilized, this.lastWateredBy, this.lastFertilizedBy, + this.repottingIntervalMonths, + this.lastRepotted, + this.lastRepottedBy, }); final String id; @@ -53,6 +56,13 @@ class Plant { final String? lastWateredBy; final String? lastFertilizedBy; + /// Umtopf-Erinnerung (V3), optional: `null` = keine Erinnerung gewünscht. + /// Bei gesetztem Intervall und `lastRepotted == null` ist die Aufgabe + /// sofort fällig (wie bei Gießen/Düngen: „noch nie erledigt"). + final int? repottingIntervalMonths; + final DateTime? lastRepotted; + final String? lastRepottedBy; + Plant copyWith({ String? nickname, String? species, @@ -66,6 +76,10 @@ class Plant { DateTime? lastFertilized, String? lastWateredBy, String? lastFertilizedBy, + // Als Getter, damit sich die Felder auch auf null zurücksetzen lassen + // (Intervall entfernen = Erinnerung abschalten). + int? Function()? repottingIntervalMonths, + DateTime? Function()? lastRepotted, }) { return Plant( id: id, @@ -82,6 +96,14 @@ class Plant { lastFertilized: lastFertilized ?? this.lastFertilized, lastWateredBy: lastWateredBy ?? this.lastWateredBy, lastFertilizedBy: lastFertilizedBy ?? this.lastFertilizedBy, + repottingIntervalMonths: repottingIntervalMonths != null + ? repottingIntervalMonths() + : this.repottingIntervalMonths, + lastRepotted: + lastRepotted != null ? lastRepotted() : this.lastRepotted, + // Bewusst nicht per copyWith änderbar — wird nur beim Bestätigen + // einer Umtopf-Aufgabe direkt in Firestore gesetzt. + lastRepottedBy: lastRepottedBy, ); } } diff --git a/lib/features/plants/presentation/plant_detail_screen.dart b/lib/features/plants/presentation/plant_detail_screen.dart index c4b3cdd..e050cfb 100644 --- a/lib/features/plants/presentation/plant_detail_screen.dart +++ b/lib/features/plants/presentation/plant_detail_screen.dart @@ -118,6 +118,15 @@ class PlantDetailScreen extends ConsumerWidget { ? l10n.doneBy(plant.lastFertilizedBy!) : ''), ), + 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!) + : ''), + ), if (plant.description.isNotEmpty) ...[ const Divider(height: 32), Text( diff --git a/lib/features/plants/presentation/plant_form_screen.dart b/lib/features/plants/presentation/plant_form_screen.dart index 6ac025c..79fb25b 100644 --- a/lib/features/plants/presentation/plant_form_screen.dart +++ b/lib/features/plants/presentation/plant_form_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../locations/data/locations_provider.dart'; @@ -33,7 +34,9 @@ class _PlantFormScreenState extends ConsumerState { late final TextEditingController _speciesController; late final TextEditingController _wateringController; late final TextEditingController _fertilizingController; + late final TextEditingController _repottingController; String? _locationId; + DateTime? _lastRepotted; Uint8List? _photoBytes; bool _recognizing = false; @@ -51,6 +54,9 @@ class _PlantFormScreenState extends ConsumerState { text: existing?.wateringIntervalDays.toString() ?? '7'); _fertilizingController = TextEditingController( text: existing?.fertilizingIntervalDays.toString() ?? '28'); + _repottingController = TextEditingController( + text: existing?.repottingIntervalMonths?.toString() ?? ''); + _lastRepotted = existing?.lastRepotted; _locationId = existing?.locationId; _description = existing?.description ?? ''; _careNotes = existing?.careNotes ?? ''; @@ -62,6 +68,7 @@ class _PlantFormScreenState extends ConsumerState { _speciesController.dispose(); _wateringController.dispose(); _fertilizingController.dispose(); + _repottingController.dispose(); super.dispose(); } @@ -125,6 +132,9 @@ class _PlantFormScreenState extends ConsumerState { final repository = ref.read(plantRepositoryProvider); final existing = widget.existing; + // Leeres Feld = Umtopf-Erinnerung aus; dann auch das Datum verwerfen. + final repotMonths = int.tryParse(_repottingController.text.trim()); + final lastRepotted = repotMonths != null ? _lastRepotted : null; if (existing != null) { await repository.updatePlant(existing.copyWith( nickname: _nicknameController.text.trim(), @@ -136,6 +146,8 @@ class _PlantFormScreenState extends ConsumerState { wateringIntervalDays: int.parse(_wateringController.text.trim()), fertilizingIntervalDays: int.parse(_fertilizingController.text.trim()), + repottingIntervalMonths: () => repotMonths, + lastRepotted: () => lastRepotted, )); } else { await repository.addPlant(Plant( @@ -149,6 +161,8 @@ class _PlantFormScreenState extends ConsumerState { wateringIntervalDays: int.parse(_wateringController.text.trim()), fertilizingIntervalDays: int.parse(_fertilizingController.text.trim()), + repottingIntervalMonths: repotMonths, + lastRepotted: lastRepotted, )); } if (mounted) context.pop(); @@ -183,6 +197,12 @@ class _PlantFormScreenState extends ConsumerState { return null; } + /// Wie [_validateInterval], aber leer ist erlaubt (Erinnerung aus). + String? _validateOptionalInterval(String? value) { + if (value == null || value.trim().isEmpty) return null; + return _validateInterval(value); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -266,9 +286,62 @@ class _PlantFormScreenState extends ConsumerState { prefixIcon: const Icon(Icons.compost), ), keyboardType: TextInputType.number, - textInputAction: TextInputAction.done, + textInputAction: TextInputAction.next, validator: _validateInterval, ), + const SizedBox(height: 16), + TextFormField( + controller: _repottingController, + decoration: InputDecoration( + labelText: l10n.repottingIntervalLabel, + helperText: l10n.repottingIntervalHelper, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.yard), + ), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + validator: _validateOptionalInterval, + onChanged: (_) => setState(() {}), + ), + if (_repottingController.text.trim().isNotEmpty) ...[ + const SizedBox(height: 16), + // Startpunkt der Umtopf-Erinnerung; ohne Datum gilt die + // Aufgabe als „noch nie erledigt" und ist sofort fällig. + InputDecorator( + decoration: InputDecoration( + labelText: l10n.lastRepottedLabel, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.event), + ), + child: Row( + children: [ + Expanded( + child: Text( + _lastRepotted != null + ? DateFormat('d. MMMM y', + Localizations.localeOf(context).toString()) + .format(_lastRepotted!) + : l10n.never, + ), + ), + TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _lastRepotted ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now(), + ); + if (picked != null) { + setState(() => _lastRepotted = picked); + } + }, + child: Text(l10n.pickDate), + ), + ], + ), + ), + ], const SizedBox(height: 24), FilledButton.icon( onPressed: _saving || _recognizing ? null : _save, diff --git a/lib/features/today/application/due_tasks_provider.dart b/lib/features/today/application/due_tasks_provider.dart index 14b7acb..db90a9d 100644 --- a/lib/features/today/application/due_tasks_provider.dart +++ b/lib/features/today/application/due_tasks_provider.dart @@ -14,6 +14,25 @@ DateTime? _nextDue(DateTime? lastDone, int intervalDays, DateTime today) { return _dateOnly(lastDone).add(Duration(days: intervalDays)); } +/// Kalendermonate addieren; ein 31. wird notfalls auf den Monatsletzten +/// gekürzt (31. Jan + 1 Monat → 28./29. Feb). +DateTime _addMonths(DateTime date, int months) { + final totalMonths = date.month - 1 + months; + final year = date.year + totalMonths ~/ 12; + final month = totalMonths % 12 + 1; + final lastDayOfMonth = DateTime(year, month + 1, 0).day; + return DateTime( + year, month, date.day > lastDayOfMonth ? lastDayOfMonth : date.day); +} + +/// Fälligkeit fürs Umtopfen — nur wenn ein Intervall gesetzt ist (opt-in). +DateTime? _nextRepotDue(Plant plant, DateTime today) { + final months = plant.repottingIntervalMonths; + if (months == null) return null; + if (plant.lastRepotted == null) return today; + return _addMonths(_dateOnly(plant.lastRepotted!), months); +} + Iterable _tasksForPlant( Plant plant, Household household, DateTime today) sync* { final watering = _nextDue(plant.lastWatered, plant.wateringIntervalDays, today); @@ -37,6 +56,16 @@ Iterable _tasksForPlant( householdName: household.name, ); } + final repotting = _nextRepotDue(plant, today); + if (repotting != null && !repotting.isAfter(today)) { + yield DueTask( + plant: plant, + type: CareTaskType.repotting, + dueDate: repotting, + householdId: household.id, + householdName: household.name, + ); + } } /// Alle heute fälligen und überfälligen Aufgaben aus **allen** Haushalten @@ -112,6 +141,7 @@ final nextDueDateProvider = Provider((ref) { for (final candidate in [ _nextDue(plant.lastWatered, plant.wateringIntervalDays, today), _nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today), + _nextRepotDue(plant, today), ]) { if (candidate == null || !candidate.isAfter(today)) continue; if (next == null || candidate.isBefore(next)) next = candidate; diff --git a/lib/features/today/domain/due_task.dart b/lib/features/today/domain/due_task.dart index e95dfc1..95b50da 100644 --- a/lib/features/today/domain/due_task.dart +++ b/lib/features/today/domain/due_task.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import '../../plants/domain/plant.dart'; -enum CareTaskType { watering, fertilizing } +enum CareTaskType { watering, fertilizing, repotting } /// Eine heute fällige (oder überfällige) Pflegeaufgabe. /// diff --git a/lib/features/today/presentation/today_screen.dart b/lib/features/today/presentation/today_screen.dart index a13ab04..31c81cf 100644 --- a/lib/features/today/presentation/today_screen.dart +++ b/lib/features/today/presentation/today_screen.dart @@ -121,10 +121,12 @@ class _TaskCard extends ConsumerWidget { final title = switch (task.type) { CareTaskType.watering => l10n.taskWaterTitle(task.plant.nickname), CareTaskType.fertilizing => l10n.taskFertilizeTitle(task.plant.nickname), + CareTaskType.repotting => l10n.taskRepotTitle(task.plant.nickname), }; final icon = switch (task.type) { CareTaskType.watering => Icons.water_drop, CareTaskType.fertilizing => Icons.compost, + CareTaskType.repotting => Icons.yard, }; return Card( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0a339b1..38119cc 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -70,6 +70,28 @@ }, "wateringIntervalLabel": "Gieß-Intervall (Tage)", "fertilizingIntervalLabel": "Dünge-Intervall (Tage)", + "repottingIntervalLabel": "Umtopf-Intervall (Monate)", + "repottingIntervalHelper": "Leer lassen, wenn du keine Umtopf-Erinnerung möchtest.", + "lastRepottedLabel": "Zuletzt umgetopft", + "pickDate": "Datum wählen", + "taskRepotTitle": "{name} umtopfen", + "@taskRepotTitle": { + "placeholders": { + "name": { "type": "String" } + } + }, + "repottingEvery": "Umtopfen: alle {months} Monate", + "@repottingEvery": { + "placeholders": { + "months": { "type": "int" } + } + }, + "lastRepotted": "Zuletzt umgetopft: {date}", + "@lastRepotted": { + "placeholders": { + "date": { "type": "String" } + } + }, "lastWatered": "Zuletzt gegossen: {date}", "@lastWatered": { "placeholders": { diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index f16d4a8..faccec8 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -274,6 +274,48 @@ abstract class AppLocalizations { /// **'Dünge-Intervall (Tage)'** String get fertilizingIntervalLabel; + /// No description provided for @repottingIntervalLabel. + /// + /// In de, this message translates to: + /// **'Umtopf-Intervall (Monate)'** + String get repottingIntervalLabel; + + /// No description provided for @repottingIntervalHelper. + /// + /// In de, this message translates to: + /// **'Leer lassen, wenn du keine Umtopf-Erinnerung möchtest.'** + String get repottingIntervalHelper; + + /// No description provided for @lastRepottedLabel. + /// + /// In de, this message translates to: + /// **'Zuletzt umgetopft'** + String get lastRepottedLabel; + + /// No description provided for @pickDate. + /// + /// In de, this message translates to: + /// **'Datum wählen'** + String get pickDate; + + /// No description provided for @taskRepotTitle. + /// + /// In de, this message translates to: + /// **'{name} umtopfen'** + String taskRepotTitle(String name); + + /// No description provided for @repottingEvery. + /// + /// In de, this message translates to: + /// **'Umtopfen: alle {months} Monate'** + String repottingEvery(int months); + + /// No description provided for @lastRepotted. + /// + /// In de, this message translates to: + /// **'Zuletzt umgetopft: {date}'** + String lastRepotted(String date); + /// No description provided for @lastWatered. /// /// 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 09a4911..9cc7f78 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -127,6 +127,34 @@ class AppLocalizationsDe extends AppLocalizations { @override String get fertilizingIntervalLabel => 'Dünge-Intervall (Tage)'; + @override + String get repottingIntervalLabel => 'Umtopf-Intervall (Monate)'; + + @override + String get repottingIntervalHelper => + 'Leer lassen, wenn du keine Umtopf-Erinnerung möchtest.'; + + @override + String get lastRepottedLabel => 'Zuletzt umgetopft'; + + @override + String get pickDate => 'Datum wählen'; + + @override + String taskRepotTitle(String name) { + return '$name umtopfen'; + } + + @override + String repottingEvery(int months) { + return 'Umtopfen: alle $months Monate'; + } + + @override + String lastRepotted(String date) { + return 'Zuletzt umgetopft: $date'; + } + @override String lastWatered(String date) { return 'Zuletzt gegossen: $date'; diff --git a/test/widget_test.dart b/test/widget_test.dart index b9e1a66..e2eb87f 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -444,6 +444,80 @@ void main() { expect(button.onPressed, isNull); }); + testWidgets( + 'Umtopf-Erinnerung: fällige Aufgabe erscheint in der Checkliste und ' + 'Bestätigen schreibt lastRepotted', (tester) 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', + 'ownerUid': 'u1', + 'memberUids': ['u1'], + 'members': { + 'u1': {'role': 'member', 'email': 'test@example.com'}, + }, + }); + final now = DateTime.now(); + // Gießen/Düngen frisch erledigt — nur das Umtopfen (vor 13 Monaten, + // Intervall 12 Monate) ist überfällig. + await firestore + .collection('households') + .doc('h1') + .collection('plants') + .doc('p1') + .set({ + 'nickname': 'Monstera', + 'species': 'Monstera deliciosa', + 'locationId': null, + 'description': '', + 'careNotes': '', + 'wateringIntervalDays': 7, + 'fertilizingIntervalDays': 28, + 'lastWatered': Timestamp.fromDate(now), + 'lastFertilized': Timestamp.fromDate(now), + 'repottingIntervalMonths': 12, + 'lastRepotted': Timestamp.fromDate( + DateTime(now.year - 1, now.month - 1, now.day)), + }); + + await tester.pumpWidget(ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + firebaseAuthProvider.overrideWithValue(auth), + firestoreProvider.overrideWithValue(firestore), + pushRegistrationServiceProvider + .overrideWithValue(_FakePushRegistrationService()), + ], + child: const LeafItToMeApp(), + )); + await tester.pumpAndSettle(); + + expect(find.textContaining('Monstera umtopfen'), findsOneWidget); + + await tester.tap(find.text('Erledigt')); + await tester.pumpAndSettle(); + + expect(find.text('Alles versorgt!'), findsOneWidget); + final doc = await firestore + .collection('households') + .doc('h1') + .collection('plants') + .doc('p1') + .get(); + final lastRepotted = (doc.data()?['lastRepotted'] as Timestamp).toDate(); + expect(now.difference(lastRepotted).inDays, 0); + expect(doc.data()?['lastRepottedBy'], 'test'); + }); + testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async { await tester.pumpWidget(await buildTestApp()); await tester.pumpAndSettle();