leafittome/lib/features/plants/presentation/plant_detail_screen.dart
cschlaefke 81d990dd7e 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>
2026-07-23 23:40:21 +02:00

343 lines
11 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';
import 'diagnosis_sheet.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.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(
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),
],
),
);
}
}
/// 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);
// 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: [
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,
),
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),
),
],
);
}
}
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,
),
),
],
),
),
],
),
);
}
}