leafittome/lib/features/plants/presentation/plant_detail_screen.dart
cschlaefke e81ce48543 V3: Nächstes Fälligkeitsdatum bei Gießen/Düngen/Umtopfen in Pflanzen-Detail
Nutzer vermisste beim Umtopf-Test eine Info, wann das nächste Umtopfen
ansteht (Formular/Checkliste zeigten nur "alle X Monate"/"zuletzt am").
Berechnung aus due_tasks_provider public gemacht statt dupliziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:13:04 +02:00

366 lines
12 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 '../../locations/domain/plant_location.dart';
import '../../today/application/due_tasks_provider.dart';
import '../data/plant_diagnosis_service.dart';
import '../data/plant_fit_service.dart';
import '../data/plants_provider.dart';
import '../domain/plant.dart';
import 'diagnosis_sheet.dart';
/// Kompakte Sterne-/Status-Anzeige für die Stellplatz-Zeile.
String _fitStatusText(AppLocalizations l10n, Plant plant, PlantLocation location) {
if (plant.fitStars == null) return l10n.fitCheckNotDone;
if (isFitStale(plant, location)) return l10n.fitCheckStale;
return '' * plant.fitStars! + '' * (5 - plant.fitStars!);
}
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 now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
String formatNextDue(DateTime date) =>
date.isAfter(today) ? dateFormat.format(date) : l10n.dueToday;
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 == null
? l10n.locationNone
: '${location.name} · ${_fitStatusText(l10n, plant, location)}',
onTap: location == null
? null
: () => context.push(AppRoutes.locationDetail(location.id)),
),
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!) : ''}'
'\n${l10n.nextWatering(formatNextDue(nextWateringDue(plant, today)!))}',
),
_InfoTile(
icon: Icons.compost,
label: l10n.fertilizingEvery(plant.fertilizingIntervalDays),
value: '${l10n.lastFertilized(formatDate(plant.lastFertilized))}'
'${plant.lastFertilizedBy != null ? l10n.doneBy(plant.lastFertilizedBy!) : ''}'
'\n${l10n.nextFertilizing(formatNextDue(nextFertilizingDue(plant, today)!))}',
),
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!) : ''}'
'\n${l10n.nextRepotting(formatNextDue(nextRepottingDue(plant, today)!))}',
),
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,
this.onTap,
});
final IconData icon;
final String label;
final String value;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final content = 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,
),
),
],
),
),
if (onTap != null)
Icon(Icons.chevron_right, color: theme.colorScheme.onSurfaceVariant),
],
),
);
if (onTap == null) return content;
return InkWell(onTap: onTap, child: content);
}
}