From 18b060413b817f3fd40b840e4748ff13415d2029 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Thu, 23 Jul 2026 22:20:48 +0200 Subject: [PATCH] V3: Krankheits-Diagnose per Foto (Function + Detail-Screen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Cloud Function diagnosePlant: Foto + optionale Art an Claude, deutsches JSON-Ergebnis (Befund/Ursache/Behandlung/Vorbeugung). App: PlantDiagnosisService + Untersuchen-Sektion im Pflanzen-Detail mit Ergebnis-Bottom-Sheet und KI-Disclaimer. askClaude max_tokens 1024→2048 (adaptives Denken zählt mit ins Limit). Deploy steht aus. Co-Authored-By: Claude Fable 5 --- docs/handoff.md | 16 +- functions/src/index.ts | 77 +++++++- .../plants/data/plant_diagnosis_service.dart | 58 ++++++ .../presentation/plant_detail_screen.dart | 185 ++++++++++++++++++ lib/l10n/app_de.arb | 7 + lib/l10n/generated/app_localizations.dart | 42 ++++ lib/l10n/generated/app_localizations_de.dart | 24 +++ 7 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 lib/features/plants/data/plant_diagnosis_service.dart diff --git a/docs/handoff.md b/docs/handoff.md index f71b4a6..57f747e 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -13,7 +13,7 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si - **Doku (im Repo, deutsch, aktuell halten!):** `README.md`, `docs/architektur.md`, `docs/firebase-einrichtung.md` (Schritt-für-Schritt-Historie aller Backend-Einrichtungen), `docs/git-forgejo.md` - **Claude-Memory:** Projektplan, Nutzerprofil und Backlog liegen im persistenten Memory-Verzeichnis dieser Claude-Code-Installation (werden automatisch geladen) - **Firebase-Projekt:** `leaf-it-to-me-app` (Blaze aktiv, Budget-Alarm eingerichtet; Firebase CLI ist eingeloggt) -- **Functions:** `functions/` (TypeScript, Node 22, Region europe-west3): `identifyPlant` (PlantNet + Claude, Secrets `PLANTNET_API_KEY`/`ANTHROPIC_API_KEY` im Secret Manager), `sendDailyReminders` (alle 15 Min, sammelt über alle Haushalte eines Nutzers), `joinHousehold` (Einladungs-Einlösung), `leaveHousehold` (Haushalt verlassen), `removeMember` (Mitglied entfernen, nur Besitzer) +- **Functions:** `functions/` (TypeScript, Node 22, Region europe-west3): `identifyPlant` (PlantNet + Claude, Secrets `PLANTNET_API_KEY`/`ANTHROPIC_API_KEY` im Secret Manager), `diagnosePlant` (V3: Krankheits-Diagnose per Foto, Claude Vision), `sendDailyReminders` (alle 15 Min, sammelt über alle Haushalte eines Nutzers), `joinHousehold` (Einladungs-Einlösung), `leaveHousehold` (Haushalt verlassen), `removeMember` (Mitglied entfernen, nur Besitzer) - **Auth:** E-Mail/Passwort + **Anmelden mit Apple** (iOS nativ, Android Web-Flow über Firebase-Handler) + **Anmelden mit Google** (beide Plattformen Web-Flow, eingebauter `GoogleAuthProvider`) — Code in `lib/features/auth/data/auth_repository.dart`, Login-UI in `.../presentation/login_screen.dart`. Haushalts-Bootstrap (Profil + eigener Haushalt beim ersten Login) läuft **reaktiv** über `householdBootstrapProvider` + `BootstrapGate` (Maßstab: `householdId` im users-Doc). ## Verifizierter Stand @@ -27,6 +27,20 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si ## Offen / Nächstes +### 🟡 V3 Baustein 1 — Krankheits-Diagnose per Foto: implementiert, wartet auf Deploy + Gerätetest + +**2026-07-23 implementiert:** +- **Function `diagnosePlant`** (`functions/src/index.ts`): Foto (+ optionale Art als `speciesHint`) → Claude beurteilt Gesundheitszustand → JSON `{healthy, summary, details, treatment, prevention}` auf Deutsch. Nutzt den bestehenden `askClaude`-Helper (Modell `claude-sonnet-5`); dessen `max_tokens` von 1024 auf 2048 erhöht (adaptives Denken zählt mit ins Limit). +- **App:** `PlantDiagnosisService` (`lib/features/plants/data/plant_diagnosis_service.dart`, lazy Functions-Getter wie beim Household-Repo) + „Pflanze untersuchen"-Sektion im Pflanzen-Detail (`_DiagnosisSection`): Kamera/Galerie-Sheet → Spinner im Button → Ergebnis als scrollbares Bottom-Sheet (Befund mit Ampel-Icon, „Was du tun kannst", „So beugst du vor", KI-Disclaimer). Auch Sitter dürfen den Check nutzen (rein lesend). Fehler der Function (z. B. „keine Pflanze erkannt") werden als Snackbar durchgereicht. +- Analyzer + alle 10 Tests grün, `tsc` kompiliert. + +**Noch zu tun (Nutzer):** +1. **Deploy** (vom Berechtigungsfilter der Session blockiert, daher manuell): `firebase deploy --only functions:diagnosePlant --project leaf-it-to-me-app` +2. Falls der erste Aufruf HTTP 401 liefert: bekannte Gen-2-Stolperfalle (Cloud-Run-Invoker) → dieselbe Function einfach erneut deployen. +3. Gerätetest: Pflanze öffnen → „Pflanze untersuchen" → Foto → Ergebnis-Sheet prüfen (gesunde UND kranke Pflanze testen; auch ein Nicht-Pflanzen-Foto → Meldung „keine Pflanze erkannt"). + +**Danach V3 weiter:** Stellplatz-Bewertung (Zwei-Ebenen-Konzept), Umtopf-Erinnerungen. + ### 🟡 Google-Login: implementiert, wartet auf Konsolen-Schalter + Gerätetest **2026-07-23 implementiert** (`AuthRepository.signInWithGoogle` via eingebautem `GoogleAuthProvider`, Google-Button im Login mit gemeinsamem Provider-Handler `_signInWithProvider`, l10n-Strings, iOS-URL-Scheme/Encoded App ID in `Info.plist`, Doku `firebase-einrichtung.md` Schritt 8). Analyzer + alle 10 Tests grün. **Nutzer muss noch:** In der Firebase-Konsole *Authentication → Sign-in method → Google* aktivieren (Support-E-Mail wählen — der einzige Konfigurationsschritt), dann auf iPhone und Android testen; der erste Google-Login muss kurz den Einrichtungs-Bildschirm zeigen (reaktiver Bootstrap deckt Google automatisch ab). diff --git a/functions/src/index.ts b/functions/src/index.ts index 22cec86..a52f724 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -82,7 +82,9 @@ async function askClaude( }, body: JSON.stringify({ model: ANTHROPIC_MODEL, - max_tokens: 1024, + // Claude Sonnet 5 denkt standardmäßig adaptiv mit; das Limit deckt + // Denk- UND Antwort-Tokens ab — großzügig, damit nichts abreißt. + max_tokens: 2048, messages: [ { role: "user", @@ -127,6 +129,79 @@ async function askClaude( * 2. Bei zu geringer Konfidenz bestimmt Claude die Art als Fallback. * 3. Claude erstellt Beschreibung, Pflegehinweise und Intervall-Vorschläge. */ +interface DiagnosisResult { + healthy: boolean; + summary: string; + details: string; + treatment: string; + prevention: string; +} + +/** + * Krankheits-Diagnose per Foto (V3). + * + * Claude beurteilt den Gesundheitszustand der Pflanze auf dem Foto und + * liefert Befund, Ursache, Behandlung und Vorbeugung auf Deutsch. + * Optional kann die App die bekannte Art mitgeben (speciesHint). + */ +export const diagnosePlant = onCall( + { + region: "europe-west3", + secrets: [anthropicApiKey], + memory: "512MiB", + timeoutSeconds: 120, + maxInstances: 5, + }, + async (request) => { + if (!request.auth) { + throw new HttpsError("unauthenticated", "Anmeldung erforderlich."); + } + const imageBase64 = request.data?.imageBase64; + if (typeof imageBase64 !== "string" || imageBase64.length === 0) { + throw new HttpsError("invalid-argument", "imageBase64 fehlt."); + } + if (imageBase64.length > 8_000_000) { + throw new HttpsError("invalid-argument", "Bild ist zu groß."); + } + const speciesHint = + typeof request.data?.speciesHint === "string" ? + request.data.speciesHint.slice(0, 200) : + ""; + + const speciesPart = speciesHint ? + `Es handelt sich vermutlich um "${speciesHint}". ` : + ""; + const diagnosis = await askClaude( + imageBase64, + "Du bist ein erfahrener Pflanzenarzt. Beurteile den Gesundheitszustand " + + `der Pflanze auf dem Foto. ${speciesPart}` + + "Achte auf Blattverfärbungen, Flecken, Schädlinge, Schimmel, " + + "Trockenheit, Überwässerung und ähnliche Anzeichen. " + + "Antworte auf Deutsch, in einfacher, freundlicher Sprache " + + "(auch für ältere Menschen gut verständlich, keine Fachbegriffe ohne " + + "Erklärung). Antworte NUR mit JSON im Format " + + '{"healthy": , ' + + '"summary": "", ' + + '"details": "<2-3 Sätze: was ist zu sehen und was ist die ' + + 'wahrscheinliche Ursache>", ' + + '"treatment": "<2-4 Sätze mit konkreten Behandlungs-Schritten; bei ' + + 'gesunder Pflanze kurze Pflegetipps>", ' + + '"prevention": "<1-2 Sätze, wie man dem Problem vorbeugt>"}. ' + + "Wenn auf dem Foto keine Pflanze erkennbar ist, setze summary auf " + + "einen leeren String.", + anthropicApiKey.value() + ); + + if (!diagnosis.summary) { + throw new HttpsError( + "not-found", + "Auf dem Foto wurde keine Pflanze erkannt." + ); + } + return diagnosis; + } +); + export const identifyPlant = onCall( { region: "europe-west3", diff --git a/lib/features/plants/data/plant_diagnosis_service.dart b/lib/features/plants/data/plant_diagnosis_service.dart new file mode 100644 index 0000000..ef15718 --- /dev/null +++ b/lib/features/plants/data/plant_diagnosis_service.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Ergebnis der KI-Krankheits-Diagnose (Cloud Function diagnosePlant). +class DiagnosisResult { + const DiagnosisResult({ + required this.healthy, + required this.summary, + required this.details, + required this.treatment, + required this.prevention, + }); + + final bool healthy; + final String summary; + final String details; + final String treatment; + final String prevention; +} + +class PlantDiagnosisService { + PlantDiagnosisService(this._functionsGetter); + + // Lazy, damit Widget-Tests ohne initialisiertes Firebase auskommen — + // FirebaseFunctions.instanceFor wirft sonst schon beim Erzeugen. + final FirebaseFunctions Function() _functionsGetter; + + /// Schickt das Foto an die Cloud Function (Claude) – dauert einige Sekunden. + /// [speciesHint] ist die bekannte Art der Pflanze und hilft der Diagnose. + Future diagnose( + Uint8List imageBytes, { + String? speciesHint, + }) async { + final callable = _functionsGetter().httpsCallable('diagnosePlant'); + final response = await callable.call>({ + 'imageBase64': base64Encode(imageBytes), + if (speciesHint != null && speciesHint.trim().isNotEmpty) + 'speciesHint': speciesHint.trim(), + }); + final data = response.data; + return DiagnosisResult( + healthy: data['healthy'] as bool? ?? false, + summary: data['summary'] as String? ?? '', + details: data['details'] as String? ?? '', + treatment: data['treatment'] as String? ?? '', + prevention: data['prevention'] as String? ?? '', + ); + } +} + +final plantDiagnosisServiceProvider = Provider((ref) { + return PlantDiagnosisService( + () => FirebaseFunctions.instanceFor(region: 'europe-west3'), + ); +}); diff --git a/lib/features/plants/presentation/plant_detail_screen.dart b/lib/features/plants/presentation/plant_detail_screen.dart index 782a506..4b7467a 100644 --- a/lib/features/plants/presentation/plant_detail_screen.dart +++ b/lib/features/plants/presentation/plant_detail_screen.dart @@ -1,6 +1,8 @@ +import 'package:cloud_functions/cloud_functions.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import '../../../core/router/app_router.dart'; @@ -8,7 +10,9 @@ import '../../../l10n/generated/app_localizations.dart'; import '../../household/data/household_providers.dart'; import '../../household/domain/household.dart'; import '../../locations/data/locations_provider.dart'; +import '../data/plant_diagnosis_service.dart'; import '../data/plants_provider.dart'; +import '../domain/plant.dart'; class PlantDetailScreen extends ConsumerWidget { const PlantDetailScreen({super.key, required this.plantId}); @@ -144,12 +148,193 @@ class PlantDetailScreen extends ConsumerWidget { ), ), ], + const Divider(height: 32), + _DiagnosisSection(plant: plant), ], ), ); } } +/// Krankheits-Check (V3): Foto aufnehmen → Claude beurteilt den Zustand +/// und gibt Behandlungstipps. Auch für Sitter nutzbar (nur lesend). +class _DiagnosisSection extends ConsumerStatefulWidget { + const _DiagnosisSection({required this.plant}); + + final Plant plant; + + @override + ConsumerState<_DiagnosisSection> createState() => _DiagnosisSectionState(); +} + +class _DiagnosisSectionState extends ConsumerState<_DiagnosisSection> { + final _picker = ImagePicker(); + bool _busy = false; + + Future _start() async { + final l10n = AppLocalizations.of(context); + final source = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_camera), + title: Text(l10n.takePhoto), + onTap: () => Navigator.pop(sheetContext, ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: Text(l10n.fromGallery), + onTap: () => Navigator.pop(sheetContext, ImageSource.gallery), + ), + ], + ), + ), + ); + if (source == null || !mounted) return; + + final picked = await _picker.pickImage( + source: source, + maxWidth: 1280, + imageQuality: 80, + ); + if (picked == null || !mounted) return; + final bytes = await picked.readAsBytes(); + + setState(() => _busy = true); + try { + final result = await ref.read(plantDiagnosisServiceProvider).diagnose( + bytes, + speciesHint: widget.plant.species, + ); + if (!mounted) return; + await _showResult(result); + } on FirebaseFunctionsException catch (e) { + if (!mounted) return; + // Die Function liefert deutsche Meldungen (z. B. „keine Pflanze + // erkannt") – die zeigen wir direkt an. + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(e.message ?? l10n.diagnosisFailed), + )); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.diagnosisFailed))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _showResult(DiagnosisResult result) { + final l10n = AppLocalizations.of(context); + return showModalBottomSheet( + 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: [ + 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) { + final l10n = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton.icon( + onPressed: _busy ? null : _start, + icon: _busy + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.health_and_safety_outlined), + label: Text(_busy ? l10n.diagnosing : l10n.diagnoseButton), + ), + const SizedBox(height: 4), + Text( + l10n.diagnoseHint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ); + } +} + class _InfoTile extends StatelessWidget { const _InfoTile({ required this.icon, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6c00781..8b801e4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -227,6 +227,13 @@ } }, "recognitionFailed": "Die Erkennung hat nicht geklappt – du kannst die Angaben auch von Hand eintragen.", + "diagnoseButton": "Pflanze untersuchen", + "diagnoseHint": "Foto von kranken Blättern oder Stellen aufnehmen – die KI beurteilt den Zustand und gibt Behandlungstipps.", + "diagnosing": "Foto wird untersucht …", + "diagnosisTreatmentTitle": "Was du tun kannst", + "diagnosisPreventionTitle": "So beugst du vor", + "diagnosisDisclaimer": "Die Einschätzung kommt von einer KI und kann falsch liegen. Bei wertvollen Pflanzen im Zweifel eine Gärtnerei fragen.", + "diagnosisFailed": "Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.", "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.", "saveErrorDetail": "Speichern fehlgeschlagen ({detail}). Bitte versuche es erneut.", "@saveErrorDetail": { diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 3d2dafd..8feb669 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -826,6 +826,48 @@ abstract class AppLocalizations { /// **'Die Erkennung hat nicht geklappt – du kannst die Angaben auch von Hand eintragen.'** String get recognitionFailed; + /// No description provided for @diagnoseButton. + /// + /// In de, this message translates to: + /// **'Pflanze untersuchen'** + String get diagnoseButton; + + /// No description provided for @diagnoseHint. + /// + /// In de, this message translates to: + /// **'Foto von kranken Blättern oder Stellen aufnehmen – die KI beurteilt den Zustand und gibt Behandlungstipps.'** + String get diagnoseHint; + + /// No description provided for @diagnosing. + /// + /// In de, this message translates to: + /// **'Foto wird untersucht …'** + String get diagnosing; + + /// No description provided for @diagnosisTreatmentTitle. + /// + /// In de, this message translates to: + /// **'Was du tun kannst'** + String get diagnosisTreatmentTitle; + + /// No description provided for @diagnosisPreventionTitle. + /// + /// In de, this message translates to: + /// **'So beugst du vor'** + String get diagnosisPreventionTitle; + + /// No description provided for @diagnosisDisclaimer. + /// + /// In de, this message translates to: + /// **'Die Einschätzung kommt von einer KI und kann falsch liegen. Bei wertvollen Pflanzen im Zweifel eine Gärtnerei fragen.'** + String get diagnosisDisclaimer; + + /// No description provided for @diagnosisFailed. + /// + /// In de, this message translates to: + /// **'Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.'** + String get diagnosisFailed; + /// No description provided for @saveError. /// /// In de, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a7186f7..706d9a0 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -449,6 +449,30 @@ class AppLocalizationsDe extends AppLocalizations { String get recognitionFailed => 'Die Erkennung hat nicht geklappt – du kannst die Angaben auch von Hand eintragen.'; + @override + String get diagnoseButton => 'Pflanze untersuchen'; + + @override + String get diagnoseHint => + 'Foto von kranken Blättern oder Stellen aufnehmen – die KI beurteilt den Zustand und gibt Behandlungstipps.'; + + @override + String get diagnosing => 'Foto wird untersucht …'; + + @override + String get diagnosisTreatmentTitle => 'Was du tun kannst'; + + @override + String get diagnosisPreventionTitle => 'So beugst du vor'; + + @override + String get diagnosisDisclaimer => + 'Die Einschätzung kommt von einer KI und kann falsch liegen. Bei wertvollen Pflanzen im Zweifel eine Gärtnerei fragen.'; + + @override + String get diagnosisFailed => + 'Die Untersuchung hat nicht geklappt. Bitte versuche es erneut.'; + @override String get saveError => 'Speichern fehlgeschlagen. Bitte versuche es erneut.';