Compare commits
3 commits
a02509c384
...
e81ce48543
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81ce48543 | ||
|
|
5e39ec8118 | ||
|
|
f0b420b656 |
19 changed files with 1235 additions and 61 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Handoff: LeafItToMe (Pflanzenpflege-App)
|
||||
|
||||
Stand: 2026-07-23 (Session-Ende). Sprache mit dem Nutzer (Chris): **Deutsch**.
|
||||
Stand: 2026-08-19 (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
|
||||
|
|
@ -21,24 +21,20 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si
|
|||
- **V1 komplett** (Foto → Erkennung → Profil; Checkliste; Sammel-Push), auf Android + iOS Ende-zu-Ende getestet. iOS-Push seit 2026-07-20 eingerichtet und laufend (`firebase-einrichtung.md` Schritt 6).
|
||||
- **V2/V2.1/V2.2 komplett getestet und bestätigt:** Einladungscodes member/sitter, Multi-Haushalt (`memberUids` = Quelle der Wahrheit, `users.householdId` = aktiver Zeiger), Besitzer-Konzept, Umbenennen, Haushalts-Wechsler, `leaveHousehold`/`removeMember`.
|
||||
- **Apple-Login: implementiert, Bug behoben, vom Nutzer bestätigt** (2026-07-23). Der Bug (neuer Apple-Account konnte nichts speichern) hatte zwei Schichten: Bootstrap hing am Login-Aufruf und wurde vom Router-Redirect überholt; UND die Push-Registrierung hatte das users-Doc ohne `householdId` vorab angelegt, weshalb ein Existenz-Check nicht reichte. Fix: reaktiver Bootstrap + `householdId`-Maßstab + merge-Writes; selbstheilend für kaputte Accounts. Zusätzlich beseitigt: Riverpod-3-Warnung „markNeedsBuild during build" (BootstrapGate als Stack-Overlay statt Baum-Austausch + `checklistWarmupProvider` in `app.dart` gegen das Pausieren unbeobachteter Provider).
|
||||
- **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.
|
||||
- **Google-Login: implementiert, deployt und auf Gerät getestet** (2026-07-23 implementiert, Gerätetest 2026-07-24 bestätigt; `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).
|
||||
- **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. **Historie-Seite auf Gerät getestet und bestätigt** (2026-07-24).
|
||||
- **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 bestätigt** (2026-08-19): Stellplatz-Bewertung „super", Umtopf-Flow selbst funktionierte, aber Chris vermisste eine Anzeige, *wann* das nächste Umtopfen ansteht (das Formularfeld war nicht das Problem). Nachgebessert: Pflanzen-Detailseite zeigt jetzt bei Gießen/Düngen/Umtopfen zusätzlich zum letzten Datum das **nächste Fälligkeitsdatum** an (bzw. „heute fällig"). Berechnung dafür aus `due_tasks_provider.dart` public gemacht (`nextWateringDue`/`nextFertilizingDue`/`nextRepottingDue`) statt dupliziert. Neue l10n-Keys `nextWatering`/`nextFertilizing`/`nextRepotting`/`dueToday`. Zwei Tests ergänzt (Stellplatz- und Umtopf-Test prüfen jetzt auch die neue Anzeige). **Gerätetest für diese Nachbesserung steht noch aus.**
|
||||
- **V3 Baustein 3 — Stellplatz-Bewertung: implementiert und deployt** (2026-07-24, Functions `analyzeLocation`/`assessPlantFit` + Firestore-Rules live). 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 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`. **Gerätetest steht aus** (frisch angelegte Functions können beim allerersten Aufruf HTTP 401 liefern, siehe Stolperfallen).
|
||||
- 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)
|
||||
|
||||
- **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).
|
||||
- **Nächstes-Fälligkeitsdatum auf der Pflanzen-Detailseite** (neu, 2026-08-19): Pflanze öffnen → bei Gießen/Düngen/Umtopfen erscheint jetzt zusätzlich zum letzten Datum eine zweite Zeile „Nächstes Gießen/Düngen/Umtopfen: …" (bzw. „heute fällig").
|
||||
- **Umtopf-Flow — Push-Text:** „Umtopfen: …" beim nächsten regulären Push-Slot (Checkliste + Bestätigen sind bereits bestätigt).
|
||||
|
||||
### 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
|
||||
### 2. 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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -67,10 +67,13 @@ async function identifyWithPlantNet(
|
|||
};
|
||||
}
|
||||
|
||||
/** Ein Aufruf der Claude-API mit Bild und Prompt, erwartet reines JSON zurück. */
|
||||
async function askClaude<T>(
|
||||
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<T>(
|
||||
content: ClaudeContent[],
|
||||
apiKey: string
|
||||
): Promise<T> {
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
|
|
@ -85,22 +88,7 @@ async function askClaude<T>(
|
|||
// 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<T>(
|
|||
}
|
||||
}
|
||||
|
||||
/** Ein Aufruf der Claude-API mit Bild und Prompt, erwartet reines JSON zurück. */
|
||||
function askClaude<T>(
|
||||
imageBase64: string,
|
||||
prompt: string,
|
||||
apiKey: string
|
||||
): Promise<T> {
|
||||
return callClaude<T>(
|
||||
[
|
||||
{
|
||||
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<T>(prompt: string, apiKey: string): Promise<T> {
|
||||
return callClaude<T>([{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<LocationAnalysis>(
|
||||
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": "<GENAU einer der folgenden Werte: ` +
|
||||
`${LIGHT_CATEGORIES.map((c) => `"${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<PlantFitAssessment>(
|
||||
"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": <Ganzzahl 1-5, 5 = optimal geeignet>, ' +
|
||||
'"reasoning": "<1-2 Sätze Begründung>"}.',
|
||||
anthropicApiKey.value()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<GoRouter>((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(),
|
||||
|
|
|
|||
73
lib/features/locations/data/location_analysis_service.dart
Normal file
73
lib/features/locations/data/location_analysis_service.dart
Normal file
|
|
@ -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<String> 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<LocationAnalysisResult> analyze(Uint8List imageBytes) async {
|
||||
final callable = _functionsGetter().httpsCallable('analyzeLocation');
|
||||
final response = await callable.call<Map<String, dynamic>>({
|
||||
'imageBase64': base64Encode(imageBytes),
|
||||
});
|
||||
final data = response.data;
|
||||
return LocationAnalysisResult(
|
||||
lightCategory: data['lightCategory'] as String? ?? '',
|
||||
description: data['description'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final locationAnalysisServiceProvider =
|
||||
Provider<LocationAnalysisService>((ref) {
|
||||
return LocationAnalysisService(
|
||||
() => FirebaseFunctions.instanceFor(region: 'europe-west3'),
|
||||
FirebaseStorage.instance,
|
||||
() => ref.read(householdIdProvider).value,
|
||||
);
|
||||
});
|
||||
|
|
@ -8,6 +8,17 @@ import '../domain/plant_location.dart';
|
|||
/// Firestore-Anbindung der Stellplätze:
|
||||
/// households/{householdId}/locations/{locationId}
|
||||
|
||||
PlantLocation _locationFromDoc(String id, Map<String, dynamic> 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<List<PlantLocation>>((ref) {
|
||||
final householdId = ref.watch(householdIdProvider).value;
|
||||
|
|
@ -22,7 +33,7 @@ final locationsProvider = StreamProvider<List<PlantLocation>>((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<void> 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<LocationRepository>((ref) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
325
lib/features/locations/presentation/location_detail_screen.dart
Normal file
325
lib/features/locations/presentation/location_detail_screen.dart
Normal file
|
|
@ -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<void> _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<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
60
lib/features/plants/data/plant_fit_service.dart
Normal file
60
lib/features/plants/data/plant_fit_service.dart
Normal file
|
|
@ -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<PlantFitResult> assessFit({
|
||||
required Plant plant,
|
||||
required PlantLocation location,
|
||||
}) async {
|
||||
final callable = _functionsGetter().httpsCallable('assessPlantFit');
|
||||
final response = await callable.call<Map<String, dynamic>>({
|
||||
'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<PlantFitService>((ref) {
|
||||
return PlantFitService(
|
||||
() => FirebaseFunctions.instanceFor(region: 'europe-west3'),
|
||||
);
|
||||
});
|
||||
|
|
@ -29,6 +29,10 @@ Plant _plantFromDoc(String id, Map<String, dynamic> 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<void> 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<PlantRepository>((ref) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,11 +10,21 @@ 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 '../../today/application/due_tasks_provider.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});
|
||||
|
||||
|
|
@ -36,6 +46,11 @@ class PlantDetailScreen extends ConsumerWidget {
|
|||
String formatDate(DateTime? date) =>
|
||||
date != null ? dateFormat.format(date) : l10n.never;
|
||||
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
String formatNextDue(DateTime date) =>
|
||||
date.isAfter(today) ? dateFormat.format(date) : l10n.dueToday;
|
||||
|
||||
final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member;
|
||||
|
||||
return Scaffold(
|
||||
|
|
@ -99,33 +114,35 @@ 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(
|
||||
icon: Icons.water_drop,
|
||||
label: l10n.wateringEvery(plant.wateringIntervalDays),
|
||||
value: l10n.lastWatered(formatDate(plant.lastWatered)) +
|
||||
(plant.lastWateredBy != null
|
||||
? l10n.doneBy(plant.lastWateredBy!)
|
||||
: ''),
|
||||
value: '${l10n.lastWatered(formatDate(plant.lastWatered))}'
|
||||
'${plant.lastWateredBy != null ? l10n.doneBy(plant.lastWateredBy!) : ''}'
|
||||
'\n${l10n.nextWatering(formatNextDue(nextWateringDue(plant, today)!))}',
|
||||
),
|
||||
_InfoTile(
|
||||
icon: Icons.compost,
|
||||
label: l10n.fertilizingEvery(plant.fertilizingIntervalDays),
|
||||
value: l10n.lastFertilized(formatDate(plant.lastFertilized)) +
|
||||
(plant.lastFertilizedBy != null
|
||||
? l10n.doneBy(plant.lastFertilizedBy!)
|
||||
: ''),
|
||||
value: '${l10n.lastFertilized(formatDate(plant.lastFertilized))}'
|
||||
'${plant.lastFertilizedBy != null ? l10n.doneBy(plant.lastFertilizedBy!) : ''}'
|
||||
'\n${l10n.nextFertilizing(formatNextDue(nextFertilizingDue(plant, today)!))}',
|
||||
),
|
||||
if (plant.repottingIntervalMonths != null)
|
||||
_InfoTile(
|
||||
icon: Icons.yard,
|
||||
label: l10n.repottingEvery(plant.repottingIntervalMonths!),
|
||||
value: l10n.lastRepotted(formatDate(plant.lastRepotted)) +
|
||||
(plant.lastRepottedBy != null
|
||||
? l10n.doneBy(plant.lastRepottedBy!)
|
||||
: ''),
|
||||
value: '${l10n.lastRepotted(formatDate(plant.lastRepotted))}'
|
||||
'${plant.lastRepottedBy != null ? l10n.doneBy(plant.lastRepottedBy!) : ''}'
|
||||
'\n${l10n.nextRepotting(formatNextDue(nextRepottingDue(plant, today)!))}',
|
||||
),
|
||||
if (plant.description.isNotEmpty) ...[
|
||||
const Divider(height: 32),
|
||||
|
|
@ -305,16 +322,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 +355,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,16 @@ DateTime _addMonths(DateTime date, int months) {
|
|||
year, month, date.day > lastDayOfMonth ? lastDayOfMonth : date.day);
|
||||
}
|
||||
|
||||
/// Fälligkeit fürs Gießen.
|
||||
DateTime? nextWateringDue(Plant plant, DateTime today) =>
|
||||
_nextDue(plant.lastWatered, plant.wateringIntervalDays, today);
|
||||
|
||||
/// Fälligkeit fürs Düngen.
|
||||
DateTime? nextFertilizingDue(Plant plant, DateTime today) =>
|
||||
_nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today);
|
||||
|
||||
/// Fälligkeit fürs Umtopfen — nur wenn ein Intervall gesetzt ist (opt-in).
|
||||
DateTime? _nextRepotDue(Plant plant, DateTime today) {
|
||||
DateTime? nextRepottingDue(Plant plant, DateTime today) {
|
||||
final months = plant.repottingIntervalMonths;
|
||||
if (months == null) return null;
|
||||
if (plant.lastRepotted == null) return today;
|
||||
|
|
@ -35,7 +43,7 @@ DateTime? _nextRepotDue(Plant plant, DateTime today) {
|
|||
|
||||
Iterable<DueTask> _tasksForPlant(
|
||||
Plant plant, Household household, DateTime today) sync* {
|
||||
final watering = _nextDue(plant.lastWatered, plant.wateringIntervalDays, today);
|
||||
final watering = nextWateringDue(plant, today);
|
||||
if (watering != null && !watering.isAfter(today)) {
|
||||
yield DueTask(
|
||||
plant: plant,
|
||||
|
|
@ -45,8 +53,7 @@ Iterable<DueTask> _tasksForPlant(
|
|||
householdName: household.name,
|
||||
);
|
||||
}
|
||||
final fertilizing =
|
||||
_nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today);
|
||||
final fertilizing = nextFertilizingDue(plant, today);
|
||||
if (fertilizing != null && !fertilizing.isAfter(today)) {
|
||||
yield DueTask(
|
||||
plant: plant,
|
||||
|
|
@ -56,7 +63,7 @@ Iterable<DueTask> _tasksForPlant(
|
|||
householdName: household.name,
|
||||
);
|
||||
}
|
||||
final repotting = _nextRepotDue(plant, today);
|
||||
final repotting = nextRepottingDue(plant, today);
|
||||
if (repotting != null && !repotting.isAfter(today)) {
|
||||
yield DueTask(
|
||||
plant: plant,
|
||||
|
|
@ -139,9 +146,9 @@ final nextDueDateProvider = Provider<DateTime?>((ref) {
|
|||
ref.watch(householdPlantsProvider(household.id)).value ?? const [];
|
||||
for (final plant in plants) {
|
||||
for (final candidate in [
|
||||
_nextDue(plant.lastWatered, plant.wateringIntervalDays, today),
|
||||
_nextDue(plant.lastFertilized, plant.fertilizingIntervalDays, today),
|
||||
_nextRepotDue(plant, today),
|
||||
nextWateringDue(plant, today),
|
||||
nextFertilizingDue(plant, today),
|
||||
nextRepottingDue(plant, today),
|
||||
]) {
|
||||
if (candidate == null || !candidate.isAfter(today)) continue;
|
||||
if (next == null || candidate.isBefore(next)) next = candidate;
|
||||
|
|
|
|||
|
|
@ -104,6 +104,25 @@
|
|||
"date": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"nextWatering": "Nächstes Gießen: {date}",
|
||||
"@nextWatering": {
|
||||
"placeholders": {
|
||||
"date": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"nextFertilizing": "Nächstes Düngen: {date}",
|
||||
"@nextFertilizing": {
|
||||
"placeholders": {
|
||||
"date": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"nextRepotting": "Nächstes Umtopfen: {date}",
|
||||
"@nextRepotting": {
|
||||
"placeholders": {
|
||||
"date": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"dueToday": "heute fällig",
|
||||
"never": "noch nie",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
|
|
@ -126,6 +145,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 +302,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": {
|
||||
|
|
|
|||
|
|
@ -328,6 +328,30 @@ abstract class AppLocalizations {
|
|||
/// **'Zuletzt gedüngt: {date}'**
|
||||
String lastFertilized(String date);
|
||||
|
||||
/// No description provided for @nextWatering.
|
||||
///
|
||||
/// In de, this message translates to:
|
||||
/// **'Nächstes Gießen: {date}'**
|
||||
String nextWatering(String date);
|
||||
|
||||
/// No description provided for @nextFertilizing.
|
||||
///
|
||||
/// In de, this message translates to:
|
||||
/// **'Nächstes Düngen: {date}'**
|
||||
String nextFertilizing(String date);
|
||||
|
||||
/// No description provided for @nextRepotting.
|
||||
///
|
||||
/// In de, this message translates to:
|
||||
/// **'Nächstes Umtopfen: {date}'**
|
||||
String nextRepotting(String date);
|
||||
|
||||
/// No description provided for @dueToday.
|
||||
///
|
||||
/// In de, this message translates to:
|
||||
/// **'heute fällig'**
|
||||
String get dueToday;
|
||||
|
||||
/// No description provided for @never.
|
||||
///
|
||||
/// In de, this message translates to:
|
||||
|
|
@ -400,6 +424,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 +1006,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:
|
||||
|
|
|
|||
|
|
@ -165,6 +165,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return 'Zuletzt gedüngt: $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String nextWatering(String date) {
|
||||
return 'Nächstes Gießen: $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String nextFertilizing(String date) {
|
||||
return 'Nächstes Düngen: $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String nextRepotting(String date) {
|
||||
return 'Nächstes Umtopfen: $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dueToday => 'heute fällig';
|
||||
|
||||
@override
|
||||
String get never => 'noch nie';
|
||||
|
||||
|
|
@ -212,6 +230,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 +564,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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -444,6 +444,256 @@ 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);
|
||||
// Nächste Fälligkeit wird zusätzlich zum letzten Datum angezeigt.
|
||||
expect(find.textContaining('Nächstes Gießen:'), findsOneWidget);
|
||||
expect(find.textContaining('Nächstes Düngen:'), 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<OutlinedButton>(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 {
|
||||
|
|
@ -516,6 +766,15 @@ void main() {
|
|||
final lastRepotted = (doc.data()?['lastRepotted'] as Timestamp).toDate();
|
||||
expect(now.difference(lastRepotted).inDays, 0);
|
||||
expect(doc.data()?['lastRepottedBy'], 'test');
|
||||
|
||||
// Detailseite zeigt jetzt zusätzlich die nächste Fälligkeit 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('Nächstes Umtopfen:'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Mitglied kann den Haushalt umbenennen', (tester) async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue