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:
parent
9caaa2ef79
commit
23560768e2
9 changed files with 336 additions and 226 deletions
|
|
@ -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.
|
**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):**
|
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).
|
||||||
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.
|
**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.
|
**Danach V3 weiter:** Stellplatz-Bewertung (Zwei-Ebenen-Konzept), Umtopf-Erinnerungen.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../../features/household/presentation/household_screen.dart';
|
||||||
import '../../features/locations/presentation/locations_screen.dart';
|
import '../../features/locations/presentation/locations_screen.dart';
|
||||||
import '../../features/plants/domain/plant.dart';
|
import '../../features/plants/domain/plant.dart';
|
||||||
import '../../features/plants/presentation/plant_detail_screen.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/plant_form_screen.dart';
|
||||||
import '../../features/plants/presentation/plants_screen.dart';
|
import '../../features/plants/presentation/plants_screen.dart';
|
||||||
import '../../features/settings/presentation/settings_screen.dart';
|
import '../../features/settings/presentation/settings_screen.dart';
|
||||||
|
|
@ -26,6 +27,10 @@ abstract final class AppRoutes {
|
||||||
static const locations = '/locations';
|
static const locations = '/locations';
|
||||||
static const household = '/household';
|
static const household = '/household';
|
||||||
static const settings = '/settings';
|
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
|
/// Übersetzt einen Stream in ein Listenable, damit der Router bei
|
||||||
|
|
@ -86,6 +91,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
PlantDetailScreen(plantId: state.pathParameters['id']!),
|
PlantDetailScreen(plantId: state.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/plants/:id/diagnoses',
|
||||||
|
builder: (context, state) =>
|
||||||
|
PlantDiagnosesScreen(plantId: state.pathParameters['id']!),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.locations,
|
path: AppRoutes.locations,
|
||||||
builder: (context, state) => const LocationsScreen(),
|
builder: (context, state) => const LocationsScreen(),
|
||||||
|
|
|
||||||
127
lib/features/plants/presentation/diagnosis_sheet.dart
Normal file
127
lib/features/plants/presentation/diagnosis_sheet.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import '../../locations/data/locations_provider.dart';
|
||||||
import '../data/plant_diagnosis_service.dart';
|
import '../data/plant_diagnosis_service.dart';
|
||||||
import '../data/plants_provider.dart';
|
import '../data/plants_provider.dart';
|
||||||
import '../domain/plant.dart';
|
import '../domain/plant.dart';
|
||||||
|
import 'diagnosis_sheet.dart';
|
||||||
|
|
||||||
class PlantDetailScreen extends ConsumerWidget {
|
class PlantDetailScreen extends ConsumerWidget {
|
||||||
const PlantDetailScreen({super.key, required this.plantId});
|
const PlantDetailScreen({super.key, required this.plantId});
|
||||||
|
|
@ -150,230 +151,12 @@ class PlantDetailScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
_DiagnosisSection(plant: plant),
|
_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
|
/// Krankheits-Check (V3): Foto aufnehmen → Claude beurteilt den Zustand
|
||||||
/// und gibt Behandlungstipps. Auch für Sitter nutzbar (nur lesend).
|
/// und gibt Behandlungstipps. Auch für Sitter nutzbar (nur lesend).
|
||||||
|
|
@ -437,7 +220,7 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> {
|
||||||
debugPrint('Untersuchung speichern fehlgeschlagen: $error');
|
debugPrint('Untersuchung speichern fehlgeschlagen: $error');
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _showDiagnosisSheet(
|
await showDiagnosisSheet(
|
||||||
context,
|
context,
|
||||||
result: result,
|
result: result,
|
||||||
plantNickname: widget.plant.nickname,
|
plantNickname: widget.plant.nickname,
|
||||||
|
|
@ -465,6 +248,13 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -487,6 +277,15 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> {
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
lib/features/plants/presentation/plant_diagnoses_screen.dart
Normal file
129
lib/features/plants/presentation/plant_diagnoses_screen.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -241,6 +241,8 @@
|
||||||
},
|
},
|
||||||
"diagnosisFailed": "Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.",
|
"diagnosisFailed": "Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.",
|
||||||
"diagnosisHistoryTitle": "Untersuchungen",
|
"diagnosisHistoryTitle": "Untersuchungen",
|
||||||
|
"diagnosisHistoryButton": "Frühere Untersuchungen",
|
||||||
|
"diagnosisHistoryEmpty": "Noch keine Untersuchungen gespeichert.",
|
||||||
"diagnosisDate": "Untersucht am {date}",
|
"diagnosisDate": "Untersucht am {date}",
|
||||||
"@diagnosisDate": {
|
"@diagnosisDate": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
|
||||||
|
|
@ -880,6 +880,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Untersuchungen'**
|
/// **'Untersuchungen'**
|
||||||
String get diagnosisHistoryTitle;
|
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.
|
/// No description provided for @diagnosisDate.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get diagnosisHistoryTitle => 'Untersuchungen';
|
String get diagnosisHistoryTitle => 'Untersuchungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosisHistoryButton => 'Frühere Untersuchungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosisHistoryEmpty => 'Noch keine Untersuchungen gespeichert.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String diagnosisDate(String date) {
|
String diagnosisDate(String date) {
|
||||||
return 'Untersucht am $date';
|
return 'Untersucht am $date';
|
||||||
|
|
|
||||||
|
|
@ -406,10 +406,14 @@ void main() {
|
||||||
await tester.tap(find.text('Monstera'));
|
await tester.tap(find.text('Monstera'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Historie ist sichtbar.
|
// Historie-Button ist aktiv (Untersuchungen vorhanden) → Seite öffnen.
|
||||||
await tester.scrollUntilVisible(
|
await tester.scrollUntilVisible(find.text('Frühere Untersuchungen'), 200);
|
||||||
find.text('Die Blätter zeigen braune Flecken.'), 200);
|
await tester.tap(find.text('Frühere Untersuchungen'));
|
||||||
expect(find.text('Untersuchungen'), findsOneWidget);
|
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);
|
expect(find.textContaining('20. Juli 2026'), findsOneWidget);
|
||||||
|
|
||||||
// Eintrag öffnen → volles Ergebnis-Sheet mit Behandlungstipps.
|
// Eintrag öffnen → volles Ergebnis-Sheet mit Behandlungstipps.
|
||||||
|
|
@ -419,6 +423,27 @@ void main() {
|
||||||
expect(find.text('Stelle die Pflanze etwas schattiger.'), findsOneWidget);
|
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 {
|
testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async {
|
||||||
await tester.pumpWidget(await buildTestApp());
|
await tester.pumpWidget(await buildTestApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue