V3: Untersuchungshistorie auf eigene Seite ausgelagert

Neue Route /plants/:id/diagnoses mit PlantDiagnosesScreen; im Detail
ersetzt der Button „Frühere Untersuchungen" (deaktiviert bei leerer
Historie) die Inline-Liste. Ergebnis-Sheet nach diagnosis_sheet.dart
ausgelagert und von beiden Screens genutzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-07-23 23:21:21 +02:00
parent 9caaa2ef79
commit 23560768e2
9 changed files with 336 additions and 226 deletions

View file

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

View file

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

View file

@ -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<void> showDiagnosisSheet(
BuildContext context, {
required DiagnosisResult result,
required String plantNickname,
DateTime? createdAt,
}) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
return showModalBottomSheet<void>(
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,
),
),
],
),
);
},
);
}

View file

@ -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<void> _showDiagnosisSheet(
BuildContext context, {
required DiagnosisResult result,
required String plantNickname,
DateTime? createdAt,
}) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
return showModalBottomSheet<void>(
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<void> _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<bool>(
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),
),
],
);
}

View file

@ -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<void> _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<bool>(
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);
}
}
}

View file

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

View file

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

View file

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

View file

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