leafittome/lib/features/plants/presentation/plant_detail_screen.dart
cschlaefke 18b060413b V3: Krankheits-Diagnose per Foto (Function + Detail-Screen)
Neue Cloud Function diagnosePlant: Foto + optionale Art an Claude,
deutsches JSON-Ergebnis (Befund/Ursache/Behandlung/Vorbeugung).
App: PlantDiagnosisService + Untersuchen-Sektion im Pflanzen-Detail
mit Ergebnis-Bottom-Sheet und KI-Disclaimer. askClaude max_tokens
1024→2048 (adaptives Denken zählt mit ins Limit). Deploy steht aus.

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

378 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 '../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),
],
),
);
}
}
/// 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 result = await ref.read(plantDiagnosisServiceProvider).diagnose(
bytes,
speciesHint: widget.plant.species,
);
if (!mounted) return;
await _showResult(result);
} 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);
}
}
Future<void> _showResult(DiagnosisResult result) {
final l10n = AppLocalizations.of(context);
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: [
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 (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,
),
),
],
),
);
},
);
}
@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,
),
),
],
),
),
],
),
);
}
}