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:
cschlaefke 2026-07-23 22:55:07 +02:00
parent 2d2be4e774
commit 9caaa2ef79
8 changed files with 471 additions and 110 deletions

View file

@ -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).
**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):**
1. **Re-Deploy** der nachgebesserten Function (Berechtigungsfilter blockiert Deploys in der Session): `firebase deploy --only functions:diagnosePlant --project leaf-it-to-me-app`
2. Nachtest: falsches Pflanzen-Foto → Warnhinweis „zeigt offenbar eine andere Pflanze"; Texte ohne HTML, durchgängig „du".
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. 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.

View file

@ -90,6 +90,16 @@ service cloud.firestore {
|| request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['lastWatered', 'lastWateredBy',
'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} {

View file

@ -1,9 +1,13 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:cloud_functions/cloud_functions.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).
class DiagnosisResult {
const DiagnosisResult({
@ -27,12 +31,47 @@ class DiagnosisResult {
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 {
PlantDiagnosisService(this._functionsGetter);
PlantDiagnosisService(
this._functionsGetter,
this._firestore,
this._householdIdGetter,
);
// Lazy, damit Widget-Tests ohne initialisiertes Firebase auskommen
// FirebaseFunctions.instanceFor wirft sonst schon beim Erzeugen.
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.
/// [speciesHint] ist die bekannte Art der Pflanze und hilft der Diagnose.
@ -56,10 +95,67 @@ class PlantDiagnosisService {
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) {
return PlantDiagnosisService(
() => 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? ?? '',
),
),
]);
});

View file

@ -150,12 +150,231 @@ class PlantDetailScreen extends ConsumerWidget {
],
const Divider(height: 32),
_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
/// und gibt Behandlungstipps. Auch für Sitter nutzbar (nur lesend).
class _DiagnosisSection extends ConsumerStatefulWidget {
@ -205,12 +424,24 @@ class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> {
setState(() => _busy = true);
try {
final result = await ref.read(plantDiagnosisServiceProvider).diagnose(
bytes,
speciesHint: widget.plant.species,
);
final service = ref.read(plantDiagnosisServiceProvider);
final result = await service.diagnose(
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;
await _showResult(result);
await _showDiagnosisSheet(
context,
result: result,
plantNickname: widget.plant.nickname,
);
} on FirebaseFunctionsException catch (e) {
if (!mounted) return;
// 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
Widget build(BuildContext context) {

View file

@ -240,6 +240,19 @@
}
},
"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.",
"saveErrorDetail": "Speichern fehlgeschlagen ({detail}). Bitte versuche es erneut.",
"@saveErrorDetail": {

View file

@ -874,6 +874,24 @@ abstract class AppLocalizations {
/// **'Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.'**
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.
///
/// In de, this message translates to:

View file

@ -478,6 +478,19 @@ class AppLocalizationsDe extends AppLocalizations {
String get diagnosisFailed =>
'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
String get saveError => 'Speichern fehlgeschlagen. Bitte versuche es erneut.';

View file

@ -341,6 +341,84 @@ void main() {
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 {
await tester.pumpWidget(await buildTestApp());
await tester.pumpAndSettle();