- Datenmodell: users/{uid}, households/{id} mit memberUids, plants/locations als Subcollections
- StreamProvider liefern Live-Daten, Repositories kapseln Schreibzugriffe
- Login-Screen mit Registrierung (legt Haushalt automatisch an), Logout in Einstellungen
- Router-Redirect bei Login/Logout, firestore.rules mit Haushalts-Prinzip
- Tests auf firebase_auth_mocks + fake_cloud_firestore umgestellt
- Verifiziert: analyze/test grün, Android-Debug-Build erfolgreich
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
6.6 KiB
Dart
189 lines
6.6 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;
|
||
|
||
// Firestore puffert Schreibzugriffe lokal (Offline-Persistenz) –
|
||
// wir müssen hier nicht auf den Server warten.
|
||
final repository = ref.read(plantRepositoryProvider);
|
||
final existing = widget.existing;
|
||
if (existing != null) {
|
||
repository.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 {
|
||
repository.addPlant(Plant(
|
||
id: '', // wird von Firestore vergeben
|
||
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).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: [
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|