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>
458 lines
16 KiB
Dart
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|