leafittome/lib/features/plants/presentation/plant_detail_screen.dart
cschlaefke 9caaa2ef79 V3: Untersuchungshistorie je Pflanze
Jede Diagnose landet automatisch in households/{id}/plants/{id}/diagnoses;
das Pflanzen-Detail zeigt die Liste (Ampel-Icon, Kurzbefund, Datum),
Antippen öffnet das Ergebnis-Sheet mit Untersuchungsdatum, volle
Mitglieder können Einträge löschen. Rules: read/create für alle im
Haushalt (auch Sitter), delete nur member, update nie. Widget-Test
navigiert bis ins Sheet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:55:07 +02:00

535 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:cloud_functions/cloud_functions.dart';
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 '../../../core/router/app_router.dart';
import '../../../l10n/generated/app_localizations.dart';
import '../../household/data/household_providers.dart';
import '../../household/domain/household.dart';
import '../../locations/data/locations_provider.dart';
import '../data/plant_diagnosis_service.dart';
import '../data/plants_provider.dart';
import '../domain/plant.dart';
class PlantDetailScreen extends ConsumerWidget {
const PlantDetailScreen({super.key, required this.plantId});
final String plantId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.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 location = ref.watch(locationByIdProvider(plant.locationId));
final locale = Localizations.localeOf(context).toString();
final dateFormat = DateFormat('d. MMMM y', locale);
String formatDate(DateTime? date) =>
date != null ? dateFormat.format(date) : l10n.never;
final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member;
return Scaffold(
appBar: AppBar(
title: Text(plant.nickname),
actions: [
if (canEdit) ...[
IconButton(
icon: const Icon(Icons.edit),
tooltip: l10n.plantDetailTitle,
onPressed: () => context.push(AppRoutes.plantEdit, extra: plant),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: l10n.delete,
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.deletePlantQuestion(plant.nickname)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.delete),
),
],
),
);
if (confirmed == true && context.mounted) {
ref.read(plantRepositoryProvider).removePlant(plant.id);
context.pop();
}
},
),
],
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (plant.photoUrl != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
plant.photoUrl!,
height: 220,
fit: BoxFit.cover,
),
),
const SizedBox(height: 16),
],
_InfoTile(
icon: Icons.local_florist,
label: l10n.speciesLabel,
value: plant.species,
),
_InfoTile(
icon: Icons.place,
label: l10n.locationLabel,
value: location?.name ?? l10n.locationNone,
),
const Divider(height: 32),
_InfoTile(
icon: Icons.water_drop,
label: l10n.wateringEvery(plant.wateringIntervalDays),
value: l10n.lastWatered(formatDate(plant.lastWatered)) +
(plant.lastWateredBy != null
? l10n.doneBy(plant.lastWateredBy!)
: ''),
),
_InfoTile(
icon: Icons.compost,
label: l10n.fertilizingEvery(plant.fertilizingIntervalDays),
value: l10n.lastFertilized(formatDate(plant.lastFertilized)) +
(plant.lastFertilizedBy != null
? l10n.doneBy(plant.lastFertilizedBy!)
: ''),
),
if (plant.description.isNotEmpty) ...[
const Divider(height: 32),
Text(
plant.description,
style: Theme.of(context).textTheme.bodyLarge,
),
],
if (plant.careNotes.isNotEmpty) ...[
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.tips_and_updates,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
plant.careNotes,
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
),
),
),
],
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).
class _DiagnosisSection extends ConsumerStatefulWidget {
const _DiagnosisSection({required this.plant});
final Plant plant;
@override
ConsumerState<_DiagnosisSection> createState() => _DiagnosisSectionState();
}
class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> {
final _picker = ImagePicker();
bool _busy = false;
Future<void> _start() async {
final l10n = AppLocalizations.of(context);
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera),
title: Text(l10n.takePhoto),
onTap: () => Navigator.pop(sheetContext, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: Text(l10n.fromGallery),
onTap: () => Navigator.pop(sheetContext, ImageSource.gallery),
),
],
),
),
);
if (source == null || !mounted) return;
final picked = await _picker.pickImage(
source: source,
maxWidth: 1280,
imageQuality: 80,
);
if (picked == null || !mounted) return;
final bytes = await picked.readAsBytes();
setState(() => _busy = true);
try {
final service = ref.read(plantDiagnosisServiceProvider);
final result = await service.diagnose(
bytes,
speciesHint: widget.plant.species,
);
// In der Historie ablegen — ein Speicherfehler soll die Anzeige des
// Ergebnisses aber nicht verhindern.
try {
await service.saveDiagnosis(widget.plant.id, result);
} catch (error) {
debugPrint('Untersuchung speichern fehlgeschlagen: $error');
}
if (!mounted) return;
await _showDiagnosisSheet(
context,
result: result,
plantNickname: widget.plant.nickname,
);
} on FirebaseFunctionsException catch (e) {
if (!mounted) return;
// Die Function liefert deutsche Meldungen (z. B. „keine Pflanze
// erkannt") die zeigen wir direkt an.
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(e.message ?? l10n.diagnosisFailed),
));
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.diagnosisFailed)));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton.icon(
onPressed: _busy ? null : _start,
icon: _busy
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.health_and_safety_outlined),
label: Text(_busy ? l10n.diagnosing : l10n.diagnoseButton),
),
const SizedBox(height: 4),
Text(
l10n.diagnoseHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.titleMedium),
const SizedBox(height: 2),
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
}