V3: Untersuchungshistorie je Pflanze
Jede Diagnose landet automatisch in households/{id}/plants/{id}/diagnoses;
das Pflanzen-Detail zeigt die Liste (Ampel-Icon, Kurzbefund, Datum),
Antippen öffnet das Ergebnis-Sheet mit Untersuchungsdatum, volle
Mitglieder können Einträge löschen. Rules: read/create für alle im
Haushalt (auch Sitter), delete nur member, update nie. Widget-Test
navigiert bis ins Sheet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2d2be4e774
commit
9caaa2ef79
8 changed files with 471 additions and 110 deletions
|
|
@ -36,9 +36,13 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si
|
||||||
|
|
||||||
**Stand nach erstem Gerätetest (2026-07-23):** Deploy erfolgreich, Grundfunktion vom Nutzer bestätigt. Drei Nachbesserungen aus dem Test umgesetzt (Commit `b665bae`): (1) `speciesHint` ist jetzt eine unbestätigte Angabe — Claude prüft selbst, welche Pflanze zu sehen ist, beurteilt bei Abweichung die Pflanze im Bild und meldet `matchesSpecies=false`, worauf die App einen Warnhinweis im Ergebnis-Sheet zeigt; (2) Prompt verbietet HTML/Markdown (Nutzer hatte ein `</br>` im Text); (3) durchgängiges Duzen vorgeschrieben (war gemischt).
|
**Stand nach erstem Gerätetest (2026-07-23):** Deploy erfolgreich, Grundfunktion vom Nutzer bestätigt. Drei Nachbesserungen aus dem Test umgesetzt (Commit `b665bae`): (1) `speciesHint` ist jetzt eine unbestätigte Angabe — Claude prüft selbst, welche Pflanze zu sehen ist, beurteilt bei Abweichung die Pflanze im Bild und meldet `matchesSpecies=false`, worauf die App einen Warnhinweis im Ergebnis-Sheet zeigt; (2) Prompt verbietet HTML/Markdown (Nutzer hatte ein `</br>` im Text); (3) durchgängiges Duzen vorgeschrieben (war gemischt).
|
||||||
|
|
||||||
|
**Feinschliff deployt und bestätigt** („sieht sehr gut aus", 2026-07-23).
|
||||||
|
|
||||||
|
**Erweiterung Untersuchungshistorie (2026-07-23):** Jede Diagnose wird automatisch unter `households/{id}/plants/{id}/diagnoses` gespeichert (healthy, matchesSpecies, Texte, `createdAt` Server-Timestamp). Im Pflanzen-Detail unter dem Untersuchen-Button: Liste „Untersuchungen" (neueste zuerst, Ampel-Icon + Kurzbefund + Datum), Tipp öffnet dasselbe Ergebnis-Sheet inkl. „Untersucht am …". Volle Mitglieder können Einträge löschen (Bestätigungsdialog); Sitter dürfen speichern/lesen, Einträge sind nachträglich unveränderbar (Rules). Neuer Widget-Test navigiert bis ins Sheet — alle 11 Tests grün.
|
||||||
|
|
||||||
**Noch zu tun (Nutzer):**
|
**Noch zu tun (Nutzer):**
|
||||||
1. **Re-Deploy** der nachgebesserten Function (Berechtigungsfilter blockiert Deploys in der Session): `firebase deploy --only functions:diagnosePlant --project leaf-it-to-me-app`
|
1. **Rules-Deploy** (neue diagnoses-Regeln; ohne sie schlägt das Speichern der Historie still fehl): `firebase deploy --only firestore --project leaf-it-to-me-app`
|
||||||
2. Nachtest: falsches Pflanzen-Foto → Warnhinweis „zeigt offenbar eine andere Pflanze"; Texte ohne HTML, durchgängig „du".
|
2. App neu bauen, Untersuchung durchführen → Eintrag erscheint in „Untersuchungen", antippen zeigt den vollen Text, Löschen fragt nach.
|
||||||
|
|
||||||
**Danach V3 weiter:** Stellplatz-Bewertung (Zwei-Ebenen-Konzept), Umtopf-Erinnerungen.
|
**Danach V3 weiter:** Stellplatz-Bewertung (Zwei-Ebenen-Konzept), Umtopf-Erinnerungen.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,16 @@ service cloud.firestore {
|
||||||
|| request.resource.data.diff(resource.data).affectedKeys()
|
|| request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['lastWatered', 'lastWateredBy',
|
.hasOnly(['lastWatered', 'lastWateredBy',
|
||||||
'lastFertilized', 'lastFertilizedBy']));
|
'lastFertilized', 'lastFertilizedBy']));
|
||||||
|
|
||||||
|
// Untersuchungshistorie (KI-Diagnosen). Speichern und lesen darf
|
||||||
|
// jede*r im Haushalt (auch Sitter — die dürfen den Check nutzen);
|
||||||
|
// löschen nur volle Mitglieder, nachträglich ändern niemand.
|
||||||
|
match /diagnoses/{diagnosisId} {
|
||||||
|
allow read, create: if isMember(householdId);
|
||||||
|
allow delete: if isMember(householdId)
|
||||||
|
&& roleOf(householdId) == 'member';
|
||||||
|
allow update: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match /locations/{locationId} {
|
match /locations/{locationId} {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:cloud_functions/cloud_functions.dart';
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/firebase/firebase_providers.dart';
|
||||||
|
import '../../household/data/household_providers.dart';
|
||||||
|
|
||||||
/// Ergebnis der KI-Krankheits-Diagnose (Cloud Function diagnosePlant).
|
/// Ergebnis der KI-Krankheits-Diagnose (Cloud Function diagnosePlant).
|
||||||
class DiagnosisResult {
|
class DiagnosisResult {
|
||||||
const DiagnosisResult({
|
const DiagnosisResult({
|
||||||
|
|
@ -27,12 +31,47 @@ class DiagnosisResult {
|
||||||
final String prevention;
|
final String prevention;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein gespeicherter Eintrag der Untersuchungshistorie
|
||||||
|
/// (households/{id}/plants/{id}/diagnoses).
|
||||||
|
class PlantDiagnosisEntry {
|
||||||
|
const PlantDiagnosisEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.result,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// null, solange der Server-Zeitstempel noch nicht bestätigt ist.
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
final DiagnosisResult result;
|
||||||
|
}
|
||||||
|
|
||||||
class PlantDiagnosisService {
|
class PlantDiagnosisService {
|
||||||
PlantDiagnosisService(this._functionsGetter);
|
PlantDiagnosisService(
|
||||||
|
this._functionsGetter,
|
||||||
|
this._firestore,
|
||||||
|
this._householdIdGetter,
|
||||||
|
);
|
||||||
|
|
||||||
// Lazy, damit Widget-Tests ohne initialisiertes Firebase auskommen —
|
// Lazy, damit Widget-Tests ohne initialisiertes Firebase auskommen —
|
||||||
// FirebaseFunctions.instanceFor wirft sonst schon beim Erzeugen.
|
// FirebaseFunctions.instanceFor wirft sonst schon beim Erzeugen.
|
||||||
final FirebaseFunctions Function() _functionsGetter;
|
final FirebaseFunctions Function() _functionsGetter;
|
||||||
|
final FirebaseFirestore _firestore;
|
||||||
|
final String? Function() _householdIdGetter;
|
||||||
|
|
||||||
|
CollectionReference<Map<String, dynamic>> _diagnosesRef(
|
||||||
|
String householdId,
|
||||||
|
String plantId,
|
||||||
|
) {
|
||||||
|
return _firestore
|
||||||
|
.collection('households')
|
||||||
|
.doc(householdId)
|
||||||
|
.collection('plants')
|
||||||
|
.doc(plantId)
|
||||||
|
.collection('diagnoses');
|
||||||
|
}
|
||||||
|
|
||||||
/// Schickt das Foto an die Cloud Function (Claude) – dauert einige Sekunden.
|
/// Schickt das Foto an die Cloud Function (Claude) – dauert einige Sekunden.
|
||||||
/// [speciesHint] ist die bekannte Art der Pflanze und hilft der Diagnose.
|
/// [speciesHint] ist die bekannte Art der Pflanze und hilft der Diagnose.
|
||||||
|
|
@ -56,10 +95,67 @@ class PlantDiagnosisService {
|
||||||
prevention: data['prevention'] as String? ?? '',
|
prevention: data['prevention'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legt einen Eintrag in der Untersuchungshistorie der Pflanze an.
|
||||||
|
Future<void> saveDiagnosis(String plantId, DiagnosisResult result) async {
|
||||||
|
final householdId = _householdIdGetter();
|
||||||
|
if (householdId == null) return;
|
||||||
|
await _diagnosesRef(householdId, plantId).add({
|
||||||
|
'healthy': result.healthy,
|
||||||
|
'matchesSpecies': result.matchesSpecies,
|
||||||
|
'summary': result.summary,
|
||||||
|
'details': result.details,
|
||||||
|
'treatment': result.treatment,
|
||||||
|
'prevention': result.prevention,
|
||||||
|
'createdAt': FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entfernt einen Eintrag aus der Historie (nur volle Mitglieder).
|
||||||
|
Future<void> deleteDiagnosis(String plantId, String diagnosisId) async {
|
||||||
|
final householdId = _householdIdGetter();
|
||||||
|
if (householdId == null) return;
|
||||||
|
await _diagnosesRef(householdId, plantId).doc(diagnosisId).delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final plantDiagnosisServiceProvider = Provider<PlantDiagnosisService>((ref) {
|
final plantDiagnosisServiceProvider = Provider<PlantDiagnosisService>((ref) {
|
||||||
return PlantDiagnosisService(
|
return PlantDiagnosisService(
|
||||||
() => FirebaseFunctions.instanceFor(region: 'europe-west3'),
|
() => FirebaseFunctions.instanceFor(region: 'europe-west3'),
|
||||||
|
ref.watch(firestoreProvider),
|
||||||
|
() => ref.read(householdIdProvider).value,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Untersuchungshistorie einer Pflanze, neueste zuerst, live aus Firestore.
|
||||||
|
final plantDiagnosesProvider =
|
||||||
|
StreamProvider.family<List<PlantDiagnosisEntry>, String>((ref, plantId) {
|
||||||
|
final householdId = ref.watch(householdIdProvider).value;
|
||||||
|
if (householdId == null) return Stream.value(const []);
|
||||||
|
|
||||||
|
return ref
|
||||||
|
.watch(firestoreProvider)
|
||||||
|
.collection('households')
|
||||||
|
.doc(householdId)
|
||||||
|
.collection('plants')
|
||||||
|
.doc(plantId)
|
||||||
|
.collection('diagnoses')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) => [
|
||||||
|
for (final doc in snapshot.docs)
|
||||||
|
PlantDiagnosisEntry(
|
||||||
|
id: doc.id,
|
||||||
|
createdAt: (doc.data()['createdAt'] as Timestamp?)?.toDate(),
|
||||||
|
result: DiagnosisResult(
|
||||||
|
healthy: doc.data()['healthy'] as bool? ?? false,
|
||||||
|
matchesSpecies:
|
||||||
|
doc.data()['matchesSpecies'] as bool? ?? true,
|
||||||
|
summary: doc.data()['summary'] as String? ?? '',
|
||||||
|
details: doc.data()['details'] as String? ?? '',
|
||||||
|
treatment: doc.data()['treatment'] as String? ?? '',
|
||||||
|
prevention: doc.data()['prevention'] as String? ?? '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -150,12 +150,231 @@ class PlantDetailScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
_DiagnosisSection(plant: plant),
|
_DiagnosisSection(plant: plant),
|
||||||
|
_DiagnosisHistory(plant: plant),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zeigt das Diagnose-Ergebnis als scrollbares Bottom-Sheet — sowohl für
|
||||||
|
/// eine frische Untersuchung als auch für Einträge aus der Historie
|
||||||
|
/// ([createdAt] gesetzt).
|
||||||
|
Future<void> _showDiagnosisSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required DiagnosisResult result,
|
||||||
|
required String plantNickname,
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
final theme = Theme.of(sheetContext);
|
||||||
|
final healthyColor = theme.brightness == Brightness.dark
|
||||||
|
? Colors.green.shade300
|
||||||
|
: Colors.green.shade700;
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
builder: (context, scrollController) => ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
if (!result.matchesSpecies) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.swap_horiz,
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.diagnosisSpeciesMismatch(plantNickname),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
result.healthy
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.warning_amber_rounded,
|
||||||
|
size: 32,
|
||||||
|
color:
|
||||||
|
result.healthy ? healthyColor : theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
result.summary,
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (createdAt != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.diagnosisDate(
|
||||||
|
DateFormat('d. MMMM y, HH:mm', locale).format(createdAt),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (result.details.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(result.details, style: theme.textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
if (result.treatment.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
l10n.diagnosisTreatmentTitle,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(result.treatment, style: theme.textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
if (result.prevention.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
l10n.diagnosisPreventionTitle,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(result.prevention, style: theme.textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
l10n.diagnosisDisclaimer,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste der gespeicherten Untersuchungen dieser Pflanze (neueste zuerst).
|
||||||
|
class _DiagnosisHistory extends ConsumerWidget {
|
||||||
|
const _DiagnosisHistory({required this.plant});
|
||||||
|
|
||||||
|
final Plant plant;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final entries = ref.watch(plantDiagnosesProvider(plant.id)).value ?? [];
|
||||||
|
if (entries.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member;
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final dateFormat = DateFormat('d. MMMM y, HH:mm', locale);
|
||||||
|
final healthyColor = theme.brightness == Brightness.dark
|
||||||
|
? Colors.green.shade300
|
||||||
|
: Colors.green.shade700;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(l10n.diagnosisHistoryTitle, style: theme.textTheme.titleMedium),
|
||||||
|
for (final entry in entries)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(
|
||||||
|
entry.result.healthy
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.warning_amber_rounded,
|
||||||
|
color: entry.result.healthy
|
||||||
|
? healthyColor
|
||||||
|
: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
entry.result.summary,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: entry.createdAt != null
|
||||||
|
? Text(dateFormat.format(entry.createdAt!))
|
||||||
|
: null,
|
||||||
|
trailing: canEdit
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: l10n.delete,
|
||||||
|
onPressed: () => _confirmDelete(context, ref, entry),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => _showDiagnosisSheet(
|
||||||
|
context,
|
||||||
|
result: entry.result,
|
||||||
|
plantNickname: plant.nickname,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
PlantDiagnosisEntry entry,
|
||||||
|
) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final dateText = entry.createdAt != null
|
||||||
|
? DateFormat('d. MMMM y', locale).format(entry.createdAt!)
|
||||||
|
: '';
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(l10n.deleteDiagnosisQuestion(dateText)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: Text(l10n.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
await ref
|
||||||
|
.read(plantDiagnosisServiceProvider)
|
||||||
|
.deleteDiagnosis(plant.id, entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Krankheits-Check (V3): Foto aufnehmen → Claude beurteilt den Zustand
|
/// Krankheits-Check (V3): Foto aufnehmen → Claude beurteilt den Zustand
|
||||||
/// und gibt Behandlungstipps. Auch für Sitter nutzbar (nur lesend).
|
/// und gibt Behandlungstipps. Auch für Sitter nutzbar (nur lesend).
|
||||||
class _DiagnosisSection extends ConsumerStatefulWidget {
|
class _DiagnosisSection extends ConsumerStatefulWidget {
|
||||||
|
|
@ -205,12 +424,24 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> {
|
||||||
|
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
try {
|
try {
|
||||||
final result = await ref.read(plantDiagnosisServiceProvider).diagnose(
|
final service = ref.read(plantDiagnosisServiceProvider);
|
||||||
bytes,
|
final result = await service.diagnose(
|
||||||
speciesHint: widget.plant.species,
|
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;
|
if (!mounted) return;
|
||||||
await _showResult(result);
|
await _showDiagnosisSheet(
|
||||||
|
context,
|
||||||
|
result: result,
|
||||||
|
plantNickname: widget.plant.nickname,
|
||||||
|
);
|
||||||
} on FirebaseFunctionsException catch (e) {
|
} on FirebaseFunctionsException catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Die Function liefert deutsche Meldungen (z. B. „keine Pflanze
|
// Die Function liefert deutsche Meldungen (z. B. „keine Pflanze
|
||||||
|
|
@ -230,108 +461,6 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showResult(DiagnosisResult result) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
return showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (sheetContext) {
|
|
||||||
final theme = Theme.of(sheetContext);
|
|
||||||
final healthyColor = theme.brightness == Brightness.dark
|
|
||||||
? Colors.green.shade300
|
|
||||||
: Colors.green.shade700;
|
|
||||||
return DraggableScrollableSheet(
|
|
||||||
expand: false,
|
|
||||||
initialChildSize: 0.6,
|
|
||||||
maxChildSize: 0.95,
|
|
||||||
builder: (context, scrollController) => ListView(
|
|
||||||
controller: scrollController,
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
children: [
|
|
||||||
if (!result.matchesSpecies) ...[
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.errorContainer,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.swap_horiz,
|
|
||||||
color: theme.colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.diagnosisSpeciesMismatch(widget.plant.nickname),
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
result.healthy
|
|
||||||
? Icons.check_circle
|
|
||||||
: Icons.warning_amber_rounded,
|
|
||||||
size: 32,
|
|
||||||
color: result.healthy
|
|
||||||
? healthyColor
|
|
||||||
: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
result.summary,
|
|
||||||
style: theme.textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (result.details.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(result.details, style: theme.textTheme.bodyLarge),
|
|
||||||
],
|
|
||||||
if (result.treatment.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
l10n.diagnosisTreatmentTitle,
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(result.treatment, style: theme.textTheme.bodyLarge),
|
|
||||||
],
|
|
||||||
if (result.prevention.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
l10n.diagnosisPreventionTitle,
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(result.prevention, style: theme.textTheme.bodyLarge),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
l10n.diagnosisDisclaimer,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,19 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"diagnosisFailed": "Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.",
|
"diagnosisFailed": "Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.",
|
||||||
|
"diagnosisHistoryTitle": "Untersuchungen",
|
||||||
|
"diagnosisDate": "Untersucht am {date}",
|
||||||
|
"@diagnosisDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deleteDiagnosisQuestion": "Untersuchung vom {date} löschen?",
|
||||||
|
"@deleteDiagnosisQuestion": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.",
|
"saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.",
|
||||||
"saveErrorDetail": "Speichern fehlgeschlagen ({detail}). Bitte versuche es erneut.",
|
"saveErrorDetail": "Speichern fehlgeschlagen ({detail}). Bitte versuche es erneut.",
|
||||||
"@saveErrorDetail": {
|
"@saveErrorDetail": {
|
||||||
|
|
|
||||||
|
|
@ -874,6 +874,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.'**
|
/// **'Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.'**
|
||||||
String get diagnosisFailed;
|
String get diagnosisFailed;
|
||||||
|
|
||||||
|
/// No description provided for @diagnosisHistoryTitle.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Untersuchungen'**
|
||||||
|
String get diagnosisHistoryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @diagnosisDate.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Untersucht am {date}'**
|
||||||
|
String diagnosisDate(String date);
|
||||||
|
|
||||||
|
/// No description provided for @deleteDiagnosisQuestion.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Untersuchung vom {date} löschen?'**
|
||||||
|
String deleteDiagnosisQuestion(String date);
|
||||||
|
|
||||||
/// No description provided for @saveError.
|
/// No description provided for @saveError.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get diagnosisFailed =>
|
String get diagnosisFailed =>
|
||||||
'Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.';
|
'Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosisHistoryTitle => 'Untersuchungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagnosisDate(String date) {
|
||||||
|
return 'Untersucht am $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deleteDiagnosisQuestion(String date) {
|
||||||
|
return 'Untersuchung vom $date löschen?';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get saveError => 'Speichern fehlgeschlagen. Bitte versuche es erneut.';
|
String get saveError => 'Speichern fehlgeschlagen. Bitte versuche es erneut.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,84 @@ void main() {
|
||||||
expect(find.text('Heute'), findsOneWidget);
|
expect(find.text('Heute'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Untersuchungshistorie: gespeicherte Diagnosen sind im Detail sichtbar',
|
||||||
|
(tester) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u1', email: 'test@example.com'),
|
||||||
|
);
|
||||||
|
final firestore = FakeFirebaseFirestore();
|
||||||
|
await firestore.collection('users').doc('u1').set({
|
||||||
|
'email': 'test@example.com',
|
||||||
|
'householdId': 'h1',
|
||||||
|
});
|
||||||
|
await firestore.collection('households').doc('h1').set({
|
||||||
|
'name': 'Mein Haushalt',
|
||||||
|
'ownerUid': 'u1',
|
||||||
|
'memberUids': ['u1'],
|
||||||
|
'members': {
|
||||||
|
'u1': {'role': 'member', 'email': 'test@example.com'},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
final plantRef =
|
||||||
|
firestore.collection('households').doc('h1').collection('plants');
|
||||||
|
await plantRef.doc('p1').set({
|
||||||
|
'nickname': 'Monstera',
|
||||||
|
'species': 'Monstera deliciosa',
|
||||||
|
'locationId': null,
|
||||||
|
'description': '',
|
||||||
|
'careNotes': '',
|
||||||
|
'wateringIntervalDays': 7,
|
||||||
|
'fertilizingIntervalDays': 28,
|
||||||
|
'lastWatered': Timestamp.fromDate(DateTime.now()),
|
||||||
|
'lastFertilized': Timestamp.fromDate(DateTime.now()),
|
||||||
|
});
|
||||||
|
await plantRef.doc('p1').collection('diagnoses').add({
|
||||||
|
'healthy': false,
|
||||||
|
'matchesSpecies': true,
|
||||||
|
'summary': 'Die Blätter zeigen braune Flecken.',
|
||||||
|
'details': 'Vermutlich zu viel direkte Sonne.',
|
||||||
|
'treatment': 'Stelle die Pflanze etwas schattiger.',
|
||||||
|
'prevention': 'Kein direktes Mittagslicht.',
|
||||||
|
'createdAt': Timestamp.fromDate(DateTime(2026, 7, 20, 14, 30)),
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||||
|
firebaseAuthProvider.overrideWithValue(auth),
|
||||||
|
firestoreProvider.overrideWithValue(firestore),
|
||||||
|
pushRegistrationServiceProvider
|
||||||
|
.overrideWithValue(_FakePushRegistrationService()),
|
||||||
|
],
|
||||||
|
child: const LeafItToMeApp(),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Zur Pflanze navigieren: Menü → Meine Pflanzen → Monstera.
|
||||||
|
await tester.tap(find.byIcon(Icons.menu));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Meine Pflanzen'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Monstera'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Historie ist sichtbar.
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('Die Blätter zeigen braune Flecken.'), 200);
|
||||||
|
expect(find.text('Untersuchungen'), findsOneWidget);
|
||||||
|
expect(find.textContaining('20. Juli 2026'), findsOneWidget);
|
||||||
|
|
||||||
|
// Eintrag öffnen → volles Ergebnis-Sheet mit Behandlungstipps.
|
||||||
|
await tester.tap(find.text('Die Blätter zeigen braune Flecken.'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Was du tun kannst'), findsOneWidget);
|
||||||
|
expect(find.text('Stelle die Pflanze etwas schattiger.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async {
|
testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async {
|
||||||
await tester.pumpWidget(await buildTestApp());
|
await tester.pumpWidget(await buildTestApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue