diff --git a/docs/handoff.md b/docs/handoff.md index f74522b..5894c4f 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -40,9 +40,9 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si **Erweiterung Untersuchungshistorie (2026-07-23):** Jede Diagnose wird automatisch unter `households/{id}/plants/{id}/diagnoses` gespeichert (healthy, matchesSpecies, Texte, `createdAt` Server-Timestamp). Im Pflanzen-Detail unter dem Untersuchen-Button: Liste „Untersuchungen" (neueste zuerst, Ampel-Icon + Kurzbefund + Datum), Tipp öffnet dasselbe Ergebnis-Sheet inkl. „Untersucht am …". Volle Mitglieder können Einträge löschen (Bestätigungsdialog); Sitter dürfen speichern/lesen, Einträge sind nachträglich unveränderbar (Rules). Neuer Widget-Test navigiert bis ins Sheet — alle 11 Tests grün. -**Noch zu tun (Nutzer):** -1. **Rules-Deploy** (neue diagnoses-Regeln; ohne sie schlägt das Speichern der Historie still fehl): `firebase deploy --only firestore --project leaf-it-to-me-app` -2. App neu bauen, Untersuchung durchführen → Eintrag erscheint in „Untersuchungen", antippen zeigt den vollen Text, Löschen fragt nach. +Rules deployt (2026-07-23). **Auf Nutzerwunsch ausgelagert:** Die Historie liegt jetzt auf einer eigenen Seite (`/plants/:id/diagnoses`, `PlantDiagnosesScreen`) statt inline im Detail; im Detail führt der Button „Frühere Untersuchungen" dorthin (deaktiviert/ausgegraut, solange keine Untersuchungen existieren). Das Ergebnis-Sheet ist in `diagnosis_sheet.dart` ausgelagert und wird von Detail (frische Untersuchung) und Historie-Seite gemeinsam genutzt. 12 Tests grün (neu: Button-deaktiviert-Test). + +**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. diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index ed24662..9fc76a3 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -9,6 +9,7 @@ import '../../features/household/presentation/household_screen.dart'; import '../../features/locations/presentation/locations_screen.dart'; import '../../features/plants/domain/plant.dart'; import '../../features/plants/presentation/plant_detail_screen.dart'; +import '../../features/plants/presentation/plant_diagnoses_screen.dart'; import '../../features/plants/presentation/plant_form_screen.dart'; import '../../features/plants/presentation/plants_screen.dart'; import '../../features/settings/presentation/settings_screen.dart'; @@ -26,6 +27,10 @@ abstract final class AppRoutes { static const locations = '/locations'; static const household = '/household'; static const settings = '/settings'; + + /// Untersuchungs-Seite einer Pflanze. + static String plantDiagnoses(String plantId) => + '/plants/$plantId/diagnoses'; } /// Übersetzt einen Stream in ein Listenable, damit der Router bei @@ -86,6 +91,11 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => PlantDetailScreen(plantId: state.pathParameters['id']!), ), + GoRoute( + path: '/plants/:id/diagnoses', + builder: (context, state) => + PlantDiagnosesScreen(plantId: state.pathParameters['id']!), + ), GoRoute( path: AppRoutes.locations, builder: (context, state) => const LocationsScreen(), diff --git a/lib/features/plants/presentation/diagnosis_sheet.dart b/lib/features/plants/presentation/diagnosis_sheet.dart new file mode 100644 index 0000000..7037922 --- /dev/null +++ b/lib/features/plants/presentation/diagnosis_sheet.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../data/plant_diagnosis_service.dart'; + +/// Zeigt das Diagnose-Ergebnis als scrollbares Bottom-Sheet — sowohl für +/// eine frische Untersuchung (Detail-Screen) als auch für Einträge aus der +/// Untersuchungs-Seite ([createdAt] gesetzt). +Future showDiagnosisSheet( + BuildContext context, { + required DiagnosisResult result, + required String plantNickname, + DateTime? createdAt, +}) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toString(); + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) { + final theme = Theme.of(sheetContext); + final healthyColor = theme.brightness == Brightness.dark + ? Colors.green.shade300 + : Colors.green.shade700; + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + maxChildSize: 0.95, + builder: (context, scrollController) => ListView( + controller: scrollController, + padding: const EdgeInsets.all(24), + children: [ + if (!result.matchesSpecies) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.swap_horiz, + color: theme.colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.diagnosisSpeciesMismatch(plantNickname), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + result.healthy + ? Icons.check_circle + : Icons.warning_amber_rounded, + size: 32, + color: + result.healthy ? healthyColor : theme.colorScheme.error, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + result.summary, + style: theme.textTheme.titleLarge, + ), + ), + ], + ), + if (createdAt != null) ...[ + const SizedBox(height: 8), + Text( + l10n.diagnosisDate( + DateFormat('d. MMMM y, HH:mm', locale).format(createdAt), + ), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (result.details.isNotEmpty) ...[ + const SizedBox(height: 16), + Text(result.details, style: theme.textTheme.bodyLarge), + ], + if (result.treatment.isNotEmpty) ...[ + const SizedBox(height: 20), + Text( + l10n.diagnosisTreatmentTitle, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text(result.treatment, style: theme.textTheme.bodyLarge), + ], + if (result.prevention.isNotEmpty) ...[ + const SizedBox(height: 20), + Text( + l10n.diagnosisPreventionTitle, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text(result.prevention, style: theme.textTheme.bodyLarge), + ], + const SizedBox(height: 16), + Text( + l10n.diagnosisDisclaimer, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + ); +} diff --git a/lib/features/plants/presentation/plant_detail_screen.dart b/lib/features/plants/presentation/plant_detail_screen.dart index dbaa5eb..c4b3cdd 100644 --- a/lib/features/plants/presentation/plant_detail_screen.dart +++ b/lib/features/plants/presentation/plant_detail_screen.dart @@ -13,6 +13,7 @@ import '../../locations/data/locations_provider.dart'; import '../data/plant_diagnosis_service.dart'; import '../data/plants_provider.dart'; import '../domain/plant.dart'; +import 'diagnosis_sheet.dart'; class PlantDetailScreen extends ConsumerWidget { const PlantDetailScreen({super.key, required this.plantId}); @@ -150,230 +151,12 @@ class PlantDetailScreen extends ConsumerWidget { ], const Divider(height: 32), _DiagnosisSection(plant: plant), - _DiagnosisHistory(plant: plant), ], ), ); } } -/// Zeigt das Diagnose-Ergebnis als scrollbares Bottom-Sheet — sowohl für -/// eine frische Untersuchung als auch für Einträge aus der Historie -/// ([createdAt] gesetzt). -Future _showDiagnosisSheet( - BuildContext context, { - required DiagnosisResult result, - required String plantNickname, - DateTime? createdAt, -}) { - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toString(); - return showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (sheetContext) { - final theme = Theme.of(sheetContext); - final healthyColor = theme.brightness == Brightness.dark - ? Colors.green.shade300 - : Colors.green.shade700; - return DraggableScrollableSheet( - expand: false, - initialChildSize: 0.6, - maxChildSize: 0.95, - builder: (context, scrollController) => ListView( - controller: scrollController, - padding: const EdgeInsets.all(24), - children: [ - if (!result.matchesSpecies) ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.swap_horiz, - color: theme.colorScheme.onErrorContainer, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - l10n.diagnosisSpeciesMismatch(plantNickname), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - ], - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - result.healthy - ? Icons.check_circle - : Icons.warning_amber_rounded, - size: 32, - color: - result.healthy ? healthyColor : theme.colorScheme.error, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - result.summary, - style: theme.textTheme.titleLarge, - ), - ), - ], - ), - if (createdAt != null) ...[ - const SizedBox(height: 8), - Text( - l10n.diagnosisDate( - DateFormat('d. MMMM y, HH:mm', locale).format(createdAt), - ), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - if (result.details.isNotEmpty) ...[ - const SizedBox(height: 16), - Text(result.details, style: theme.textTheme.bodyLarge), - ], - if (result.treatment.isNotEmpty) ...[ - const SizedBox(height: 20), - Text( - l10n.diagnosisTreatmentTitle, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text(result.treatment, style: theme.textTheme.bodyLarge), - ], - if (result.prevention.isNotEmpty) ...[ - const SizedBox(height: 20), - Text( - l10n.diagnosisPreventionTitle, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text(result.prevention, style: theme.textTheme.bodyLarge), - ], - const SizedBox(height: 16), - Text( - l10n.diagnosisDisclaimer, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - }, - ); -} - -/// Liste der gespeicherten Untersuchungen dieser Pflanze (neueste zuerst). -class _DiagnosisHistory extends ConsumerWidget { - const _DiagnosisHistory({required this.plant}); - - final Plant plant; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - final entries = ref.watch(plantDiagnosesProvider(plant.id)).value ?? []; - if (entries.isEmpty) return const SizedBox.shrink(); - - final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member; - final locale = Localizations.localeOf(context).toString(); - final dateFormat = DateFormat('d. MMMM y, HH:mm', locale); - final healthyColor = theme.brightness == Brightness.dark - ? Colors.green.shade300 - : Colors.green.shade700; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 24), - Text(l10n.diagnosisHistoryTitle, style: theme.textTheme.titleMedium), - for (final entry in entries) - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - entry.result.healthy - ? Icons.check_circle - : Icons.warning_amber_rounded, - color: entry.result.healthy - ? healthyColor - : theme.colorScheme.error, - ), - title: Text( - entry.result.summary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: entry.createdAt != null - ? Text(dateFormat.format(entry.createdAt!)) - : null, - trailing: canEdit - ? IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: l10n.delete, - onPressed: () => _confirmDelete(context, ref, entry), - ) - : null, - onTap: () => _showDiagnosisSheet( - context, - result: entry.result, - plantNickname: plant.nickname, - createdAt: entry.createdAt, - ), - ), - ], - ); - } - - Future _confirmDelete( - BuildContext context, - WidgetRef ref, - PlantDiagnosisEntry entry, - ) async { - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toString(); - final dateText = entry.createdAt != null - ? DateFormat('d. MMMM y', locale).format(entry.createdAt!) - : ''; - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(l10n.deleteDiagnosisQuestion(dateText)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(l10n.delete), - ), - ], - ), - ); - if (confirmed == true) { - await ref - .read(plantDiagnosisServiceProvider) - .deleteDiagnosis(plant.id, entry.id); - } - } -} /// Krankheits-Check (V3): Foto aufnehmen → Claude beurteilt den Zustand /// und gibt Behandlungstipps. Auch für Sitter nutzbar (nur lesend). @@ -437,7 +220,7 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> { debugPrint('Untersuchung speichern fehlgeschlagen: $error'); } if (!mounted) return; - await _showDiagnosisSheet( + await showDiagnosisSheet( context, result: result, plantNickname: widget.plant.nickname, @@ -465,6 +248,13 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + // Für den Historie-Button: deaktiviert, solange keine Untersuchungen + // gespeichert sind (oder die Liste noch lädt). + final hasHistory = (ref + .watch(plantDiagnosesProvider(widget.plant.id)) + .value ?? + const []) + .isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -487,6 +277,15 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> { ), textAlign: TextAlign.center, ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: hasHistory + ? () => context + .push(AppRoutes.plantDiagnoses(widget.plant.id)) + : null, + icon: const Icon(Icons.history), + label: Text(l10n.diagnosisHistoryButton), + ), ], ); } diff --git a/lib/features/plants/presentation/plant_diagnoses_screen.dart b/lib/features/plants/presentation/plant_diagnoses_screen.dart new file mode 100644 index 0000000..8cb3b42 --- /dev/null +++ b/lib/features/plants/presentation/plant_diagnoses_screen.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../household/data/household_providers.dart'; +import '../../household/domain/household.dart'; +import '../data/plant_diagnosis_service.dart'; +import '../data/plants_provider.dart'; +import 'diagnosis_sheet.dart'; + +/// Untersuchungs-Seite einer Pflanze: alle gespeicherten Diagnosen +/// (neueste zuerst); Antippen öffnet das volle Ergebnis, volle Mitglieder +/// können Einträge löschen. +class PlantDiagnosesScreen extends ConsumerWidget { + const PlantDiagnosesScreen({super.key, required this.plantId}); + + final String plantId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final plant = ref.watch(plantByIdProvider(plantId)); + + if (plant == null) { + // Pflanze wurde gelöscht, während der Screen offen war. + return Scaffold(appBar: AppBar(), body: const SizedBox.shrink()); + } + + final entries = ref.watch(plantDiagnosesProvider(plantId)).value ?? []; + final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member; + final locale = Localizations.localeOf(context).toString(); + final dateFormat = DateFormat('d. MMMM y, HH:mm', locale); + final healthyColor = theme.brightness == Brightness.dark + ? Colors.green.shade300 + : Colors.green.shade700; + + return Scaffold( + appBar: AppBar( + title: Text('${l10n.diagnosisHistoryTitle} · ${plant.nickname}'), + ), + body: entries.isEmpty + // Kommt nur vor, wenn hier der letzte Eintrag gelöscht wurde — + // der Button im Detail ist bei leerer Historie deaktiviert. + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + l10n.diagnosisHistoryEmpty, + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + return ListTile( + leading: Icon( + entry.result.healthy + ? Icons.check_circle + : Icons.warning_amber_rounded, + color: entry.result.healthy + ? healthyColor + : theme.colorScheme.error, + ), + title: Text( + entry.result.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: entry.createdAt != null + ? Text(dateFormat.format(entry.createdAt!)) + : null, + trailing: canEdit + ? IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: l10n.delete, + onPressed: () => _confirmDelete(context, ref, entry), + ) + : null, + onTap: () => showDiagnosisSheet( + context, + result: entry.result, + plantNickname: plant.nickname, + createdAt: entry.createdAt, + ), + ); + }, + ), + ); + } + + Future _confirmDelete( + BuildContext context, + WidgetRef ref, + PlantDiagnosisEntry entry, + ) async { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toString(); + final dateText = entry.createdAt != null + ? DateFormat('d. MMMM y', locale).format(entry.createdAt!) + : ''; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.deleteDiagnosisQuestion(dateText)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.delete), + ), + ], + ), + ); + if (confirmed == true) { + await ref + .read(plantDiagnosisServiceProvider) + .deleteDiagnosis(plantId, entry.id); + } + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d919182..0a339b1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -241,6 +241,8 @@ }, "diagnosisFailed": "Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.", "diagnosisHistoryTitle": "Untersuchungen", + "diagnosisHistoryButton": "Frühere Untersuchungen", + "diagnosisHistoryEmpty": "Noch keine Untersuchungen gespeichert.", "diagnosisDate": "Untersucht am {date}", "@diagnosisDate": { "placeholders": { diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 9f80b6a..f16d4a8 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -880,6 +880,18 @@ abstract class AppLocalizations { /// **'Untersuchungen'** String get diagnosisHistoryTitle; + /// No description provided for @diagnosisHistoryButton. + /// + /// In de, this message translates to: + /// **'Frühere Untersuchungen'** + String get diagnosisHistoryButton; + + /// No description provided for @diagnosisHistoryEmpty. + /// + /// In de, this message translates to: + /// **'Noch keine Untersuchungen gespeichert.'** + String get diagnosisHistoryEmpty; + /// No description provided for @diagnosisDate. /// /// 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 c7ef0a0..09a4911 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -481,6 +481,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diagnosisHistoryTitle => 'Untersuchungen'; + @override + String get diagnosisHistoryButton => 'Frühere Untersuchungen'; + + @override + String get diagnosisHistoryEmpty => 'Noch keine Untersuchungen gespeichert.'; + @override String diagnosisDate(String date) { return 'Untersucht am $date'; diff --git a/test/widget_test.dart b/test/widget_test.dart index c7dd011..b9e1a66 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -406,10 +406,14 @@ void main() { await tester.tap(find.text('Monstera')); await tester.pumpAndSettle(); - // Historie ist sichtbar. - await tester.scrollUntilVisible( - find.text('Die Blätter zeigen braune Flecken.'), 200); - expect(find.text('Untersuchungen'), findsOneWidget); + // Historie-Button ist aktiv (Untersuchungen vorhanden) → Seite öffnen. + await tester.scrollUntilVisible(find.text('Frühere Untersuchungen'), 200); + await tester.tap(find.text('Frühere Untersuchungen')); + await tester.pumpAndSettle(); + + // Untersuchungs-Seite listet den Eintrag. + expect(find.textContaining('Untersuchungen'), findsWidgets); + expect(find.text('Die Blätter zeigen braune Flecken.'), findsOneWidget); expect(find.textContaining('20. Juli 2026'), findsOneWidget); // Eintrag öffnen → volles Ergebnis-Sheet mit Behandlungstipps. @@ -419,6 +423,27 @@ void main() { expect(find.text('Stelle die Pflanze etwas schattiger.'), findsOneWidget); }); + testWidgets( + 'Ohne gespeicherte Untersuchungen ist der Historie-Button deaktiviert', + (tester) async { + await tester.pumpWidget(await buildTestApp()); + await tester.pumpAndSettle(); + + 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(); + + await tester.scrollUntilVisible(find.text('Frühere Untersuchungen'), 200); + final button = tester.widget(find.ancestor( + of: find.text('Frühere Untersuchungen'), + matching: find.byType(OutlinedButton), + )); + expect(button.onPressed, isNull); + }); + testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async { await tester.pumpWidget(await buildTestApp()); await tester.pumpAndSettle();