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:
cschlaefke 2026-07-24 00:17:18 +02:00
parent a02509c384
commit f0b420b656
18 changed files with 1138 additions and 35 deletions

View file

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

View file

@ -1,6 +1,6 @@
# Handoff: LeafItToMe (Pflanzenpflege-App)
Stand: 2026-07-23 (Session-Ende). Sprache mit dem Nutzer (Chris): **Deutsch**.
Stand: 2026-07-24 (Session-Ende). Sprache mit dem Nutzer (Chris): **Deutsch**.
## Projekt in einem Satz
@ -13,7 +13,7 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si
- **Doku (im Repo, deutsch, aktuell halten!):** `README.md`, `docs/architektur.md` (inkl. Kernentscheidungen: berechnete Aufgaben, reaktiver Haushalts-Bootstrap, Umtopfen als Opt-in-Monatsintervall), `docs/firebase-einrichtung.md` (Schritt-für-Schritt-Historie aller Backend-Einrichtungen, zuletzt Schritt 8 Google-Login), `docs/git-forgejo.md`
- **Claude-Memory:** Projektplan, Nutzerprofil und Backlog liegen im persistenten Memory-Verzeichnis dieser Claude-Code-Installation (werden automatisch geladen)
- **Firebase-Projekt:** `leaf-it-to-me-app` (Blaze aktiv, Budget-Alarm eingerichtet; Firebase CLI ist eingeloggt)
- **Functions:** `functions/` (TypeScript, Node 22, Region europe-west3): `identifyPlant` (PlantNet + Claude, Secrets `PLANTNET_API_KEY`/`ANTHROPIC_API_KEY` im Secret Manager), `diagnosePlant` (V3: Krankheits-Diagnose per Foto, Claude Vision, Modell `claude-sonnet-5`), `sendDailyReminders` (alle 15 Min, sammelt Gießen/Düngen/Umtopfen über alle Haushalte eines Nutzers), `joinHousehold`, `leaveHousehold`, `removeMember`
- **Functions:** `functions/` (TypeScript, Node 22, Region europe-west3): `identifyPlant` (PlantNet + Claude, Secrets `PLANTNET_API_KEY`/`ANTHROPIC_API_KEY` im Secret Manager), `diagnosePlant` (V3: Krankheits-Diagnose per Foto, Claude Vision, Modell `claude-sonnet-5`), `analyzeLocation` (V3: Standort-Analyse per Foto, Claude Vision, Lichtverhältnisse), `assessPlantFit` (V3: Eignungs-Bewertung einer Pflanze für ihren Stellplatz, rein textbasiert ohne Foto), `sendDailyReminders` (alle 15 Min, sammelt Gießen/Düngen/Umtopfen über alle Haushalte eines Nutzers), `joinHousehold`, `leaveHousehold`, `removeMember`
- **Auth:** E-Mail/Passwort + **Apple** (iOS nativ, Android Web-Flow) + **Google** (beide Plattformen Web-Flow, eingebauter `GoogleAuthProvider`) — Code in `lib/features/auth/`. Haushalts-Bootstrap läuft **reaktiv** über `householdBootstrapProvider` + `BootstrapGate` (Maßstab: `householdId` im users-Doc, NICHT Doc-Existenz — die Push-Registrierung legt das users-Doc nebenbei an!). Details der Entscheidung in `docs/architektur.md`.
## Verifizierter Stand
@ -24,20 +24,28 @@ Private Flutter-App (iOS+Android) zur Pflanzenpflege für Haushalt + Pflanzen-Si
- **Google-Login: implementiert und deployt** (2026-07-23, `firebase-einrichtung.md` Schritt 8; Debug-SHA-1/SHA-256 per `firebase apps:android:sha:create` hinterlegt — der SHA-Konsolen-Hinweis betrifft nur den nativen Flow und ist für unseren Browser-Flow irrelevant). Konsolen-Schalter vom Nutzer aktiviert; expliziter Gerätetest nicht rückgemeldet.
- **V3 Baustein 1 — Krankheits-Diagnose per Foto: komplett, deployt, vom Nutzer bestätigt** („sieht richtig gut aus"). Function `diagnosePlant` + „Pflanze untersuchen" im Pflanzen-Detail (Kamera/Galerie → Ergebnis-Sheet mit Ampel-Icon, Behandlung, Vorbeugung, KI-Disclaimer; `diagnosis_sheet.dart` gemeinsam genutzt). Nachbesserungen aus Live-Tests, alle bestätigt: Art-Angabe ist unbestätigter Hinweis (`matchesSpecies=false` → Warnkasten „Foto zeigt andere Pflanze"), kein HTML/Markdown im Text, konsequentes Duzen. **Untersuchungshistorie** unter `households/{id}/plants/{id}/diagnoses` (Rules: read/create alle im Haushalt, delete nur member, update nie), auf eigener Seite `/plants/:id/diagnoses`; Button „Frühere Untersuchungen" im Detail ist bei leerer Historie deaktiviert.
- **V3 Baustein 2 — Umtopf-Erinnerungen: implementiert und deployt** (2026-07-23, Commit `81d990d`). Dritter Aufgabentyp `repotting`, als Einziger opt-in (`repottingIntervalMonths` optional) und in Kalender-Monaten (31. → Monatsletzter; Logik gespiegelt in `due_tasks_provider` und `reminders.ts`). Formular mit Intervall-Feld + „Zuletzt umgetopft"-Datum, Checkliste + Push („Umtopfen: …"), Sitter dürfen bestätigen. Rules + `sendDailyReminders` sind deployt. **Gerätetest steht aus.**
- Testabdeckung: 13 Widget-Tests grün (u. a. Bootstrap-Heilung, Untersuchungshistorie bis ins Sheet, Umtopf-Flow Ende-zu-Ende), Analyzer sauber, `tsc` kompiliert.
- **V3 Baustein 3 — Stellplatz-Bewertung: implementiert, noch NICHT deployt** (2026-07-24). Konzept mit dem Nutzer abgestimmt: Standort-Daten nur per Foto (keine Zusatzfragen zu Himmelsrichtung), Eignungs-Sterne ausschließlich auf Abruf (Kostenkontrolle, eigener Anthropic-Key). Zwei neue Functions `analyzeLocation` (Foto → Lichtkategorie + Freitext, gespeichert auf `PlantLocation`) und `assessPlantFit` (textbasiert, Pflegeprofil + Standort-Analyse → 1-5 Sterne + Begründung, gespeichert auf `Plant`). Neue Seite `LocationDetailScreen` (`/locations/:id`, erreichbar per Tap in der Stellplätze-Liste): Foto/Analyse-Bereich (nur Mitglieder) + Liste zugeordneter Pflanzen mit Sternen oder „Eignung prüfen"-Button (auch für Sitter, informativ). Pflanzen-Detail zeigt den Stellplatz jetzt kompakt mit Sternen und verlinkt dorthin. Veraltete Bewertungen (Pflanze verschoben oder Stellplatz neu analysiert) werden erkannt und als solche markiert statt falscher Sterne. Firestore-Rules erweitert (Sitter dürfen die vier Fit-Felder schreiben). Details/Begründung der Entscheidungen in `docs/architektur.md`.
- Testabdeckung: 16 Widget-Tests grün (u. a. Bootstrap-Heilung, Untersuchungshistorie bis ins Sheet, Umtopf-Flow Ende-zu-Ende, Stellplatz-Bewertung inkl. Veraltet-Erkennung), Analyzer sauber, `tsc` kompiliert, Android-Debug-Build ok.
## Offen / Nächstes
### 1. Ausstehende Gerätetests (Nutzer, bei Rückmeldung ggf. nachbessern)
### 1. Deploy ausstehend (Nutzer, per `! <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.
- **Historie-Seite:** Button → Liste → Eintrag → Sheet; Löschen mit Rückfrage.
- **Google-Login** auf iPhone und Android (erster Login eines Google-Kontos muss kurz „Dein Haushalt wird eingerichtet …" zeigen — der reaktive Bootstrap deckt Google automatisch ab).
### 2. V3 Baustein 3 — Stellplatz-Bewertung (letzter Roadmap-Punkt)
Zwei-Ebenen-Konzept laut Roadmap: Standort-Analyse pro Stellplatz + Eignungs-Sterne pro Pflanze. **Konzeptlastig — VOR der Implementierung mit dem Nutzer abstimmen.** Bereits angeteaserte offene Fragen: Woher kommen die Standort-Daten (Stellplatz-Foto, Fragen zu Himmelsrichtung/Fensternähe, beides)? Sterne automatisch für alle Pflanzen des Stellplatzes oder auf Abruf (Kostenaspekt: Nutzer zahlt eigenen Anthropic-Key)?
### 3. Backlog
Button „KI-Zweitmeinung" bei unbefriedigendem PlantNet-Ergebnis; Push-Anzeige auch bei App im Vordergrund; App Check vor App-Store-Release (`firebase-einrichtung.md` Schritt 9); Englisch (`app_en.arb`); `firebase-functions`-Paket-Upgrade (Deploy-Warnung, mit Breaking Changes — bei Gelegenheit).

View file

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

View file

@ -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()
);
}
);

