V3: Umtopf-Erinnerungen (opt-in, Intervall in Monaten)

Dritter Aufgabentyp repotting im Intervall-Modell: optionales
repottingIntervalMonths + lastRepotted/By am Pflanzenprofil, Formular
mit Intervall-Feld und Datums-Auswahl, Umtopf-Zeile im Detail,
Aufgaben in Checkliste und Sammel-Push (reminders.ts gespiegelt),
Sitter dürfen bestätigen (Rules-Allowlist). Monats-Arithmetik kürzt
den 31. auf den Monatsletzten. Ende-zu-Ende-Widget-Test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-07-23 23:40:21 +02:00
parent 23560768e2
commit 81d990dd7e
15 changed files with 373 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,10 @@ Plant _plantFromDoc(String id, Map<String, dynamic> 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<String, dynamic> _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')

View file

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

View file

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

View file

@ -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<PlantFormScreen> {
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<PlantFormScreen> {
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<PlantFormScreen> {
_speciesController.dispose();
_wateringController.dispose();
_fertilizingController.dispose();
_repottingController.dispose();
super.dispose();
}
@ -125,6 +132,9 @@ class _PlantFormScreenState extends ConsumerState<PlantFormScreen> {
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<PlantFormScreen> {
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<PlantFormScreen> {
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<PlantFormScreen> {
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<PlantFormScreen> {
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,

View file

@ -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<DueTask> _tasksForPlant(
Plant plant, Household household, DateTime today) sync* {
final watering = _nextDue(plant.lastWatered, plant.wateringIntervalDays, today);
@ -37,6 +56,16 @@ Iterable<DueTask> _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<DateTime?>((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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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