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>
161 lines
5.3 KiB
Dart
161 lines
5.3 KiB
Dart
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({
|
||
required this.healthy,
|
||
required this.matchesSpecies,
|
||
required this.summary,
|
||
required this.details,
|
||
required this.treatment,
|
||
required this.prevention,
|
||
});
|
||
|
||
final bool healthy;
|
||
|
||
/// false, wenn das Foto laut KI eindeutig eine andere Pflanzenart zeigt
|
||
/// als die im Profil hinterlegte (z. B. versehentlich falsches Foto).
|
||
final bool matchesSpecies;
|
||
|
||
final String summary;
|
||
final String details;
|
||
final String treatment;
|
||
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,
|
||
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.
|
||
Future<DiagnosisResult> diagnose(
|
||
Uint8List imageBytes, {
|
||
String? speciesHint,
|
||
}) async {
|
||
final callable = _functionsGetter().httpsCallable('diagnosePlant');
|
||
final response = await callable.call<Map<String, dynamic>>({
|
||
'imageBase64': base64Encode(imageBytes),
|
||
if (speciesHint != null && speciesHint.trim().isNotEmpty)
|
||
'speciesHint': speciesHint.trim(),
|
||
});
|
||
final data = response.data;
|
||
return DiagnosisResult(
|
||
healthy: data['healthy'] as bool? ?? false,
|
||
matchesSpecies: data['matchesSpecies'] as bool? ?? true,
|
||
summary: data['summary'] as String? ?? '',
|
||
details: data['details'] as String? ?? '',
|
||
treatment: data['treatment'] 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) {
|
||
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? ?? '',
|
||
),
|
||
),
|
||
]);
|
||
});
|