View file

@ -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(),

View 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,
);
});

View file

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

View file

@ -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;
}

View 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,
),
),
],
),
);
}
}

View file

@ -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)),
);
},
);

View 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'),
);
});

View file

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

View file

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

View file

@ -10,11 +10,20 @@ import '../../../l10n/generated/app_localizations.dart';
import '../../household/data/household_providers.dart';
import '../../household/domain/household.dart';
import '../../locations/data/locations_provider.dart';
import '../../locations/domain/plant_location.dart';
import '../data/plant_diagnosis_service.dart';
import '../data/plant_fit_service.dart';
import '../data/plants_provider.dart';
import '../domain/plant.dart';
import 'diagnosis_sheet.dart';
/// Kompakte Sterne-/Status-Anzeige für die Stellplatz-Zeile.
String _fitStatusText(AppLocalizations l10n, Plant plant, PlantLocation location) {
if (plant.fitStars == null) return l10n.fitCheckNotDone;
if (isFitStale(plant, location)) return l10n.fitCheckStale;
return '' * plant.fitStars! + '' * (5 - plant.fitStars!);
}
class PlantDetailScreen extends ConsumerWidget {
const PlantDetailScreen({super.key, required this.plantId});
@ -99,7 +108,12 @@ class PlantDetailScreen extends ConsumerWidget {
_InfoTile(
icon: Icons.place,
label: l10n.locationLabel,
value: location?.name ?? l10n.locationNone,
value: location == null
? l10n.locationNone
: '${location.name} · ${_fitStatusText(l10n, plant, location)}',
onTap: location == null
? null
: () => context.push(AppRoutes.locationDetail(location.id)),
),
const Divider(height: 32),
_InfoTile(
@ -305,16 +319,18 @@ class _InfoTile extends StatelessWidget {
required this.icon,
required this.label,
required this.value,
this.onTap,
});
final IconData icon;
final String label;
final String value;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
final content = Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -336,8 +352,12 @@ class _InfoTile extends StatelessWidget {
],
),
),
if (onTap != null)
Icon(Icons.chevron_right, color: theme.colorScheme.onSurfaceVariant),
],
),
);
if (onTap == null) return content;
return InkWell(onTap: onTap, child: content);
}
}

