V3: Krankheits-Diagnose per Foto (Function + Detail-Screen)

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 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-07-23 22:20:48 +02:00
parent 6560232655
commit 18b060413b
7 changed files with 407 additions and 2 deletions

View file

@ -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).

View file

@ -82,7 +82,9 @@ async function askClaude<T>(
},
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<T>(
* 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<DiagnosisResult>(
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": <true wenn die Pflanze gesund wirkt, sonst false>, ' +
'"summary": "<Kurzbefund in 1 Satz>", ' +
'"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",

View file

@ -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<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,
summary: data['summary'] as String? ?? '',
details: data['details'] as String? ?? '',
treatment: data['treatment'] as String? ?? '',
prevention: data['prevention'] as String? ?? '',
);
}
}
final plantDiagnosisServiceProvider = Provider<PlantDiagnosisService>((ref) {
return PlantDiagnosisService(
() => FirebaseFunctions.instanceFor(region: 'europe-west3'),
);
});

View file

@ -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<void> _start() async {
final l10n = AppLocalizations.of(context);
final source = await showModalBottomSheet<ImageSource>(
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<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: [
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,

View file

@ -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": {

View file

@ -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:

View file

@ -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.';