Letzter Roadmap-Punkt von V3. Foto-basierte Lichtverhältnis-Analyse pro Stellplatz (Cloud Function analyzeLocation) und darauf aufbauende, rein textbasierte Eignungs-Bewertung einer Pflanze (assessPlantFit) — beides ausschließlich auf Abruf, nicht automatisch (Kostenkontrolle, eigener Anthropic-Key). Neue Stellplatz-Detailseite, Verlinkung im Pflanzen-Detail, Erkennung veralteter Bewertungen bei Umzug/Neuanalyse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
363 lines
12 KiB
Dart
363 lines
12 KiB
Dart
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 '../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 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!)
|
||
: ''),
|
||
),
|
||
_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,
|
||
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);
|
||
}
|
||
}
|