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).
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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} {
|
||||
|
|
|
|||
|
|
@ -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? ?? '',
|
||||
),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue