leafittome/lib/features/plants/presentation/plant_form_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

458 lines
16 KiB
Dart

import 'dart:typed_data';
import 'package:firebase_core/firebase_core.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 '../../../l10n/generated/app_localizations.dart';
import '../../locations/data/locations_provider.dart';
import '../data/plant_recognition_service.dart';
import '../data/plants_provider.dart';
import '../domain/plant.dart';
/// Anlegen und Bearbeiten einer Pflanze.
///
/// Mit Foto: Kamera/Galerie → Cloud Function (PlantNet + Claude) erkennt die
/// Art und füllt Art, Beschreibung, Pflegehinweise und Intervalle vor —
/// der Nutzer prüft, passt an und speichert. Ohne Foto: manuell ausfüllen.
class PlantFormScreen extends ConsumerStatefulWidget {
const PlantFormScreen({super.key, this.existing});
final Plant? existing;
@override
ConsumerState<PlantFormScreen> createState() => _PlantFormScreenState();
}
class _PlantFormScreenState extends ConsumerState<PlantFormScreen> {
final _formKey = GlobalKey<FormState>();
final _picker = ImagePicker();
late final TextEditingController _nicknameController;
late final TextEditingController _speciesController;
late final TextEditingController _wateringController;
late final TextEditingController _fertilizingController;
late final TextEditingController _repottingController;
String? _locationId;
DateTime? _lastRepotted;
Uint8List? _photoBytes;
bool _recognizing = false;
bool _saving = false;
String _description = '';
String _careNotes = '';
@override
void initState() {
super.initState();
final existing = widget.existing;
_nicknameController = TextEditingController(text: existing?.nickname ?? '');
_speciesController = TextEditingController(text: existing?.species ?? '');
_wateringController = TextEditingController(
text: existing?.wateringIntervalDays.toString() ?? '7');
_fertilizingController = TextEditingController(
text: existing?.fertilizingIntervalDays.toString() ?? '28');
_repottingController = TextEditingController(
text: existing?.repottingIntervalMonths?.toString() ?? '');
_lastRepotted = existing?.lastRepotted;
_locationId = existing?.locationId;
_description = existing?.description ?? '';
_careNotes = existing?.careNotes ?? '';
}
@override
void dispose() {
_nicknameController.dispose();
_speciesController.dispose();
_wateringController.dispose();
_fertilizingController.dispose();
_repottingController.dispose();
super.dispose();
}
Future<void> _pickImage(ImageSource source) async {
final picked = await _picker.pickImage(
source: source,
maxWidth: 1280,
imageQuality: 80,
);
if (picked == null) return;
final bytes = await picked.readAsBytes();
setState(() => _photoBytes = bytes);
await _recognize(bytes);
}
Future<void> _recognize(Uint8List bytes) async {
final l10n = AppLocalizations.of(context);
setState(() => _recognizing = true);
try {
final result =
await ref.read(plantRecognitionServiceProvider).identify(bytes);
if (!mounted) return;
setState(() {
_speciesController.text = result.displaySpecies;
_wateringController.text = result.wateringIntervalDays.toString();
_fertilizingController.text = result.fertilizingIntervalDays.toString();
_description = result.description;
_careNotes = result.careNotes;
if (_nicknameController.text.trim().isEmpty &&
result.germanName.isNotEmpty) {
_nicknameController.text = result.germanName;
}
});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(l10n.recognitionSuccess(result.displaySpecies)),
));
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.recognitionFailed)));
} finally {
if (mounted) setState(() => _recognizing = false);
}
}
Future<void> _save() async {
final l10n = AppLocalizations.of(context);
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
String? photoUrl = widget.existing?.photoUrl;
if (_photoBytes != null) {
photoUrl = await ref
.read(plantRecognitionServiceProvider)
.uploadPhoto(_photoBytes!);
}
final repository = ref.read(plantRepositoryProvider);
final existing = widget.existing;
// Leeres Feld = Umtopf-Erinnerung aus; dann auch das Datum verwerfen.
final repotMonths = int.tryParse(_repottingController.text.trim());
final lastRepotted = repotMonths != null ? _lastRepotted : null;
if (existing != null) {
await repository.updatePlant(existing.copyWith(
nickname: _nicknameController.text.trim(),
species: _speciesController.text.trim(),
locationId: () => _locationId,
description: _description,
careNotes: _careNotes,
photoUrl: photoUrl,
wateringIntervalDays: int.parse(_wateringController.text.trim()),
fertilizingIntervalDays:
int.parse(_fertilizingController.text.trim()),
repottingIntervalMonths: () => repotMonths,
lastRepotted: () => lastRepotted,
));
} else {
await repository.addPlant(Plant(
id: '', // wird von Firestore vergeben
nickname: _nicknameController.text.trim(),
species: _speciesController.text.trim(),
locationId: _locationId,
description: _description,
careNotes: _careNotes,
photoUrl: photoUrl,
wateringIntervalDays: int.parse(_wateringController.text.trim()),
fertilizingIntervalDays:
int.parse(_fertilizingController.text.trim()),
repottingIntervalMonths: repotMonths,
lastRepotted: lastRepotted,
));
}
if (mounted) context.pop();
} catch (error) {
// Der konkrete Grund (z. B. firebase_storage/unauthorized vs.
// cloud_firestore/permission-denied) entscheidet, wo der Fehler liegt —
// deshalb wird er mit angezeigt statt verschluckt.
debugPrint('Pflanze speichern fehlgeschlagen: $error');
final detail = error is FirebaseException
? '${error.plugin}/${error.code}'
: error.runtimeType.toString();
if (mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.saveErrorDetail(detail))));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
String? _validateRequired(String? value) {
final l10n = AppLocalizations.of(context);
if (value == null || value.trim().isEmpty) return l10n.requiredField;
return null;
}
String? _validateInterval(String? value) {
final l10n = AppLocalizations.of(context);
final parsed = int.tryParse(value?.trim() ?? '');
if (parsed == null || parsed <= 0) return l10n.invalidNumber;
return null;
}
/// Wie [_validateInterval], aber leer ist erlaubt (Erinnerung aus).
String? _validateOptionalInterval(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _validateInterval(value);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locations = ref.watch(locationsProvider).value ?? const [];
return Scaffold(
appBar: AppBar(
title: Text(
widget.existing != null ? l10n.plantDetailTitle : l10n.addPlant),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_PhotoSection(
photoBytes: _photoBytes,
existingPhotoUrl: widget.existing?.photoUrl,
recognizing: _recognizing,
onTakePhoto: () => _pickImage(ImageSource.camera),
onPickFromGallery: () => _pickImage(ImageSource.gallery),
),
const SizedBox(height: 24),
TextFormField(
controller: _nicknameController,
decoration: InputDecoration(
labelText: l10n.nicknameLabel,
border: const OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
validator: _validateRequired,
),
const SizedBox(height: 16),
TextFormField(
controller: _speciesController,
decoration: InputDecoration(
labelText: l10n.speciesLabel,
border: const OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
validator: _validateRequired,
),
const SizedBox(height: 16),
DropdownButtonFormField<String?>(
initialValue: _locationId,
decoration: InputDecoration(
labelText: l10n.locationLabel,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text(l10n.locationNone),
),
for (final location in locations)
DropdownMenuItem<String?>(
value: location.id,
child: Text(location.name),
),
],
onChanged: (value) => setState(() => _locationId = value),
),
const SizedBox(height: 16),
TextFormField(
controller: _wateringController,
decoration: InputDecoration(
labelText: l10n.wateringIntervalLabel,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.water_drop),
),
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
validator: _validateInterval,
),
const SizedBox(height: 16),
TextFormField(
controller: _fertilizingController,
decoration: InputDecoration(
labelText: l10n.fertilizingIntervalLabel,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.compost),
),
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
validator: _validateInterval,
),
const SizedBox(height: 16),
TextFormField(
controller: _repottingController,
decoration: InputDecoration(
labelText: l10n.repottingIntervalLabel,
helperText: l10n.repottingIntervalHelper,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.yard),
),
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
validator: _validateOptionalInterval,
onChanged: (_) => setState(() {}),
),
if (_repottingController.text.trim().isNotEmpty) ...[
const SizedBox(height: 16),
// Startpunkt der Umtopf-Erinnerung; ohne Datum gilt die
// Aufgabe als „noch nie erledigt" und ist sofort fällig.
InputDecorator(
decoration: InputDecoration(
labelText: l10n.lastRepottedLabel,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.event),
),
child: Row(
children: [
Expanded(
child: Text(
_lastRepotted != null
? DateFormat('d. MMMM y',
Localizations.localeOf(context).toString())
.format(_lastRepotted!)
: l10n.never,
),
),
TextButton(
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _lastRepotted ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() => _lastRepotted = picked);
}
},
child: Text(l10n.pickDate),
),
],
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _saving || _recognizing ? null : _save,
icon: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: Text(l10n.save),
),
const SizedBox(height: 8),
TextButton(
onPressed: _saving ? null : () => context.pop(),
child: Text(l10n.cancel),
),
],
),
),
);
}
}
class _PhotoSection extends StatelessWidget {
const _PhotoSection({
required this.photoBytes,
required this.existingPhotoUrl,
required this.recognizing,
required this.onTakePhoto,
required this.onPickFromGallery,
});
final Uint8List? photoBytes;
final String? existingPhotoUrl;
final bool recognizing;
final VoidCallback onTakePhoto;
final VoidCallback onPickFromGallery;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
Widget preview;
if (photoBytes != null) {
preview = Image.memory(photoBytes!, fit: BoxFit.cover);
} else if (existingPhotoUrl != null) {
preview = Image.network(existingPhotoUrl!, fit: BoxFit.cover);
} else {
preview = Container(
color: theme.colorScheme.surfaceContainerHighest,
child: Icon(
Icons.local_florist,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 200,
child: Stack(
fit: StackFit.expand,
children: [
preview,
if (recognizing)
Container(
color: Colors.black45,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: Colors.white),
const SizedBox(height: 12),
Text(
l10n.recognizing,
style: const TextStyle(color: Colors.white),
),
],
),
),
],
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: recognizing ? null : onTakePhoto,
icon: const Icon(Icons.photo_camera),
label: Text(l10n.takePhoto),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: recognizing ? null : onPickFromGallery,
icon: const Icon(Icons.photo_library),
label: Text(l10n.fromGallery),
),
),
],
),
],
);
}
}