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:
parent
23560768e2
commit
81d990dd7e
15 changed files with 373 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue