- Flutter/Dart (iOS + Android), Riverpod, go_router, i18n via ARB (deutsch) - Feature-Struktur: today, plants, locations, household (V2-Platzhalter), settings - Intervall-Modell: Aufgaben werden aus lastWatered/lastFertilized + Intervall berechnet - In-Memory-Demo-Daten, Firebase-Anbindung folgt im nächsten Block - Doku: Architektur, Firebase-Einrichtung, Forgejo-Git-Hosting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
187 lines
6.5 KiB
Dart
187 lines
6.5 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import '../../../l10n/generated/app_localizations.dart';
|
||
import '../../locations/data/locations_provider.dart';
|
||
import '../data/plants_provider.dart';
|
||
import '../domain/plant.dart';
|
||
|
||
/// Manuelles Anlegen und Bearbeiten einer Pflanze.
|
||
///
|
||
/// Im KI-Block von V1 wird dieser Screen um den Foto-Flow ergänzt:
|
||
/// Foto → PlantNet-Erkennung → Claude befüllt Art, Beschreibung und
|
||
/// Intervall-Vorschläge vor – der Nutzer bestätigt hier nur noch.
|
||
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>();
|
||
late final TextEditingController _nicknameController;
|
||
late final TextEditingController _speciesController;
|
||
late final TextEditingController _wateringController;
|
||
late final TextEditingController _fertilizingController;
|
||
String? _locationId;
|
||
|
||
@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');
|
||
_locationId = existing?.locationId;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nicknameController.dispose();
|
||
_speciesController.dispose();
|
||
_wateringController.dispose();
|
||
_fertilizingController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _save() {
|
||
if (!_formKey.currentState!.validate()) return;
|
||
|
||
final notifier = ref.read(plantsProvider.notifier);
|
||
final existing = widget.existing;
|
||
if (existing != null) {
|
||
notifier.updatePlant(existing.copyWith(
|
||
nickname: _nicknameController.text.trim(),
|
||
species: _speciesController.text.trim(),
|
||
locationId: () => _locationId,
|
||
wateringIntervalDays: int.parse(_wateringController.text.trim()),
|
||
fertilizingIntervalDays: int.parse(_fertilizingController.text.trim()),
|
||
));
|
||
} else {
|
||
notifier.addPlant(Plant(
|
||
id: 'plant-${DateTime.now().millisecondsSinceEpoch}',
|
||
nickname: _nicknameController.text.trim(),
|
||
species: _speciesController.text.trim(),
|
||
locationId: _locationId,
|
||
wateringIntervalDays: int.parse(_wateringController.text.trim()),
|
||
fertilizingIntervalDays: int.parse(_fertilizingController.text.trim()),
|
||
));
|
||
}
|
||
context.pop();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context);
|
||
final locations = ref.watch(locationsProvider);
|
||
|
||
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: [
|
||
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.done,
|
||
validator: _validateInterval,
|
||
),
|
||
const SizedBox(height: 24),
|
||
FilledButton.icon(
|
||
onPressed: _save,
|
||
icon: const Icon(Icons.check),
|
||
label: Text(l10n.save),
|
||
),
|
||
const SizedBox(height: 8),
|
||
TextButton(
|
||
onPressed: () => context.pop(),
|
||
child: Text(l10n.cancel),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|