diff --git a/docs/architektur.md b/docs/architektur.md index ff64a0e..acf5056 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -48,6 +48,7 @@ Jedes Feature folgt demselben Muster (nicht jedes braucht alle Schichten): - **Haushaltszentriert gedacht:** Pflanzen, Stellplätze und Aufgaben gehören dem *Haushalt*, nicht einem Nutzer. In Firestore wird das `households/{id}/plants/{id}` usw. — so ist V2 (Sharing) nur „weiteres Mitglied hinzufügen", keine Datenmigration. - **Mehrere Haushalte pro Nutzer:** Quelle der Wahrheit für die Mitgliedschaft ist das Feld `memberUids` der Haushalte (`myHouseholdsProvider` fragt sie per `arrayContains` ab). `users/{uid}.householdId` ist nur noch der Zeiger auf den gerade **aktiven** Haushalt — Wechseln heißt: diesen Zeiger umstellen (Haushalts-Screen „Meine Haushalte“ oder Wechsler im Menü). Der aktive Haushalt bestimmt Pflanzen-/Stellplatz-Listen und wohin neue Pflanzen wandern; die **Tages-Checkliste und die Sammel-Push aggregieren dagegen über alle Haushalte** (Checkliste mit Zwischenüberschriften, `DueTask` trägt dafür seine Haushalts-Herkunft). Jeder Haushalt hat einen **Besitzer** (`ownerUid`): nur er entfernt Mitglieder (`removeMember`-Function), er kann nicht austreten; alle anderen können per `leaveHousehold`-Function austreten. Umbenennen dürfen volle Mitglieder direkt (Rules lassen am Haushalts-Dokument clientseitig nur noch `name` zu). - **`null` bei `lastWatered` heißt „noch nie"** → Aufgabe ist sofort fällig. So braucht eine neu angelegte Pflanze keine Sonderbehandlung. +- **Stellplatz-Bewertung (V3) ist zweistufig und beides auf Abruf, nicht automatisch:** (1) Eine **Standort-Analyse pro Stellplatz** (Foto → Cloud Function `analyzeLocation` → Claude schätzt Lichtverhältnisse ein, eine von vier festen Kategorien + Freitext, gespeichert direkt auf `PlantLocation`, keine Historie — immer der aktuelle Stand). (2) **Eignungs-Sterne pro Pflanze** für ihren aktuell zugeordneten Stellplatz (Cloud Function `assessPlantFit`, rein textbasiert anhand Pflegeprofil + vorhandener Standort-Analyse, kein neues Foto nötig), gespeichert direkt auf `Plant` (`fitStars`, `fitReasoning`, `fitLocationId`, `fitAssessedAt`). Beide Schritte lösen ausschließlich Buttons aus — bewusst keine automatische Neuberechnung bei Änderungen, da der Nutzer die Claude-Aufrufe über seinen eigenen API-Key bezahlt. Ein Fit-Ergebnis gilt als **veraltet** (UI zeigt Hinweis statt Sterne), wenn die Pflanze seither an einen anderen Stellplatz verschoben wurde oder der Stellplatz seither neu analysiert wurde — rein clientseitig durch Vergleich der Zeitstempel/IDs berechnet, kein weiteres Feld nötig. - **Haushalts-Bootstrap läuft reaktiv, nicht am Login-Aufruf:** Beim ersten Login (egal ob E-Mail-Registrierung, Apple oder künftig Google) müssen Profil-Dokument (`users/{uid}`) und ein eigener Haushalt angelegt werden — ohne sie lehnen die Rules jeden Schreibzugriff ab. Statt den Bootstrap an die einzelnen Login-Methoden zu hängen (fehleranfällig: der Router leitet schon bei „angemeldet“ weiter, ein Bootstrap-Fehler danach wäre unsichtbar), beobachtet `householdBootstrapProvider` (auth/data) das `users`-Dokument und legt beides an, sobald „angemeldet, aber kein Profil“ eintritt. Das `BootstrapGate` in `app.dart` zeigt so lange einen Warte-Bildschirm und macht Fehler mit „Erneut versuchen“ sichtbar. ## Übersetzungen (i18n) diff --git a/docs/handoff.md b/docs/handoff.md index c11e50f..1658f15 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -1,6 +1,6 @@ # Handoff: LeafItToMe (Pflanzenpflege-App) -Stand: 2026-07-23 (Session-Ende). Sprache mit dem Nutzer (Chris): **Deutsch**. +Stand: 2026-07-24 (Session-Ende). Sprache mit dem Nutzer (Chris): **Deutsch**. ## Projekt in einem Satz @@ -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` (inkl. Kernentscheidungen: berechnete Aufgaben, reaktiver Haushalts-Bootstrap, Umtopfen als Opt-in-Monatsintervall), `docs/firebase-einrichtung.md` (Schritt-für-Schritt-Historie aller Backend-Einrichtungen, zuletzt Schritt 8 Google-Login), `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), `diagnosePlant` (V3: Krankheits-Diagnose per Foto, Claude Vision, Modell `claude-sonnet-5`), `sendDailyReminders` (alle 15 Min, sammelt Gießen/Düngen/Umtopfen über alle Haushalte eines Nutzers), `joinHousehold`, `leaveHousehold`, `removeMember` +- **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, Modell `claude-sonnet-5`), `analyzeLocation` (V3: Standort-Analyse per Foto, Claude Vision, Lichtverhältnisse), `assessPlantFit` (V3: Eignungs-Bewertung einer Pflanze für ihren Stellplatz, rein textbasiert ohne Foto), `sendDailyReminders` (alle 15 Min, sammelt Gießen/Düngen/Umtopfen über alle Haushalte eines Nutzers), `joinHousehold`, `leaveHousehold`, `removeMember` - **Auth:** E-Mail/Passwort + **Apple** (iOS nativ, Android Web-Flow) + **Google** (beide Plattformen Web-Flow, eingebauter `GoogleAuthProvider`) — Code in `lib/features/auth/`. Haushalts-Bootstrap läuft **reaktiv** über `householdBootstrapProvider` + `BootstrapGate` (Maßstab: `householdId` im users-Doc, NICHT Doc-Existenz — die Push-Registrierung legt das users-Doc nebenbei an!). Details der Entscheidung in `docs/architektur.md`. ## Verifizierter Stand @@ -24,20 +24,28 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si - **Google-Login: implementiert und deployt** (2026-07-23, `firebase-einrichtung.md` Schritt 8; Debug-SHA-1/SHA-256 per `firebase apps:android:sha:create` hinterlegt — der SHA-Konsolen-Hinweis betrifft nur den nativen Flow und ist für unseren Browser-Flow irrelevant). Konsolen-Schalter vom Nutzer aktiviert; expliziter Gerätetest nicht rückgemeldet. - **V3 Baustein 1 — Krankheits-Diagnose per Foto: komplett, deployt, vom Nutzer bestätigt** („sieht richtig gut aus"). Function `diagnosePlant` + „Pflanze untersuchen" im Pflanzen-Detail (Kamera/Galerie → Ergebnis-Sheet mit Ampel-Icon, Behandlung, Vorbeugung, KI-Disclaimer; `diagnosis_sheet.dart` gemeinsam genutzt). Nachbesserungen aus Live-Tests, alle bestätigt: Art-Angabe ist unbestätigter Hinweis (`matchesSpecies=false` → Warnkasten „Foto zeigt andere Pflanze"), kein HTML/Markdown im Text, konsequentes Duzen. **Untersuchungshistorie** unter `households/{id}/plants/{id}/diagnoses` (Rules: read/create alle im Haushalt, delete nur member, update nie), auf eigener Seite `/plants/:id/diagnoses`; Button „Frühere Untersuchungen" im Detail ist bei leerer Historie deaktiviert. - **V3 Baustein 2 — Umtopf-Erinnerungen: implementiert und deployt** (2026-07-23, Commit `81d990d`). Dritter Aufgabentyp `repotting`, als Einziger opt-in (`repottingIntervalMonths` optional) und in Kalender-Monaten (31. → Monatsletzter; Logik gespiegelt in `due_tasks_provider` und `reminders.ts`). Formular mit Intervall-Feld + „Zuletzt umgetopft"-Datum, Checkliste + Push („Umtopfen: …"), Sitter dürfen bestätigen. Rules + `sendDailyReminders` sind deployt. **Gerätetest steht aus.** -- Testabdeckung: 13 Widget-Tests grün (u. a. Bootstrap-Heilung, Untersuchungshistorie bis ins Sheet, Umtopf-Flow Ende-zu-Ende), Analyzer sauber, `tsc` kompiliert. +- **V3 Baustein 3 — Stellplatz-Bewertung: implementiert, noch NICHT deployt** (2026-07-24). Konzept mit dem Nutzer abgestimmt: Standort-Daten nur per Foto (keine Zusatzfragen zu Himmelsrichtung), Eignungs-Sterne ausschließlich auf Abruf (Kostenkontrolle, eigener Anthropic-Key). Zwei neue Functions `analyzeLocation` (Foto → Lichtkategorie + Freitext, gespeichert auf `PlantLocation`) und `assessPlantFit` (textbasiert, Pflegeprofil + Standort-Analyse → 1-5 Sterne + Begründung, gespeichert auf `Plant`). Neue Seite `LocationDetailScreen` (`/locations/:id`, erreichbar per Tap in der Stellplätze-Liste): Foto/Analyse-Bereich (nur Mitglieder) + Liste zugeordneter Pflanzen mit Sternen oder „Eignung prüfen"-Button (auch für Sitter, informativ). Pflanzen-Detail zeigt den Stellplatz jetzt kompakt mit Sternen und verlinkt dorthin. Veraltete Bewertungen (Pflanze verschoben oder Stellplatz neu analysiert) werden erkannt und als solche markiert statt falscher Sterne. Firestore-Rules erweitert (Sitter dürfen die vier Fit-Felder schreiben). Details/Begründung der Entscheidungen in `docs/architektur.md`. +- Testabdeckung: 16 Widget-Tests grün (u. a. Bootstrap-Heilung, Untersuchungshistorie bis ins Sheet, Umtopf-Flow Ende-zu-Ende, Stellplatz-Bewertung inkl. Veraltet-Erkennung), Analyzer sauber, `tsc` kompiliert, Android-Debug-Build ok. ## Offen / Nächstes -### 1. Ausstehende Gerätetests (Nutzer, bei Rückmeldung ggf. nachbessern) +### 1. Deploy ausstehend (Nutzer, per `! `) +V3 Baustein 3 ist fertig implementiert und getestet, aber noch nicht auf Produktion. Nötig, bevor Gerätetests möglich sind: + +``` +firebase deploy --only functions:analyzeLocation,functions:assessPlantFit,firestore:rules --project leaf-it-to-me-app +``` + +(Frisch angelegte Functions können beim allerersten Aufruf HTTP 401 liefern — siehe Stolperfallen, dann einfach erneut deployen.) + +### 2. Ausstehende Gerätetests (Nutzer, bei Rückmeldung ggf. nachbessern) + +- **Stellplatz-Bewertung:** Stellplatz öffnen → Foto aufnehmen → Licht-Analyse erscheint (Kategorie-Chip + Text); bei einer zugeordneten Pflanze „Eignung prüfen" → Sterne + Begründung erscheinen; Pflanze auf anderen Stellplatz verschieben → alte Sterne werden als veraltet markiert; Pflanzen-Detail zeigt den Stellplatz samt Sternen kompakt an und verlinkt zur Stellplatz-Seite. - **Umtopf-Flow:** Pflanze bearbeiten → Intervall 12 Monate + „Zuletzt umgetopft" vor >1 Jahr → Aufgabe „… umtopfen" in Checkliste → Erledigt; Push-Text „Umtopfen: …" beim nächsten regulären Slot. - **Historie-Seite:** Button → Liste → Eintrag → Sheet; Löschen mit Rückfrage. - **Google-Login** auf iPhone und Android (erster Login eines Google-Kontos muss kurz „Dein Haushalt wird eingerichtet …" zeigen — der reaktive Bootstrap deckt Google automatisch ab). -### 2. V3 Baustein 3 — Stellplatz-Bewertung (letzter Roadmap-Punkt) - -Zwei-Ebenen-Konzept laut Roadmap: Standort-Analyse pro Stellplatz + Eignungs-Sterne pro Pflanze. **Konzeptlastig — VOR der Implementierung mit dem Nutzer abstimmen.** Bereits angeteaserte offene Fragen: Woher kommen die Standort-Daten (Stellplatz-Foto, Fragen zu Himmelsrichtung/Fensternähe, beides)? Sterne automatisch für alle Pflanzen des Stellplatzes oder auf Abruf (Kostenaspekt: Nutzer zahlt eigenen Anthropic-Key)? - ### 3. Backlog Button „KI-Zweitmeinung" bei unbefriedigendem PlantNet-Ergebnis; Push-Anzeige auch bei App im Vordergrund; App Check vor App-Store-Release (`firebase-einrichtung.md` Schritt 9); Englisch (`app_en.arb`); `firebase-functions`-Paket-Upgrade (Deploy-Warnung, mit Breaking Changes — bei Gelegenheit). diff --git a/firestore.rules b/firestore.rules index 9895d6e..3c1ce8a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -90,7 +90,9 @@ service cloud.firestore { || request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['lastWatered', 'lastWateredBy', 'lastFertilized', 'lastFertilizedBy', - 'lastRepotted', 'lastRepottedBy'])); + 'lastRepotted', 'lastRepottedBy', + 'fitStars', 'fitReasoning', + 'fitLocationId', 'fitAssessedAt'])); // Untersuchungshistorie (KI-Diagnosen). Speichern und lesen darf // jede*r im Haushalt (auch Sitter — die dürfen den Check nutzen); diff --git a/functions/src/index.ts b/functions/src/index.ts index 0bab641..bb6a612 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -67,10 +67,13 @@ async function identifyWithPlantNet( }; } -/** Ein Aufruf der Claude-API mit Bild und Prompt, erwartet reines JSON zurück. */ -async function askClaude( - imageBase64: string, - prompt: string, +type ClaudeContent = + | {type: "text"; text: string} + | {type: "image"; source: {type: "base64"; media_type: string; data: string}}; + +/** Ruft die Claude-API mit fertigem Content-Array auf, erwartet reines JSON zurück. */ +async function callClaude( + content: ClaudeContent[], apiKey: string ): Promise { const response = await fetch("https://api.anthropic.com/v1/messages", { @@ -85,22 +88,7 @@ async function askClaude( // 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", - content: [ - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: imageBase64, - }, - }, - {type: "text", text: prompt}, - ], - }, - ], + messages: [{role: "user", content}], }), }); if (!response.ok) { @@ -121,6 +109,29 @@ async function askClaude( } } +/** Ein Aufruf der Claude-API mit Bild und Prompt, erwartet reines JSON zurück. */ +function askClaude( + imageBase64: string, + prompt: string, + apiKey: string +): Promise { + return callClaude( + [ + { + type: "image", + source: {type: "base64", media_type: "image/jpeg", data: imageBase64}, + }, + {type: "text", text: prompt}, + ], + apiKey + ); +} + +/** Ein reiner Text-Aufruf der Claude-API (ohne Bild), erwartet reines JSON zurück. */ +function askClaudeText(prompt: string, apiKey: string): Promise { + return callClaude([{type: "text", text: prompt}], apiKey); +} + /** * Pflanze per Foto erkennen und ein deutsches Pflegeprofil erstellen. * @@ -299,3 +310,119 @@ export const identifyPlant = onCall( }; } ); + +/** Die vier Licht-Kategorien, auf die Claude sich bei der Standort-Analyse festlegt. */ +const LIGHT_CATEGORIES = ["Volle Sonne", "Hell, indirekt", "Halbschatten", "Schatten"]; + +interface LocationAnalysis { + lightCategory: string; + description: string; +} + +/** + * Standort-Analyse per Foto (V3 Baustein 3). + * + * Claude schätzt die Lichtverhältnisse an einem Stellplatz anhand eines + * Fotos ein. Ergebnis wird von der App auf dem Stellplatz-Dokument + * gespeichert (keine Historie nötig, immer der aktuelle Stand). + */ +export const analyzeLocation = 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ß."); + } + + return askClaude( + imageBase64, + "Du bist Experte für die Lichtverhältnisse an Zimmerpflanzen-Stellplätzen. " + + "Beurteile anhand des Fotos, wie viel und welche Art von Licht an diesem " + + "Stellplatz herrscht (z. B. Fensternähe, direkte Sonneneinstrahlung, " + + "Verschattung durch Vorhänge/Möbel/andere Räume). " + + "Sprache: Deutsch, einfach und freundlich (auch für ältere Menschen gut " + + "verständlich, keine Fachbegriffe ohne Erklärung). Sprich die Person " + + "immer mit „du“ an, niemals mit „Sie“. Schreibe reinen Fließtext ohne " + + "HTML-Tags und ohne Markdown-Formatierung. Antworte NUR mit JSON im " + + `Format {"lightCategory": " `"${c}"`).join(", ")}>", ` + + '"description": "<1-2 Sätze, was auf dem Foto zu den Lichtverhältnissen ' + + 'zu erkennen ist>"}.', + anthropicApiKey.value() + ); + } +); + +interface PlantFitAssessment { + stars: number; + reasoning: string; +} + +/** + * Eignungs-Bewertung einer Pflanze für einen (bereits analysierten) Stellplatz + * (V3 Baustein 3). Rein textbasiert — kein neues Foto nötig, da Pflanzenprofil + * und Standort-Analyse bereits als Text vorliegen. Wird ausschließlich auf + * Abruf durch den Nutzer ausgelöst (Kostenkontrolle). + */ +export const assessPlantFit = onCall( + { + region: "europe-west3", + secrets: [anthropicApiKey], + memory: "512MiB", + timeoutSeconds: 120, + maxInstances: 5, + }, + async (request) => { + if (!request.auth) { + throw new HttpsError("unauthenticated", "Anmeldung erforderlich."); + } + const species = typeof request.data?.species === "string" ? + request.data.species.slice(0, 200) : + ""; + const description = typeof request.data?.description === "string" ? + request.data.description.slice(0, 1000) : + ""; + const careNotes = typeof request.data?.careNotes === "string" ? + request.data.careNotes.slice(0, 1000) : + ""; + const lightCategory = typeof request.data?.lightCategory === "string" ? + request.data.lightCategory.slice(0, 200) : + ""; + const lightAssessment = typeof request.data?.lightAssessment === "string" ? + request.data.lightAssessment.slice(0, 1000) : + ""; + if (!species || !lightCategory) { + throw new HttpsError( + "invalid-argument", + "species und lightCategory sind erforderlich." + ); + } + + return askClaudeText( + "Du bist Pflanzen-Experte. Beurteile, wie gut die folgende Pflanze an " + + "den beschriebenen Stellplatz passt, vor allem im Hinblick auf ihren " + + "Lichtbedarf.\n\n" + + `Pflanze: "${species}". ${description} ${careNotes}\n\n` + + `Stellplatz — Lichtverhältnisse: "${lightCategory}". ${lightAssessment}\n\n` + + "Sprache: Deutsch, einfach und freundlich (auch für ältere Menschen gut " + + "verständlich, keine Fachbegriffe ohne Erklärung). Sprich die Person " + + "immer mit „du“ an, niemals mit „Sie“. Schreibe reinen Fließtext ohne " + + "HTML-Tags und ohne Markdown-Formatierung. Antworte NUR mit JSON im " + + 'Format {"stars": , ' + + '"reasoning": "<1-2 Sätze Begründung>"}.', + anthropicApiKey.value() + ); + } +); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 9fc76a3..1275c2b 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../../features/auth/presentation/login_screen.dart'; import '../../features/household/presentation/household_screen.dart'; +import '../../features/locations/presentation/location_detail_screen.dart'; import '../../features/locations/presentation/locations_screen.dart'; import '../../features/plants/domain/plant.dart'; import '../../features/plants/presentation/plant_detail_screen.dart'; @@ -31,6 +32,9 @@ abstract final class AppRoutes { /// Untersuchungs-Seite einer Pflanze. static String plantDiagnoses(String plantId) => '/plants/$plantId/diagnoses'; + + /// Detailseite eines Stellplatzes (Foto, Standort-Analyse, Eignung). + static String locationDetail(String locationId) => '/locations/$locationId'; } /// Übersetzt einen Stream in ein Listenable, damit der Router bei @@ -100,6 +104,11 @@ final appRouterProvider = Provider((ref) { path: AppRoutes.locations, builder: (context, state) => const LocationsScreen(), ), + GoRoute( + path: '/locations/:id', + builder: (context, state) => + LocationDetailScreen(locationId: state.pathParameters['id']!), + ), GoRoute( path: AppRoutes.household, builder: (context, state) => const HouseholdScreen(), diff --git a/lib/features/locations/data/location_analysis_service.dart b/lib/features/locations/data/location_analysis_service.dart new file mode 100644 index 0000000..b7bbb27 --- /dev/null +++ b/lib/features/locations/data/location_analysis_service.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../household/data/household_providers.dart'; + +/// Ergebnis der Standort-Analyse (Cloud Function analyzeLocation). +class LocationAnalysisResult { + const LocationAnalysisResult({ + required this.lightCategory, + required this.description, + }); + + final String lightCategory; + final String description; +} + +class LocationAnalysisService { + LocationAnalysisService( + this._functionsGetter, + this._storage, + this._householdIdGetter, + ); + + // Lazy, damit Widget-Tests ohne initialisiertes Firebase auskommen — + // FirebaseFunctions.instanceFor wirft sonst schon beim Erzeugen. + final FirebaseFunctions Function() _functionsGetter; + final FirebaseStorage _storage; + final String? Function() _householdIdGetter; + + /// Lädt das Stellplatz-Foto in den Haushalts-Ordner in Storage und + /// liefert die URL. + Future uploadPhoto(Uint8List imageBytes) async { + final householdId = _householdIdGetter(); + if (householdId == null) { + throw StateError('Kein Haushalt geladen – Aktion nicht möglich.'); + } + final ref = _storage.ref( + 'households/$householdId/location-photos/' + '${DateTime.now().millisecondsSinceEpoch}.jpg', + ); + await ref.putData( + imageBytes, + SettableMetadata(contentType: 'image/jpeg'), + ); + return ref.getDownloadURL(); + } + + /// Schickt das Foto an die Cloud Function (Claude) – dauert einige Sekunden. + Future analyze(Uint8List imageBytes) async { + final callable = _functionsGetter().httpsCallable('analyzeLocation'); + final response = await callable.call>({ + 'imageBase64': base64Encode(imageBytes), + }); + final data = response.data; + return LocationAnalysisResult( + lightCategory: data['lightCategory'] as String? ?? '', + description: data['description'] as String? ?? '', + ); + } +} + +final locationAnalysisServiceProvider = + Provider((ref) { + return LocationAnalysisService( + () => FirebaseFunctions.instanceFor(region: 'europe-west3'), + FirebaseStorage.instance, + () => ref.read(householdIdProvider).value, + ); +}); diff --git a/lib/features/locations/data/locations_provider.dart b/lib/features/locations/data/locations_provider.dart index d7f01a8..728ecf9 100644 --- a/lib/features/locations/data/locations_provider.dart +++ b/lib/features/locations/data/locations_provider.dart @@ -8,6 +8,17 @@ import '../domain/plant_location.dart'; /// Firestore-Anbindung der Stellplätze: /// households/{householdId}/locations/{locationId} +PlantLocation _locationFromDoc(String id, Map data) { + return PlantLocation( + id: id, + name: data['name'] as String? ?? '', + photoUrl: data['photoUrl'] as String?, + lightCategory: data['lightCategory'] as String?, + lightAssessment: data['lightAssessment'] as String?, + analyzedAt: (data['analyzedAt'] as Timestamp?)?.toDate(), + ); +} + /// Alle Stellplätze des Haushalts, live aus Firestore. final locationsProvider = StreamProvider>((ref) { final householdId = ref.watch(householdIdProvider).value; @@ -22,7 +33,7 @@ final locationsProvider = StreamProvider>((ref) { .snapshots() .map((snapshot) => [ for (final doc in snapshot.docs) - PlantLocation(id: doc.id, name: doc.data()['name'] as String? ?? ''), + _locationFromDoc(doc.id, doc.data()), ]); }); @@ -53,6 +64,30 @@ class LocationRepository { .collection('locations') .add({'name': name}); } + + /// Speichert das Ergebnis der Standort-Analyse (Foto + Claude-Einschätzung). + Future updateAnalysis( + String locationId, { + required String photoUrl, + required String lightCategory, + required String lightAssessment, + }) { + final householdId = _householdIdGetter(); + if (householdId == null) { + throw StateError('Kein Haushalt geladen – Aktion nicht möglich.'); + } + return _firestore + .collection('households') + .doc(householdId) + .collection('locations') + .doc(locationId) + .update({ + 'photoUrl': photoUrl, + 'lightCategory': lightCategory, + 'lightAssessment': lightAssessment, + 'analyzedAt': FieldValue.serverTimestamp(), + }); + } } final locationRepositoryProvider = Provider((ref) { diff --git a/lib/features/locations/domain/plant_location.dart b/lib/features/locations/domain/plant_location.dart index 24bd9f0..ab01d3f 100644 --- a/lib/features/locations/domain/plant_location.dart +++ b/lib/features/locations/domain/plant_location.dart @@ -2,12 +2,37 @@ import 'package:flutter/foundation.dart'; /// Ein benannter Stellplatz im Haushalt, z. B. "Wohnzimmer-Fensterbank". /// -/// In V3 kommt hier die Standort-Analyse dazu (Foto, Licht-Einschätzung) -/// sowie eine Eignungs-Bewertung pro zugeordneter Pflanze. +/// V3: Standort-Analyse per Foto (Lichtverhältnisse). Die +/// Eignungs-Bewertung pro zugeordneter Pflanze liegt dagegen auf der +/// Pflanze selbst (`Plant.fitStars` etc.), da sie eine Pflanze-Stellplatz- +/// Paarung beschreibt. @immutable class PlantLocation { - const PlantLocation({required this.id, required this.name}); + const PlantLocation({ + required this.id, + required this.name, + this.photoUrl, + this.lightCategory, + this.lightAssessment, + this.analyzedAt, + }); final String id; final String name; + + /// Download-URL des Stellplatz-Fotos in Firebase Storage (optional). + final String? photoUrl; + + /// Kurzes Label, eines von vier festen Werten (siehe Cloud Function + /// `analyzeLocation`): "Volle Sonne", "Hell, indirekt", "Halbschatten", + /// "Schatten". + final String? lightCategory; + + /// Freitext-Begründung der Licht-Einschätzung (1-2 Sätze). + final String? lightAssessment; + + /// Wann die Standort-Analyse zuletzt durchgeführt wurde. Dient dazu, + /// veraltete Eignungs-Sterne einer Pflanze zu erkennen (Pflanze wurde + /// vor der letzten Analyse bewertet). + final DateTime? analyzedAt; } diff --git a/lib/features/locations/presentation/location_detail_screen.dart b/lib/features/locations/presentation/location_detail_screen.dart new file mode 100644 index 0000000..3a14fa6 --- /dev/null +++ b/lib/features/locations/presentation/location_detail_screen.dart @@ -0,0 +1,325 @@ +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../household/data/household_providers.dart'; +import '../../household/domain/household.dart'; +import '../../plants/data/plant_fit_service.dart'; +import '../../plants/data/plants_provider.dart'; +import '../../plants/domain/plant.dart'; +import '../data/location_analysis_service.dart'; +import '../data/locations_provider.dart'; +import '../domain/plant_location.dart'; + +class LocationDetailScreen extends ConsumerWidget { + const LocationDetailScreen({super.key, required this.locationId}); + + final String locationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final location = ref.watch(locationByIdProvider(locationId)); + + if (location == null) { + // Stellplatz wurde gelöscht, während der Screen offen war. + return Scaffold(appBar: AppBar(), body: const SizedBox.shrink()); + } + + final plants = (ref.watch(plantsProvider).value ?? const []) + .where((plant) => plant.locationId == locationId) + .toList(); + final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member; + + return Scaffold( + appBar: AppBar(title: Text(location.name)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _LocationPhotoSection(location: location, canEdit: canEdit), + const Divider(height: 32), + Text( + l10n.locationAssignedPlants, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (plants.isEmpty) + Text( + l10n.locationNoPlantsAssigned, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + else + for (final plant in plants) ...[ + _PlantFitTile(plant: plant, location: location), + const Divider(height: 24), + ], + ], + ), + ); + } +} + +/// Foto vom Stellplatz aufnehmen und von Claude die Lichtverhältnisse +/// einschätzen lassen (V3). Nur volle Mitglieder dürfen das auslösen. +class _LocationPhotoSection extends ConsumerStatefulWidget { + const _LocationPhotoSection({required this.location, required this.canEdit}); + + final PlantLocation location; + final bool canEdit; + + @override + ConsumerState<_LocationPhotoSection> createState() => + _LocationPhotoSectionState(); +} + +class _LocationPhotoSectionState extends ConsumerState<_LocationPhotoSection> { + final _picker = ImagePicker(); + bool _busy = false; + + Future _pickAndAnalyze(ImageSource source) async { + final l10n = AppLocalizations.of(context); + 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 service = ref.read(locationAnalysisServiceProvider); + final photoUrl = await service.uploadPhoto(bytes); + final result = await service.analyze(bytes); + await ref.read(locationRepositoryProvider).updateAnalysis( + widget.location.id, + photoUrl: photoUrl, + lightCategory: result.lightCategory, + lightAssessment: result.description, + ); + } on FirebaseFunctionsException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(e.message ?? l10n.locationAnalysisFailed))); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.locationAnalysisFailed))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final location = widget.location; + + Widget preview; + if (location.photoUrl != null) { + preview = Image.network(location.photoUrl!, fit: BoxFit.cover); + } else { + preview = Container( + color: theme.colorScheme.surfaceContainerHighest, + child: Icon( + Icons.place, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SizedBox( + height: 180, + child: Stack( + fit: StackFit.expand, + children: [ + preview, + if (_busy) + Container( + color: Colors.black45, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 12), + Text( + l10n.locationAnalyzing, + style: const TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + if (widget.canEdit) + Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + onPressed: + _busy ? null : () => _pickAndAnalyze(ImageSource.camera), + icon: const Icon(Icons.photo_camera), + label: Text(l10n.takePhoto), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonalIcon( + onPressed: _busy + ? null + : () => _pickAndAnalyze(ImageSource.gallery), + icon: const Icon(Icons.photo_library), + label: Text(l10n.fromGallery), + ), + ), + ], + ), + const SizedBox(height: 12), + if (location.lightCategory != null) ...[ + Chip(label: Text(location.lightCategory!)), + if (location.lightAssessment != null) ...[ + const SizedBox(height: 8), + Text( + location.lightAssessment!, + style: theme.textTheme.bodyMedium, + ), + ], + ] else + Text( + l10n.locationAnalysisEmpty, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ); + } +} + +/// Eine zugeordnete Pflanze mit ihrer Eignungs-Bewertung (auf Abruf) oder +/// einem Button, um sie anzustoßen. Für alle Rollen nutzbar (informativ), +/// setzt aber eine bereits analysierte Standort-Analyse voraus. +class _PlantFitTile extends ConsumerStatefulWidget { + const _PlantFitTile({required this.plant, required this.location}); + + final Plant plant; + final PlantLocation location; + + @override + ConsumerState<_PlantFitTile> createState() => _PlantFitTileState(); +} + +class _PlantFitTileState extends ConsumerState<_PlantFitTile> { + bool _busy = false; + + Future _check() async { + final l10n = AppLocalizations.of(context); + setState(() => _busy = true); + try { + final result = await ref.read(plantFitServiceProvider).assessFit( + plant: widget.plant, + location: widget.location, + ); + await ref.read(plantRepositoryProvider).saveFitAssessment( + widget.plant.id, + stars: result.stars, + reasoning: result.reasoning, + locationId: widget.location.id, + ); + } on FirebaseFunctionsException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(e.message ?? l10n.fitCheckFailed))); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.fitCheckFailed))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final plant = widget.plant; + final canAnalyze = widget.location.lightCategory != null; + final stale = isFitStale(plant, widget.location); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(plant.nickname, style: theme.textTheme.titleMedium), + if (plant.fitStars != null && !stale) ...[ + const SizedBox(height: 2), + Text( + '★' * plant.fitStars! + '☆' * (5 - plant.fitStars!), + style: theme.textTheme.bodyLarge, + ), + if (plant.fitReasoning != null) + Text( + plant.fitReasoning!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ] else if (plant.fitStars != null && stale) ...[ + const SizedBox(height: 2), + Text( + l10n.fitCheckStale, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _busy || !canAnalyze ? null : _check, + icon: _busy + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.star_outline, size: 18), + label: Text( + _busy + ? l10n.fitChecking + : !canAnalyze + ? l10n.fitCheckNeedsAnalysis + : l10n.fitCheckButton, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/locations/presentation/locations_screen.dart b/lib/features/locations/presentation/locations_screen.dart index 3db29b6..d79ce5c 100644 --- a/lib/features/locations/presentation/locations_screen.dart +++ b/lib/features/locations/presentation/locations_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/router/app_router.dart'; import '../../../core/widgets/app_drawer.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../household/data/household_providers.dart'; @@ -78,6 +80,9 @@ class LocationsScreen extends ConsumerWidget { style: Theme.of(context).textTheme.titleMedium, ), subtitle: Text(l10n.locationPlantCount(plantCount)), + trailing: const Icon(Icons.chevron_right), + onTap: () => + context.push(AppRoutes.locationDetail(location.id)), ); }, ); diff --git a/lib/features/plants/data/plant_fit_service.dart b/lib/features/plants/data/plant_fit_service.dart new file mode 100644 index 0000000..2ecf958 --- /dev/null +++ b/lib/features/plants/data/plant_fit_service.dart @@ -0,0 +1,60 @@ +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../locations/domain/plant_location.dart'; +import '../domain/plant.dart'; + +/// Ein Fit-Ergebnis gilt als veraltet, wenn die Pflanze seither verschoben +/// oder der Stellplatz seither neu analysiert wurde — dann zeigt die UI +/// einen "neu prüfen"-Hinweis statt der (nicht mehr verlässlichen) Sterne. +bool isFitStale(Plant plant, PlantLocation location) { + if (plant.fitLocationId != location.id) return true; + final assessedAt = plant.fitAssessedAt; + final analyzedAt = location.analyzedAt; + if (assessedAt == null) return true; + if (analyzedAt != null && analyzedAt.isAfter(assessedAt)) return true; + return false; +} + +/// Ergebnis der Eignungs-Bewertung (Cloud Function assessPlantFit). +class PlantFitResult { + const PlantFitResult({required this.stars, required this.reasoning}); + + final int stars; + final String reasoning; +} + +class PlantFitService { + PlantFitService(this._functionsGetter); + + // Lazy, damit Widget-Tests ohne initialisiertes Firebase auskommen — + // FirebaseFunctions.instanceFor wirft sonst schon beim Erzeugen. + final FirebaseFunctions Function() _functionsGetter; + + /// Fragt Claude, wie gut [plant] an den (bereits analysierten) [location] + /// passt – rein textbasiert, kein neues Foto nötig. + Future assessFit({ + required Plant plant, + required PlantLocation location, + }) async { + final callable = _functionsGetter().httpsCallable('assessPlantFit'); + final response = await callable.call>({ + 'species': plant.species, + 'description': plant.description, + 'careNotes': plant.careNotes, + 'lightCategory': location.lightCategory ?? '', + 'lightAssessment': location.lightAssessment ?? '', + }); + final data = response.data; + return PlantFitResult( + stars: (data['stars'] as num?)?.toInt() ?? 0, + reasoning: data['reasoning'] as String? ?? '', + ); + } +} + +final plantFitServiceProvider = Provider((ref) { + return PlantFitService( + () => FirebaseFunctions.instanceFor(region: 'europe-west3'), + ); +}); diff --git a/lib/features/plants/data/plants_provider.dart b/lib/features/plants/data/plants_provider.dart index cfc00d4..f81c7cd 100644 --- a/lib/features/plants/data/plants_provider.dart +++ b/lib/features/plants/data/plants_provider.dart @@ -29,6 +29,10 @@ Plant _plantFromDoc(String id, Map data) { (data['repottingIntervalMonths'] as num?)?.toInt(), lastRepotted: (data['lastRepotted'] as Timestamp?)?.toDate(), lastRepottedBy: data['lastRepottedBy'] as String?, + fitStars: (data['fitStars'] as num?)?.toInt(), + fitReasoning: data['fitReasoning'] as String?, + fitLocationId: data['fitLocationId'] as String?, + fitAssessedAt: (data['fitAssessedAt'] as Timestamp?)?.toDate(), ); } @@ -137,6 +141,22 @@ class PlantRepository { '${field}By': _userLabelGetter(), }); } + + /// Speichert das Ergebnis einer Eignungs-Bewertung (V3, auf Abruf) für + /// den aktuell zugeordneten Stellplatz. + Future saveFitAssessment( + String plantId, { + required int stars, + required String reasoning, + required String locationId, + }) { + return _plants.doc(plantId).update({ + 'fitStars': stars, + 'fitReasoning': reasoning, + 'fitLocationId': locationId, + 'fitAssessedAt': Timestamp.now(), + }); + } } final plantRepositoryProvider = Provider((ref) { diff --git a/lib/features/plants/domain/plant.dart b/lib/features/plants/domain/plant.dart index b5444d2..c580ab8 100644 --- a/lib/features/plants/domain/plant.dart +++ b/lib/features/plants/domain/plant.dart @@ -24,6 +24,10 @@ class Plant { this.repottingIntervalMonths, this.lastRepotted, this.lastRepottedBy, + this.fitStars, + this.fitReasoning, + this.fitLocationId, + this.fitAssessedAt, }); final String id; @@ -63,6 +67,15 @@ class Plant { final DateTime? lastRepotted; final String? lastRepottedBy; + /// Eignungs-Bewertung (V3, auf Abruf) für den aktuell zugeordneten + /// Stellplatz: 1-5 Sterne mit kurzer Begründung. Gilt als veraltet, wenn + /// [fitLocationId] nicht mehr [locationId] entspricht (Pflanze wurde + /// verschoben) oder der Stellplatz seither neu analysiert wurde. + final int? fitStars; + final String? fitReasoning; + final String? fitLocationId; + final DateTime? fitAssessedAt; + Plant copyWith({ String? nickname, String? species, @@ -80,6 +93,10 @@ class Plant { // (Intervall entfernen = Erinnerung abschalten). int? Function()? repottingIntervalMonths, DateTime? Function()? lastRepotted, + int? fitStars, + String? fitReasoning, + String? fitLocationId, + DateTime? fitAssessedAt, }) { return Plant( id: id, @@ -101,6 +118,10 @@ class Plant { : this.repottingIntervalMonths, lastRepotted: lastRepotted != null ? lastRepotted() : this.lastRepotted, + fitStars: fitStars ?? this.fitStars, + fitReasoning: fitReasoning ?? this.fitReasoning, + fitLocationId: fitLocationId ?? this.fitLocationId, + fitAssessedAt: fitAssessedAt ?? this.fitAssessedAt, // Bewusst nicht per copyWith änderbar — wird nur beim Bestätigen // einer Umtopf-Aufgabe direkt in Firestore gesetzt. lastRepottedBy: lastRepottedBy, diff --git a/lib/features/plants/presentation/plant_detail_screen.dart b/lib/features/plants/presentation/plant_detail_screen.dart index e050cfb..324a3bb 100644 --- a/lib/features/plants/presentation/plant_detail_screen.dart +++ b/lib/features/plants/presentation/plant_detail_screen.dart @@ -10,11 +10,20 @@ import '../../../l10n/generated/app_localizations.dart'; import '../../household/data/household_providers.dart'; import '../../household/domain/household.dart'; import '../../locations/data/locations_provider.dart'; +import '../../locations/domain/plant_location.dart'; import '../data/plant_diagnosis_service.dart'; +import '../data/plant_fit_service.dart'; import '../data/plants_provider.dart'; import '../domain/plant.dart'; import 'diagnosis_sheet.dart'; +/// Kompakte Sterne-/Status-Anzeige für die Stellplatz-Zeile. +String _fitStatusText(AppLocalizations l10n, Plant plant, PlantLocation location) { + if (plant.fitStars == null) return l10n.fitCheckNotDone; + if (isFitStale(plant, location)) return l10n.fitCheckStale; + return '★' * plant.fitStars! + '☆' * (5 - plant.fitStars!); +} + class PlantDetailScreen extends ConsumerWidget { const PlantDetailScreen({super.key, required this.plantId}); @@ -99,7 +108,12 @@ class PlantDetailScreen extends ConsumerWidget { _InfoTile( icon: Icons.place, label: l10n.locationLabel, - value: location?.name ?? l10n.locationNone, + value: location == null + ? l10n.locationNone + : '${location.name} · ${_fitStatusText(l10n, plant, location)}', + onTap: location == null + ? null + : () => context.push(AppRoutes.locationDetail(location.id)), ), const Divider(height: 32), _InfoTile( @@ -305,16 +319,18 @@ class _InfoTile extends StatelessWidget { required this.icon, required this.label, required this.value, + this.onTap, }); final IconData icon; final String label; final String value; + final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Padding( + final content = Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -336,8 +352,12 @@ class _InfoTile extends StatelessWidget { ], ), ), + if (onTap != null) + Icon(Icons.chevron_right, color: theme.colorScheme.onSurfaceVariant), ], ), ); + if (onTap == null) return content; + return InkWell(onTap: onTap, child: content); } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 38119cc..fe9614d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -126,6 +126,12 @@ "count": { "type": "int" } } }, + "locationAnalysisButton": "Standort analysieren", + "locationAnalyzing": "Standort wird analysiert …", + "locationAnalysisEmpty": "Noch keine Analyse. Foto aufnehmen, damit die KI die Lichtverhältnisse einschätzen kann.", + "locationAnalysisFailed": "Die Analyse hat nicht geklappt. Bitte versuche es erneut.", + "locationAssignedPlants": "Zugeordnete Pflanzen", + "locationNoPlantsAssigned": "Diesem Stellplatz ist noch keine Pflanze zugeordnet.", "householdTitle": "Haushalt", "myHouseholds": "Meine Haushalte", "activeHouseholdLabel": "Aktiv", @@ -277,6 +283,12 @@ "date": { "type": "String" } } }, + "fitCheckButton": "Eignung prüfen", + "fitChecking": "Wird geprüft …", + "fitCheckNeedsAnalysis": "Erst den Stellplatz analysieren (Foto oben)", + "fitCheckStale": "Stellplatz hat sich geändert – neu prüfen", + "fitCheckFailed": "Die Prüfung hat nicht geklappt. Bitte versuche es erneut.", + "fitCheckNotDone": "Eignung noch nicht geprüft", "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 faccec8..ad9bbe2 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -400,6 +400,42 @@ abstract class AppLocalizations { /// **'{count, plural, =0{Keine Pflanzen} one{1 Pflanze} other{{count} Pflanzen}}'** String locationPlantCount(int count); + /// No description provided for @locationAnalysisButton. + /// + /// In de, this message translates to: + /// **'Standort analysieren'** + String get locationAnalysisButton; + + /// No description provided for @locationAnalyzing. + /// + /// In de, this message translates to: + /// **'Standort wird analysiert …'** + String get locationAnalyzing; + + /// No description provided for @locationAnalysisEmpty. + /// + /// In de, this message translates to: + /// **'Noch keine Analyse. Foto aufnehmen, damit die KI die Lichtverhältnisse einschätzen kann.'** + String get locationAnalysisEmpty; + + /// No description provided for @locationAnalysisFailed. + /// + /// In de, this message translates to: + /// **'Die Analyse hat nicht geklappt. Bitte versuche es erneut.'** + String get locationAnalysisFailed; + + /// No description provided for @locationAssignedPlants. + /// + /// In de, this message translates to: + /// **'Zugeordnete Pflanzen'** + String get locationAssignedPlants; + + /// No description provided for @locationNoPlantsAssigned. + /// + /// In de, this message translates to: + /// **'Diesem Stellplatz ist noch keine Pflanze zugeordnet.'** + String get locationNoPlantsAssigned; + /// No description provided for @householdTitle. /// /// In de, this message translates to: @@ -946,6 +982,42 @@ abstract class AppLocalizations { /// **'Untersuchung vom {date} löschen?'** String deleteDiagnosisQuestion(String date); + /// No description provided for @fitCheckButton. + /// + /// In de, this message translates to: + /// **'Eignung prüfen'** + String get fitCheckButton; + + /// No description provided for @fitChecking. + /// + /// In de, this message translates to: + /// **'Wird geprüft …'** + String get fitChecking; + + /// No description provided for @fitCheckNeedsAnalysis. + /// + /// In de, this message translates to: + /// **'Erst den Stellplatz analysieren (Foto oben)'** + String get fitCheckNeedsAnalysis; + + /// No description provided for @fitCheckStale. + /// + /// In de, this message translates to: + /// **'Stellplatz hat sich geändert – neu prüfen'** + String get fitCheckStale; + + /// No description provided for @fitCheckFailed. + /// + /// In de, this message translates to: + /// **'Die Prüfung hat nicht geklappt. Bitte versuche es erneut.'** + String get fitCheckFailed; + + /// No description provided for @fitCheckNotDone. + /// + /// In de, this message translates to: + /// **'Eignung noch nicht geprüft'** + String get fitCheckNotDone; + /// 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 9cc7f78..9aa995b 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -212,6 +212,27 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get locationAnalysisButton => 'Standort analysieren'; + + @override + String get locationAnalyzing => 'Standort wird analysiert …'; + + @override + String get locationAnalysisEmpty => + 'Noch keine Analyse. Foto aufnehmen, damit die KI die Lichtverhältnisse einschätzen kann.'; + + @override + String get locationAnalysisFailed => + 'Die Analyse hat nicht geklappt. Bitte versuche es erneut.'; + + @override + String get locationAssignedPlants => 'Zugeordnete Pflanzen'; + + @override + String get locationNoPlantsAssigned => + 'Diesem Stellplatz ist noch keine Pflanze zugeordnet.'; + @override String get householdTitle => 'Haushalt'; @@ -525,6 +546,26 @@ class AppLocalizationsDe extends AppLocalizations { return 'Untersuchung vom $date löschen?'; } + @override + String get fitCheckButton => 'Eignung prüfen'; + + @override + String get fitChecking => 'Wird geprüft …'; + + @override + String get fitCheckNeedsAnalysis => + 'Erst den Stellplatz analysieren (Foto oben)'; + + @override + String get fitCheckStale => 'Stellplatz hat sich geändert – neu prüfen'; + + @override + String get fitCheckFailed => + 'Die Prüfung hat nicht geklappt. Bitte versuche es erneut.'; + + @override + String get fitCheckNotDone => 'Eignung noch nicht geprüft'; + @override String get saveError => 'Speichern fehlgeschlagen. Bitte versuche es erneut.'; diff --git a/test/widget_test.dart b/test/widget_test.dart index e2eb87f..e7c6391 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -444,6 +444,253 @@ void main() { expect(button.onPressed, isNull); }); + testWidgets( + 'Stellplatz-Bewertung: Standort-Analyse und aktuelle Eignungs-Sterne ' + 'sind sichtbar, auch verlinkt im Pflanzen-Detail', (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 analyzedAt = DateTime(2026, 7, 20, 10, 0); + await firestore + .collection('households') + .doc('h1') + .collection('locations') + .doc('loc1') + .set({ + 'name': 'Fensterbank', + 'lightCategory': 'Hell, indirekt', + 'lightAssessment': 'Viel indirektes Licht, keine direkte Sonne.', + 'analyzedAt': Timestamp.fromDate(analyzedAt), + }); + await firestore + .collection('households') + .doc('h1') + .collection('plants') + .doc('p1') + .set({ + 'nickname': 'Monstera', + 'species': 'Monstera deliciosa', + 'locationId': 'loc1', + 'description': '', + 'careNotes': '', + 'wateringIntervalDays': 7, + 'fertilizingIntervalDays': 28, + 'lastWatered': Timestamp.fromDate(DateTime.now()), + 'lastFertilized': Timestamp.fromDate(DateTime.now()), + 'fitStars': 4, + 'fitReasoning': 'Passt gut zu diesem Standort.', + 'fitLocationId': 'loc1', + // Nach der letzten Analyse bewertet → nicht veraltet. + 'fitAssessedAt': + Timestamp.fromDate(analyzedAt.add(const Duration(minutes: 5))), + }); + + await tester.pumpWidget(ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + firebaseAuthProvider.overrideWithValue(auth), + firestoreProvider.overrideWithValue(firestore), + pushRegistrationServiceProvider + .overrideWithValue(_FakePushRegistrationService()), + ], + child: const LeafItToMeApp(), + )); + await tester.pumpAndSettle(); + + // Pflanzen-Detail zeigt den Stellplatz samt aktuellen Sternen kompakt an. + 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(); + expect(find.textContaining('Fensterbank · ★★★★☆'), findsOneWidget); + + // Antippen navigiert zur Stellplatz-Detailseite mit voller Analyse. + await tester.tap(find.textContaining('Fensterbank · ★★★★☆')); + await tester.pumpAndSettle(); + expect(find.text('Fensterbank'), findsWidgets); + expect(find.text('Hell, indirekt'), findsOneWidget); + expect(find.text('Viel indirektes Licht, keine direkte Sonne.'), + findsOneWidget); + expect(find.text('Zugeordnete Pflanzen'), findsOneWidget); + expect(find.text('★★★★☆'), findsOneWidget); + expect(find.text('Passt gut zu diesem Standort.'), findsOneWidget); + }); + + testWidgets( + 'Stellplatz-Bewertung: veraltete Eignung wird nach neuer Analyse ' + 'als solche markiert statt der alten Sterne', (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 analyzedAt = DateTime(2026, 7, 20, 10, 0); + await firestore + .collection('households') + .doc('h1') + .collection('locations') + .doc('loc1') + .set({ + 'name': 'Fensterbank', + 'lightCategory': 'Hell, indirekt', + 'lightAssessment': 'Viel indirektes Licht, keine direkte Sonne.', + 'analyzedAt': Timestamp.fromDate(analyzedAt), + }); + await firestore + .collection('households') + .doc('h1') + .collection('plants') + .doc('p1') + .set({ + 'nickname': 'Monstera', + 'species': 'Monstera deliciosa', + 'locationId': 'loc1', + 'description': '', + 'careNotes': '', + 'wateringIntervalDays': 7, + 'fertilizingIntervalDays': 28, + 'lastWatered': Timestamp.fromDate(DateTime.now()), + 'lastFertilized': Timestamp.fromDate(DateTime.now()), + 'fitStars': 3, + 'fitReasoning': 'Alte Begründung.', + 'fitLocationId': 'loc1', + // Vor der letzten Analyse bewertet → gilt als veraltet. + 'fitAssessedAt': + Timestamp.fromDate(analyzedAt.subtract(const Duration(days: 1))), + }); + + await tester.pumpWidget(ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + firebaseAuthProvider.overrideWithValue(auth), + firestoreProvider.overrideWithValue(firestore), + pushRegistrationServiceProvider + .overrideWithValue(_FakePushRegistrationService()), + ], + child: const LeafItToMeApp(), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Stellplätze')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fensterbank')); + await tester.pumpAndSettle(); + + expect(find.text('★★★☆☆'), findsNothing); + expect(find.text('Stellplatz hat sich geändert – neu prüfen'), + findsOneWidget); + }); + + testWidgets( + 'Stellplatz-Bewertung: ohne Standort-Analyse ist der ' + 'Eignungs-Check deaktiviert', (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'}, + }, + }); + await firestore + .collection('households') + .doc('h1') + .collection('locations') + .doc('loc1') + .set({'name': 'Fensterbank'}); + await firestore + .collection('households') + .doc('h1') + .collection('plants') + .doc('p1') + .set({ + 'nickname': 'Monstera', + 'species': 'Monstera deliciosa', + 'locationId': 'loc1', + 'description': '', + 'careNotes': '', + 'wateringIntervalDays': 7, + 'fertilizingIntervalDays': 28, + 'lastWatered': Timestamp.fromDate(DateTime.now()), + 'lastFertilized': Timestamp.fromDate(DateTime.now()), + }); + + await tester.pumpWidget(ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + firebaseAuthProvider.overrideWithValue(auth), + firestoreProvider.overrideWithValue(firestore), + pushRegistrationServiceProvider + .overrideWithValue(_FakePushRegistrationService()), + ], + child: const LeafItToMeApp(), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Stellplätze')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fensterbank')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Noch keine Analyse. Foto aufnehmen, damit die KI die ' + 'Lichtverhältnisse einschätzen kann.'), + findsOneWidget); + final fitButton = tester.widget(find.ancestor( + of: find.text('Erst den Stellplatz analysieren (Foto oben)'), + matching: find.byType(OutlinedButton), + )); + expect(fitButton.onPressed, isNull); + }); + testWidgets( 'Umtopf-Erinnerung: fällige Aufgabe erscheint in der Checkliste und ' 'Bestätigen schreibt lastRepotted', (tester) async {