View file

@ -126,6 +126,12 @@
"count": { "type": "int" }
}
},
"locationAnalysisButton": "Standort analysieren",
"locationAnalyzing": "Standort wird analysiert …",
"locationAnalysisEmpty": "Noch keine Analyse. Foto aufnehmen, damit die KI die Lichtverhältnisse einschätzen kann.",
"locationAnalysisFailed": "Die Analyse hat nicht geklappt. Bitte versuche es erneut.",
"locationAssignedPlants": "Zugeordnete Pflanzen",
"locationNoPlantsAssigned": "Diesem Stellplatz ist noch keine Pflanze zugeordnet.",
"householdTitle": "Haushalt",
"myHouseholds": "Meine Haushalte",
"activeHouseholdLabel": "Aktiv",
@ -277,6 +283,12 @@
"date": { "type": "String" }
}
},
"fitCheckButton": "Eignung prüfen",
"fitChecking": "Wird geprüft …",
"fitCheckNeedsAnalysis": "Erst den Stellplatz analysieren (Foto oben)",
"fitCheckStale": "Stellplatz hat sich geändert neu prüfen",
"fitCheckFailed": "Die Prüfung hat nicht geklappt. Bitte versuche es erneut.",
"fitCheckNotDone": "Eignung noch nicht geprüft",
"saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.",
"saveErrorDetail": "Speichern fehlgeschlagen ({detail}). Bitte versuche es erneut.",
"@saveErrorDetail": {

View file

@ -400,6 +400,42 @@ abstract class AppLocalizations {
/// **'{count, plural, =0{Keine Pflanzen} one{1 Pflanze} other{{count} Pflanzen}}'**
String locationPlantCount(int count);
/// No description provided for @locationAnalysisButton.
///
/// In de, this message translates to:
/// **'Standort analysieren'**
String get locationAnalysisButton;
/// No description provided for @locationAnalyzing.
///
/// In de, this message translates to:
/// **'Standort wird analysiert …'**
String get locationAnalyzing;
/// No description provided for @locationAnalysisEmpty.
///
/// In de, this message translates to:
/// **'Noch keine Analyse. Foto aufnehmen, damit die KI die Lichtverhältnisse einschätzen kann.'**
String get locationAnalysisEmpty;
/// No description provided for @locationAnalysisFailed.
///
/// In de, this message translates to:
/// **'Die Analyse hat nicht geklappt. Bitte versuche es erneut.'**
String get locationAnalysisFailed;
/// No description provided for @locationAssignedPlants.
///
/// In de, this message translates to:
/// **'Zugeordnete Pflanzen'**
String get locationAssignedPlants;
/// No description provided for @locationNoPlantsAssigned.
///
/// In de, this message translates to:
/// **'Diesem Stellplatz ist noch keine Pflanze zugeordnet.'**
String get locationNoPlantsAssigned;
/// No description provided for @householdTitle.
///
/// In de, this message translates to:
@ -946,6 +982,42 @@ abstract class AppLocalizations {
/// **'Untersuchung vom {date} löschen?'**
String deleteDiagnosisQuestion(String date);
/// No description provided for @fitCheckButton.
///
/// In de, this message translates to:
/// **'Eignung prüfen'**
String get fitCheckButton;
/// No description provided for @fitChecking.
///
/// In de, this message translates to:
/// **'Wird geprüft …'**
String get fitChecking;
/// No description provided for @fitCheckNeedsAnalysis.
///
/// In de, this message translates to:
/// **'Erst den Stellplatz analysieren (Foto oben)'**
String get fitCheckNeedsAnalysis;
/// No description provided for @fitCheckStale.
///
/// In de, this message translates to:
/// **'Stellplatz hat sich geändert neu prüfen'**
String get fitCheckStale;
/// No description provided for @fitCheckFailed.
///
/// In de, this message translates to:
/// **'Die Prüfung hat nicht geklappt. Bitte versuche es erneut.'**
String get fitCheckFailed;
/// No description provided for @fitCheckNotDone.
///
/// In de, this message translates to:
/// **'Eignung noch nicht geprüft'**
String get fitCheckNotDone;
/// No description provided for @saveError.
///
/// In de, this message translates to:

View file

@ -212,6 +212,27 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0';
}
@override
String get locationAnalysisButton => 'Standort analysieren';
@override
String get locationAnalyzing => 'Standort wird analysiert …';
@override
String get locationAnalysisEmpty =>
'Noch keine Analyse. Foto aufnehmen, damit die KI die Lichtverhältnisse einschätzen kann.';
@override
String get locationAnalysisFailed =>
'Die Analyse hat nicht geklappt. Bitte versuche es erneut.';
@override
String get locationAssignedPlants => 'Zugeordnete Pflanzen';
@override
String get locationNoPlantsAssigned =>
'Diesem Stellplatz ist noch keine Pflanze zugeordnet.';
@override
String get householdTitle => 'Haushalt';
@ -525,6 +546,26 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Untersuchung vom $date löschen?';
}
@override
String get fitCheckButton => 'Eignung prüfen';
@override
String get fitChecking => 'Wird geprüft …';
@override
String get fitCheckNeedsAnalysis =>
'Erst den Stellplatz analysieren (Foto oben)';
@override
String get fitCheckStale => 'Stellplatz hat sich geändert neu prüfen';
@override
String get fitCheckFailed =>
'Die Prüfung hat nicht geklappt. Bitte versuche es erneut.';
@override
String get fitCheckNotDone => 'Eignung noch nicht geprüft';
@override
String get saveError => 'Speichern fehlgeschlagen. Bitte versuche es erneut.';

View file

@ -444,6 +444,253 @@ void main() {
expect(button.onPressed, isNull);
});
testWidgets(
'Stellplatz-Bewertung: Standort-Analyse und aktuelle Eignungs-Sterne '
'sind sichtbar, auch verlinkt im Pflanzen-Detail', (tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'u1', email: 'test@example.com'),
);
final firestore = FakeFirebaseFirestore();
await firestore.collection('users').doc('u1').set({
'email': 'test@example.com',
'householdId': 'h1',
});
await firestore.collection('households').doc('h1').set({
'name': 'Mein Haushalt',
'ownerUid': 'u1',
'memberUids': ['u1'],
'members': {
'u1': {'role': 'member', 'email': 'test@example.com'},
},
});
final analyzedAt = DateTime(2026, 7, 20, 10, 0);
await firestore
.collection('households')
.doc('h1')
.collection('locations')
.doc('loc1')
.set({
'name': 'Fensterbank',
'lightCategory': 'Hell, indirekt',
'lightAssessment': 'Viel indirektes Licht, keine direkte Sonne.',
'analyzedAt': Timestamp.fromDate(analyzedAt),
});
await firestore
.collection('households')
.doc('h1')
.collection('plants')
.doc('p1')
.set({
'nickname': 'Monstera',
'species': 'Monstera deliciosa',
'locationId': 'loc1',
'description': '',
'careNotes': '',
'wateringIntervalDays': 7,
'fertilizingIntervalDays': 28,
'lastWatered': Timestamp.fromDate(DateTime.now()),
'lastFertilized': Timestamp.fromDate(DateTime.now()),
'fitStars': 4,
'fitReasoning': 'Passt gut zu diesem Standort.',
'fitLocationId': 'loc1',
// Nach der letzten Analyse bewertet nicht veraltet.
'fitAssessedAt':
Timestamp.fromDate(analyzedAt.add(const Duration(minutes: 5))),
});
await tester.pumpWidget(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
));
await tester.pumpAndSettle();
// Pflanzen-Detail zeigt den Stellplatz samt aktuellen Sternen kompakt an.
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
await tester.tap(find.text('Meine Pflanzen'));
await tester.pumpAndSettle();
await tester.tap(find.text('Monstera'));
await tester.pumpAndSettle();
expect(find.textContaining('Fensterbank · ★★★★☆'), findsOneWidget);
// Antippen navigiert zur Stellplatz-Detailseite mit voller Analyse.
await tester.tap(find.textContaining('Fensterbank · ★★★★☆'));
await tester.pumpAndSettle();
expect(find.text('Fensterbank'), findsWidgets);
expect(find.text('Hell, indirekt'), findsOneWidget);
expect(find.text('Viel indirektes Licht, keine direkte Sonne.'),
findsOneWidget);
expect(find.text('Zugeordnete Pflanzen'), findsOneWidget);
expect(find.text('★★★★☆'), findsOneWidget);
expect(find.text('Passt gut zu diesem Standort.'), findsOneWidget);
});
testWidgets(
'Stellplatz-Bewertung: veraltete Eignung wird nach neuer Analyse '
'als solche markiert statt der alten Sterne', (tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'u1', email: 'test@example.com'),
);
final firestore = FakeFirebaseFirestore();
await firestore.collection('users').doc('u1').set({
'email': 'test@example.com',
'householdId': 'h1',
});
await firestore.collection('households').doc('h1').set({
'name': 'Mein Haushalt',
'ownerUid': 'u1',
'memberUids': ['u1'],
'members': {
'u1': {'role': 'member', 'email': 'test@example.com'},
},
});
final analyzedAt = DateTime(2026, 7, 20, 10, 0);
await firestore
.collection('households')
.doc('h1')
.collection('locations')
.doc('loc1')
.set({
'name': 'Fensterbank',
'lightCategory': 'Hell, indirekt',
'lightAssessment': 'Viel indirektes Licht, keine direkte Sonne.',
'analyzedAt': Timestamp.fromDate(analyzedAt),
});
await firestore
.collection('households')
.doc('h1')
.collection('plants')
.doc('p1')
.set({
'nickname': 'Monstera',
'species': 'Monstera deliciosa',
'locationId': 'loc1',
'description': '',
'careNotes': '',
'wateringIntervalDays': 7,
'fertilizingIntervalDays': 28,
'lastWatered': Timestamp.fromDate(DateTime.now()),
'lastFertilized': Timestamp.fromDate(DateTime.now()),
'fitStars': 3,
'fitReasoning': 'Alte Begründung.',
'fitLocationId': 'loc1',
// Vor der letzten Analyse bewertet gilt als veraltet.
'fitAssessedAt':
Timestamp.fromDate(analyzedAt.subtract(const Duration(days: 1))),
});
await tester.pumpWidget(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
await tester.tap(find.text('Stellplätze'));
await tester.pumpAndSettle();
await tester.tap(find.text('Fensterbank'));
await tester.pumpAndSettle();
expect(find.text('★★★☆☆'), findsNothing);
expect(find.text('Stellplatz hat sich geändert neu prüfen'),
findsOneWidget);
});
testWidgets(
'Stellplatz-Bewertung: ohne Standort-Analyse ist der '
'Eignungs-Check deaktiviert', (tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'u1', email: 'test@example.com'),
);
final firestore = FakeFirebaseFirestore();
await firestore.collection('users').doc('u1').set({
'email': 'test@example.com',
'householdId': 'h1',
});
await firestore.collection('households').doc('h1').set({
'name': 'Mein Haushalt',
'ownerUid': 'u1',
'memberUids': ['u1'],
'members': {
'u1': {'role': 'member', 'email': 'test@example.com'},
},
});
await firestore
.collection('households')
.doc('h1')
.collection('locations')
.doc('loc1')
.set({'name': 'Fensterbank'});
await firestore
.collection('households')
.doc('h1')
.collection('plants')
.doc('p1')
.set({
'nickname': 'Monstera',
'species': 'Monstera deliciosa',
'locationId': 'loc1',
'description': '',
'careNotes': '',
'wateringIntervalDays': 7,
'fertilizingIntervalDays': 28,
'lastWatered': Timestamp.fromDate(DateTime.now()),
'lastFertilized': Timestamp.fromDate(DateTime.now()),
});
await tester.pumpWidget(ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
firebaseAuthProvider.overrideWithValue(auth),
firestoreProvider.overrideWithValue(firestore),
pushRegistrationServiceProvider
.overrideWithValue(_FakePushRegistrationService()),
],
child: const LeafItToMeApp(),
));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
await tester.tap(find.text('Stellplätze'));
await tester.pumpAndSettle();
await tester.tap(find.text('Fensterbank'));
await tester.pumpAndSettle();
expect(
find.text(
'Noch keine Analyse. Foto aufnehmen, damit die KI die '
'Lichtverhältnisse einschätzen kann.'),
findsOneWidget);
final fitButton = tester.widget<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 {