V3: Stellplatz-Bewertung (Standort-Analyse per Foto + Eignungs-Sterne auf Abruf)
Letzter Roadmap-Punkt von V3. Foto-basierte Lichtverhältnis-Analyse pro Stellplatz (Cloud Function analyzeLocation) und darauf aufbauende, rein textbasierte Eignungs-Bewertung einer Pflanze (assessPlantFit) — beides ausschließlich auf Abruf, nicht automatisch (Kostenkontrolle, eigener Anthropic-Key). Neue Stellplatz-Detailseite, Verlinkung im Pflanzen-Detail, Erkennung veralteter Bewertungen bei Umzug/Neuanalyse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a02509c384
commit
f0b420b656
18 changed files with 1138 additions and 35 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.
|
- **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).
|
- **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.
|
- **`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.
|
- **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)
|
## Übersetzungen (i18n)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Handoff: LeafItToMe (Pflanzenpflege-App)
|
# 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
|
## 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`
|
- **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)
|
- **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)
|
- **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`.
|
- **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
|
## 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.
|
- **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 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.**
|
- **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
|
## Offen / Nächstes
|
||||||
|
|
||||||
### 1. Ausstehende Gerätetests (Nutzer, bei Rückmeldung ggf. nachbessern)
|
### 1. Deploy ausstehend (Nutzer, per `! <kommando>`)
|
||||||
|
|
||||||
|
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.
|
- **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.
|
- **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).
|
- **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
|
### 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).
|
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()
|
|| request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['lastWatered', 'lastWateredBy',
|
.hasOnly(['lastWatered', 'lastWateredBy',
|
||||||
'lastFertilized', 'lastFertilizedBy',
|
'lastFertilized', 'lastFertilizedBy',
|
||||||
'lastRepotted', 'lastRepottedBy']));
|
'lastRepotted', 'lastRepottedBy',
|
||||||
|
'fitStars', 'fitReasoning',
|
||||||
|
'fitLocationId', 'fitAssessedAt']));
|
||||||
|
|
||||||
// Untersuchungshistorie (KI-Diagnosen). Speichern und lesen darf
|
// Untersuchungshistorie (KI-Diagnosen). Speichern und lesen darf
|
||||||
// jede*r im Haushalt (auch Sitter — die dürfen den Check nutzen);
|
// 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. */
|
type ClaudeContent =
|
||||||
async function askClaude<T>(
|
| {type: "text"; text: string}
|
||||||
imageBase64: string,
|
| {type: "image"; source: {type: "base64"; media_type: string; data: string}};
|
||||||
prompt: string,
|
|
||||||
|
/** Ruft die Claude-API mit fertigem Content-Array auf, erwartet reines JSON zurück. */
|
||||||
|
async function callClaude<T>(
|
||||||
|
content: ClaudeContent[],
|
||||||
apiKey: string
|
apiKey: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
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
|
// Claude Sonnet 5 denkt standardmäßig adaptiv mit; das Limit deckt
|
||||||
// Denk- UND Antwort-Tokens ab — großzügig, damit nichts abreißt.
|
// Denk- UND Antwort-Tokens ab — großzügig, damit nichts abreißt.
|
||||||
max_tokens: 2048,
|
max_tokens: 2048,
|
||||||
messages: [
|
messages: [{role: "user", content}],
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "image",
|
|
||||||
source: {
|
|
||||||
type: "base64",
|
|
||||||
media_type: "image/jpeg",
|
|
||||||
data: imageBase64,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{type: "text", text: prompt},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
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.
|
* 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/auth/presentation/login_screen.dart';
|
||||||
import '../../features/household/presentation/household_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/locations/presentation/locations_screen.dart';
|
||||||
import '../../features/plants/domain/plant.dart';
|
import '../../features/plants/domain/plant.dart';
|
||||||
import '../../features/plants/presentation/plant_detail_screen.dart';
|
import '../../features/plants/presentation/plant_detail_screen.dart';
|
||||||
|
|
@ -31,6 +32,9 @@ abstract final class AppRoutes {
|
||||||
/// Untersuchungs-Seite einer Pflanze.
|
/// Untersuchungs-Seite einer Pflanze.
|
||||||
static String plantDiagnoses(String plantId) =>
|
static String plantDiagnoses(String plantId) =>
|
||||||
'/plants/$plantId/diagnoses';
|
'/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
|
/// Übersetzt einen Stream in ein Listenable, damit der Router bei
|
||||||
|
|
@ -100,6 +104,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
path: AppRoutes.locations,
|
path: AppRoutes.locations,
|
||||||
builder: (context, state) => const LocationsScreen(),
|
builder: (context, state) => const LocationsScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/locations/:id',
|
||||||
|
builder: (context, state) =>
|
||||||
|
LocationDetailScreen(locationId: state.pathParameters['id']!),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.household,
|
path: AppRoutes.household,
|
||||||
builder: (context, state) => const HouseholdScreen(),
|
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:
|
/// Firestore-Anbindung der Stellplätze:
|
||||||
/// households/{householdId}/locations/{locationId}
|
/// 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.
|
/// Alle Stellplätze des Haushalts, live aus Firestore.
|
||||||
final locationsProvider = StreamProvider<List<PlantLocation>>((ref) {
|
final locationsProvider = StreamProvider<List<PlantLocation>>((ref) {
|
||||||
final householdId = ref.watch(householdIdProvider).value;
|
final householdId = ref.watch(householdIdProvider).value;
|
||||||
|
|
@ -22,7 +33,7 @@ final locationsProvider = StreamProvider<List<PlantLocation>>((ref) {
|
||||||
.snapshots()
|
.snapshots()
|
||||||
.map((snapshot) => [
|
.map((snapshot) => [
|
||||||
for (final doc in snapshot.docs)
|
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')
|
.collection('locations')
|
||||||
.add({'name': name});
|
.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) {
|
final locationRepositoryProvider = Provider<LocationRepository>((ref) {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,37 @@ import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
/// Ein benannter Stellplatz im Haushalt, z. B. "Wohnzimmer-Fensterbank".
|
/// Ein benannter Stellplatz im Haushalt, z. B. "Wohnzimmer-Fensterbank".
|
||||||
///
|
///
|
||||||
/// In V3 kommt hier die Standort-Analyse dazu (Foto, Licht-Einschätzung)
|
/// V3: Standort-Analyse per Foto (Lichtverhältnisse). Die
|
||||||
/// sowie eine Eignungs-Bewertung pro zugeordneter Pflanze.
|
/// Eignungs-Bewertung pro zugeordneter Pflanze liegt dagegen auf der
|
||||||
|
/// Pflanze selbst (`Plant.fitStars` etc.), da sie eine Pflanze-Stellplatz-
|
||||||
|
/// Paarung beschreibt.
|
||||||
@immutable
|
@immutable
|
||||||
class PlantLocation {
|
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 id;
|
||||||
final String name;
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../../core/widgets/app_drawer.dart';
|
||||||
import '../../../l10n/generated/app_localizations.dart';
|
import '../../../l10n/generated/app_localizations.dart';
|
||||||
import '../../household/data/household_providers.dart';
|
import '../../household/data/household_providers.dart';
|
||||||
|
|
@ -78,6 +80,9 @@ class LocationsScreen extends ConsumerWidget {
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
subtitle: Text(l10n.locationPlantCount(plantCount)),
|
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(),
|
(data['repottingIntervalMonths'] as num?)?.toInt(),
|
||||||
lastRepotted: (data['lastRepotted'] as Timestamp?)?.toDate(),
|
lastRepotted: (data['lastRepotted'] as Timestamp?)?.toDate(),
|
||||||
lastRepottedBy: data['lastRepottedBy'] as String?,
|
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(),
|
'${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) {
|
final plantRepositoryProvider = Provider<PlantRepository>((ref) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ class Plant {
|
||||||
this.repottingIntervalMonths,
|
this.repottingIntervalMonths,
|
||||||
this.lastRepotted,
|
this.lastRepotted,
|
||||||
this.lastRepottedBy,
|
this.lastRepottedBy,
|
||||||
|
this.fitStars,
|
||||||
|
this.fitReasoning,
|
||||||
|
this.fitLocationId,
|
||||||
|
this.fitAssessedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -63,6 +67,15 @@ class Plant {
|
||||||
final DateTime? lastRepotted;
|
final DateTime? lastRepotted;
|
||||||
final String? lastRepottedBy;
|
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({
|
Plant copyWith({
|
||||||
String? nickname,
|
String? nickname,
|
||||||
String? species,
|
String? species,
|
||||||
|
|
@ -80,6 +93,10 @@ class Plant {
|
||||||
// (Intervall entfernen = Erinnerung abschalten).
|
// (Intervall entfernen = Erinnerung abschalten).
|
||||||
int? Function()? repottingIntervalMonths,
|
int? Function()? repottingIntervalMonths,
|
||||||
DateTime? Function()? lastRepotted,
|
DateTime? Function()? lastRepotted,
|
||||||
|
int? fitStars,
|
||||||
|
String? fitReasoning,
|
||||||
|
String? fitLocationId,
|
||||||
|
DateTime? fitAssessedAt,
|
||||||
}) {
|
}) {
|
||||||
return Plant(
|
return Plant(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -101,6 +118,10 @@ class Plant {
|
||||||
: this.repottingIntervalMonths,
|
: this.repottingIntervalMonths,
|
||||||
lastRepotted:
|
lastRepotted:
|
||||||
lastRepotted != null ? lastRepotted() : this.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
|
// Bewusst nicht per copyWith änderbar — wird nur beim Bestätigen
|
||||||
// einer Umtopf-Aufgabe direkt in Firestore gesetzt.
|
// einer Umtopf-Aufgabe direkt in Firestore gesetzt.
|
||||||
lastRepottedBy: lastRepottedBy,
|
lastRepottedBy: lastRepottedBy,
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,20 @@ import '../../../l10n/generated/app_localizations.dart';
|
||||||
import '../../household/data/household_providers.dart';
|
import '../../household/data/household_providers.dart';
|
||||||
import '../../household/domain/household.dart';
|
import '../../household/domain/household.dart';
|
||||||
import '../../locations/data/locations_provider.dart';
|
import '../../locations/data/locations_provider.dart';
|
||||||
|
import '../../locations/domain/plant_location.dart';
|
||||||
import '../data/plant_diagnosis_service.dart';
|
import '../data/plant_diagnosis_service.dart';
|
||||||
|
import '../data/plant_fit_service.dart';
|
||||||
import '../data/plants_provider.dart';
|
import '../data/plants_provider.dart';
|
||||||
import '../domain/plant.dart';
|
import '../domain/plant.dart';
|
||||||
import 'diagnosis_sheet.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 {
|
class PlantDetailScreen extends ConsumerWidget {
|
||||||
const PlantDetailScreen({super.key, required this.plantId});
|
const PlantDetailScreen({super.key, required this.plantId});
|
||||||
|
|
||||||
|
|
@ -99,7 +108,12 @@ class PlantDetailScreen extends ConsumerWidget {
|
||||||
_InfoTile(
|
_InfoTile(
|
||||||
icon: Icons.place,
|
icon: Icons.place,
|
||||||
label: l10n.locationLabel,
|
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),
|
const Divider(height: 32),
|
||||||
_InfoTile(
|
_InfoTile(
|
||||||
|
|
@ -305,16 +319,18 @@ class _InfoTile extends StatelessWidget {
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Padding(
|
final content = Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,12 @@
|
||||||
"count": { "type": "int" }
|
"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",
|
"householdTitle": "Haushalt",
|
||||||
"myHouseholds": "Meine Haushalte",
|
"myHouseholds": "Meine Haushalte",
|
||||||
"activeHouseholdLabel": "Aktiv",
|
"activeHouseholdLabel": "Aktiv",
|
||||||
|
|
@ -277,6 +283,12 @@
|
||||||
"date": { "type": "String" }
|
"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.",
|
"saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.",
|
||||||
"saveErrorDetail": "Speichern fehlgeschlagen ({detail}). Bitte versuche es erneut.",
|
"saveErrorDetail": "Speichern fehlgeschlagen ({detail}). Bitte versuche es erneut.",
|
||||||
"@saveErrorDetail": {
|
"@saveErrorDetail": {
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,42 @@ abstract class AppLocalizations {
|
||||||
/// **'{count, plural, =0{Keine Pflanzen} one{1 Pflanze} other{{count} Pflanzen}}'**
|
/// **'{count, plural, =0{Keine Pflanzen} one{1 Pflanze} other{{count} Pflanzen}}'**
|
||||||
String locationPlantCount(int count);
|
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.
|
/// No description provided for @householdTitle.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
|
|
@ -946,6 +982,42 @@ abstract class AppLocalizations {
|
||||||
/// **'Untersuchung vom {date} löschen?'**
|
/// **'Untersuchung vom {date} löschen?'**
|
||||||
String deleteDiagnosisQuestion(String date);
|
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.
|
/// No description provided for @saveError.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,27 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$_temp0';
|
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
|
@override
|
||||||
String get householdTitle => 'Haushalt';
|
String get householdTitle => 'Haushalt';
|
||||||
|
|
||||||
|
|
@ -525,6 +546,26 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return 'Untersuchung vom $date löschen?';
|
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
|
@override
|
||||||
String get saveError => 'Speichern fehlgeschlagen. Bitte versuche es erneut.';
|
String get saveError => 'Speichern fehlgeschlagen. Bitte versuche es erneut.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,253 @@ void main() {
|
||||||
expect(button.onPressed, isNull);
|
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<OutlinedButton>(find.ancestor(
|
||||||
|
of: find.text('Erst den Stellplatz analysieren (Foto oben)'),
|
||||||
|
matching: find.byType(OutlinedButton),
|
||||||
|
));
|
||||||
|
expect(fitButton.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'Umtopf-Erinnerung: fällige Aufgabe erscheint in der Checkliste und '
|
'Umtopf-Erinnerung: fällige Aufgabe erscheint in der Checkliste und '
|
||||||
'Bestätigen schreibt lastRepotted', (tester) async {
|
'Bestätigen schreibt lastRepotted', (tester) async {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue