diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index eebe29c..0a6cd26 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -# CI for Tanemaki. Mirrors the local verification gate (analyze + test + +# CI for Tane. Mirrors the local verification gate (analyze + test + # coverage) from docs/design/testing.md. Adapt to GitHub Actions if the # canonical remote changes; the commands are the same. # diff --git a/CHANGELOG.md b/CHANGELOG.md index e2226e1..736a321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -All notable changes to Tanemaki are recorded here. Human-readable, newest +All notable changes to Tane are recorded here. Human-readable, newest first. Dates are ISO (YYYY-MM-DD). ## [Unreleased] — Block 1 beta prep diff --git a/CLAUDE.md b/CLAUDE.md index dee3ed0..d335a1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ -# CLAUDE.md — Tanemaki +# CLAUDE.md — Tane Context for AI agents (Claude Code) working in this repo. Read this first, then [`docs/design/open-decisions.md`](docs/design/open-decisions.md) (the **live decision log**). ## What this is -**Tanemaki** (種まき, "sow/scatter seeds"; short: **Tane**) — a **local-first, decentralized** app to manage and share traditional seeds. First app on a generic commons engine. Web: `tanemaki.app`. Package id: `org.comunes.tane`. See [`VISION.md`](VISION.md) for the human explanation and [`PLAN.md`](PLAN.md) for the origin analysis (with a "Nota de vigencia" reconciling evolved decisions). +**Tane** (種, "seed"; full name **Tanemaki** 種まき, "sow/scatter seeds") — a **local-first, decentralized** app to manage and share traditional seeds. First app on a generic commons engine. Web: `tane.comunes.org`. Package id: `org.comunes.tane`. See [`VISION.md`](VISION.md) for the human explanation and [`PLAN.md`](PLAN.md) for the origin analysis (with a "Nota de vigencia" reconciling evolved decisions). ## Golden rules (do not violate) @@ -36,10 +36,21 @@ tane/ packages/ commons_core/ # identity, Party/Group, TrustEdge, Offer, Pledge, transport (Nostr)+CRDT+sync, geohash discovery. NO seed specifics. apps/ - app_seeds/ # Tanemaki: Variety/Lot/Movement/Species, germination, plant-family units, UI, i18n, commons_ui (embedded for now) + app_seeds/ # Tane: Variety/Lot/Movement/Species, germination, plant-family units, UI, i18n, commons_ui (embedded for now) ``` Dependency direction: **`app_seeds` → `commons_core`, never the reverse.** Boundary rules in [`docs/design/core-domain-boundary.md`](docs/design/core-domain-boundary.md). +**Prefer workspace-level commands from the repo root** — one lockfile, one `.dart_tool`, both members at once. Verified working: +- `dart pub get` (root) — resolves the whole workspace; don't `pub get` per package. +- `dart analyze` (root) — analyzes `commons_core` **and** `app_seeds` together. The default gate. + +**Testing — split by layer; NEVER run the whole `flutter test` blind (it's what "hangs").** Diagnosed 2026-07-11: logic tests are fast and reliable; only *widget* tests hang, because `pumpAndSettle()` on a screen with a never-settling source (live Drift/Nostr stream, periodic `Timer`, connectivity/plugin) waits at the 10-min default. The everyday gate: +- `dart test packages/commons_core` — pure Dart, ~1s. The TDD workhorse. (`dart test` can't run `app_seeds` — it needs `flutter`.) +- `flutter test test/services` — app logic + in-memory DB, no widgets, ~10s, reliable. Put new app logic in plain `test()` here. +- Widget tests: run targeted (`flutter test test/ui/`), always with a timeout so a hang FAILS fast — a global `apps/app_seeds/dart_test.yaml` sets `timeout: 90s`; add `--timeout 30s` when in doubt. Never `pumpAndSettle` a live screen; use bounded `pump(Duration)`. See the testing-gotchas memory + [testing.md](docs/design/testing.md). +- **ALWAYS wrap a widget-test run in an OS hard-kill: `timeout -k 10 200 flutter test test/ui/ --timeout 45s`.** Recurring pain: some hangs sit for 30–40 min because neither `dart_test.yaml`'s `timeout` nor `--timeout` can bound them — a bare `await someStream.first` (outside the per-test clock) or the slow whole-app compile. The OS `timeout` is the only thing that reliably cuts them; without it a hung run wastes half an hour. +- **The #1 hang cause: a live Drift stream where a one-shot Future belongs.** Never `await watchX().first` — not in a `testWidgets` body AND not in production widget code (e.g. a transient sheet's `initState` filling a picker). A guard (`test/no_stream_first_in_widget_tests_test.dart`) scans `test/ui/` for it, but NOT `lib/ui/`. When a widget test hangs, grep BOTH `test/ui` and the `lib/ui` widget under test for `watch*().first` / a `StreamBuilder` on a never-closing source; replace with a repo one-shot (`…get()` Future). A transient sheet/dialog must never hold a live subscription for one-time data. + ## Data model (decided, `schemaVersion = 1`) Three levels: **`Variety`** (identity/accession) → **`Lot`** (a batch you hold: `harvest_year`, quantity, `offer_status`, germination) → **`Movement`** (append-only event log = history + provenance DAG + Plantare). Plus `Species` (bundled Wikidata/GBIF catalog), `VernacularName`, `Attachment`, `ExternalLink`, `GerminationTest`, `Party`, `SeedBank`, `Offer`, `Plantare`. Every mutable row: client `UUIDv7`, `created_at`, HLC `updated_at`, `last_author`, `is_deleted` (soft delete/tombstone), `schema_row_version`. CRDT: LWW scalars, OR-Set collections, grow-only `Movement`. Full spec: [`docs/design/data-model.md`](docs/design/data-model.md). @@ -53,10 +64,10 @@ Decided schema details: `Quantity` is a shared value type (Lot + Movement); `off ## Phasing — build order -**Block 1 (NOW): Inventory.** Shippable alone, useful with zero network. This is the current target. -**Block 2 (later): the social leap** — offers + messaging + relays + web of trust — big and indivisible; needs its own round (and ideally funding). Do NOT start Block 2 now. +**Block 1: Inventory.** Shippable alone, useful with zero network. Delivered (beta). +**Block 2 (STARTED — the social leap):** offers + messaging + relays + trust — big and indivisible. The de-risking spike ([`docs/design/spike-block2-findings.md`](docs/design/spike-block2-findings.md)) validated the whole happy path, so the **social round is now open**. Build order within Block 2: **(1) the transport foundation in `commons_core`** — one `NostrConnection` + N interfaces (`OfferTransport`/`MessageTransport`/`TrustTransport`/`RatingTransport`) + the pure `WebOfTrust` engine, on vetted libraries — then (2) offers UI, (3) messaging hardening, (4) trust & reputation polish. It is still large: scope honestly, keep it behind the transport interfaces, and don't let it regress Block 1. -Social layer (for later) uses: Nostr (offers NIP-99, DMs NIP-17), Duniter-style **web of trust** (Duniter/Ğ1 compatible, ~5 certifications), optional **Ğ1** currency (levels: price → wallet deep-link → optional WoT import). Cold-start via real Ğ1 seed groups + fairs. See [`docs/design/network-trust.md`](docs/design/network-trust.md) and [`docs/design/g1-integration.md`](docs/design/g1-integration.md). +Social layer uses: Nostr (offers NIP-99, DMs NIP-17, via the pure-Dart `nostr` package), **ego-centric trust** (you vouch for people you've met — kind 30777, one live cert per pair, expiring; your circle = friends-of-friends from YOUR key; **no global membership, no bootstrap referents, no user-facing parameters** — the global Duniter rule was dropped 2026-07-11 as not internationally viable, see open-decisions.md), **Wallapop-style ratings** (kind 30778, one live rating per pair, circle-weighted display), optional **Ğ1** currency (levels: price → wallet deep-link → optional WoT import remains a possible future enrichment). Identity: a **secp256k1 Nostr key derived (HKDF, domain-separated, one-way) from the Ğ1 root seed** — user backs up ONE seed. Cold-start via real Ğ1 seed groups + fairs. See [`docs/design/network-trust.md`](docs/design/network-trust.md), [`docs/design/g1-integration.md`](docs/design/g1-integration.md), and the spike findings above. ## Where things live diff --git a/PLAN.md b/PLAN.md index 26a2319..b5d9bc0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,4 +1,4 @@ -# Tanemaki — Plan de análisis y desarrollo +# Tane — Plan de análisis y desarrollo *App descentralizada para la gestión personal y la compartición de semillas y plantel* @@ -6,7 +6,7 @@ Documento de trabajo · Julio 2026 · Autor del proyecto: vjrj (Comunes) Licencia del proyecto: **AGPL-3.0** (software) · docs y assets CC-BY-SA. > **Nota de vigencia.** Este PLAN es el documento *origen*. Varias decisiones han evolucionado en `docs/design/`; la **lista viva de decisiones** es [docs/design/open-decisions.md](docs/design/open-decisions.md). En particular, actualizan/matizan este texto: -> - **Nombre:** Tanemaki (種まき), apodo Tane; dominio `tanemaki.app`. +> - **Nombre:** **Tane** (種; nombre completo Tanemaki 種まき); dominio `tane.comunes.org`. (2026-07-12: se soltó el "-maki"; el §5-ter de abajo, que eligió "Tanemaki" y descartó "Tane a secas" + `tanemaki.app`, quedó **superado**.) > - **Identidad:** *una sola clave* raíz estilo Duniter/Ğ1, de la que se **deriva** la clave secp256k1 para el transporte (Nostr). No "solo Nostr". → [g1-integration.md](docs/design/g1-integration.md) > - **"¿Blockchain?" (§4):** sigue siendo *no* para los datos de semillas (locales/CRDT), pero **sí interoperamos con la cadena de Duniter v2** para identidad, red de confianza y moneda **opcionales** (Ğ1). No es contradicción: la blockchain no es el libro mayor de las semillas, es una capa opcional de identidad/confianza/pago. > - **Transporte social:** Nostr sigue siendo la vía (viable con la clave derivada); datapods de Duniter descartados (no en servicio). @@ -16,7 +16,7 @@ Licencia del proyecto: **AGPL-3.0** (software) · docs y assets CC-BY-SA. ## 0. Resumen ejecutivo -Tanemaki es una app **local-first, multilingüe y descentralizada** para que personas y colectivos gestionen su banco de semillas y plantel, decidan qué ofrecen, y lo compartan localmente sin un intermediario central. La motivación es política además de práctica: contrarrestar el monopolio de las semillas privativas y sostener las variedades tradicionales, en la línea de [Plantare](http://plantare.ourproject.org/) y del relato "Las semillas del conocimiento libre" que ya escribiste en *Diagonal* (2005). +Tane es una app **local-first, multilingüe y descentralizada** para que personas y colectivos gestionen su banco de semillas y plantel, decidan qué ofrecen, y lo compartan localmente sin un intermediario central. La motivación es política además de práctica: contrarrestar el monopolio de las semillas privativas y sostener las variedades tradicionales, en la línea de [Plantare](http://plantare.ourproject.org/) y del relato "Las semillas del conocimiento libre" que ya escribiste en *Diagonal* (2005). La decisión de partida acordada: @@ -213,7 +213,9 @@ En una frase: **Plantare ya resolvió, en papel y en 2009, el problema de la con ## 5-ter. Nombre de la app y pantalla "Acerca de" -**Nombre (decidido): Tanemaki** (種まき, japonés: "sembrar / esparcir semillas"), con apodo corto **"Tane"** (種, "semilla") — que es el identificador que usamos en repo y código. Honra la tradición japonesa de ayuda mutua que inspiró el Plantare original, viaja bien en cualquier idioma (no es ni anglo ni latina), y su significado —*esparcir semillas*— captura literalmente la faceta vírica y descentralizada del proyecto (§5-bis). Alternativas descartadas por colisión o encaje: Tane a secas como nombre público (genérico, dominios cortos en manos de brokers), Plantare (solo funciona en español/latín), Spora (suena fúngico), Semia (demasiado "semiótica"). +> **Superado (2026-07-12):** el nombre público es ahora **Tane** (se soltó el "-maki") y el dominio es `tane.comunes.org`. Lo que sigue documenta la decisión original y se conserva como registro histórico. + +**Nombre (decidido en su momento): Tanemaki** (種まき, japonés: "sembrar / esparcir semillas"), con apodo corto **"Tane"** (種, "semilla") — que es el identificador que usamos en repo y código. Honra la tradición japonesa de ayuda mutua que inspiró el Plantare original, viaja bien en cualquier idioma (no es ni anglo ni latina), y su significado —*esparcir semillas*— captura literalmente la faceta vírica y descentralizada del proyecto (§5-bis). Alternativas descartadas por colisión o encaje: Tane a secas como nombre público (genérico, dominios cortos en manos de brokers), Plantare (solo funciona en español/latín), Spora (suena fúngico), Semia (demasiado "semiótica"). **Identidad digital (decidida):** - **Dominio:** `tanemaki.app` (libre, barato, fuerza HTTPS). Descartados `.eco` (caro ~70 €/año + Eco Profile obligatorio anual) y los `tane.*` cortos (cogidos o aparcados para reventa). @@ -288,7 +290,7 @@ El mayor riesgo de este proyecto no es técnico, es de *continuidad*. Los proyec - **Descartar** el intento en Meteor (`meteor-old`, `seedks/`): Meteor es cliente-servidor centralizado, justo lo contrario de local-first, y el ecosistema está en declive. No arrastres esa base. - **Conservar y tratar como oro** los **mockups** (`docs/mockups/`) — como tú mismo dices, es lo valioso. Ya definen: inicio, menús, búsqueda con tarjetas y precio/trueque, inventario por categorías con miembros del banco, ítem con historial/docs/comentarios, perfil público con mapa y valoraciones, chat. Son un *product spec* casi completo de las capas 1–3. - **Conservar** los iconos SVG propios (`docs/icons.svg`, la fuente `seedks.ttf`) y el logo — identidad visual ya hecha. -- El nombre **Tanemaki** (apodo **Tane**) ya está decidido — ver §5-ter para el razonamiento y las alternativas descartadas. El antiguo nombre de trabajo "seedks" queda solo como nombre heredado de algún asset (p.ej. la fuente `seedks.ttf`), no como nombre del proyecto. +- El nombre **Tane** (antes Tanemaki, 種まき) ya está decidido — ver §5-ter para el razonamiento histórico y las alternativas descartadas. El antiguo nombre de trabajo "seedks" queda solo como nombre heredado de algún asset (p.ej. la fuente `seedks.ttf`), no como nombre del proyecto. **Nota operativa sobre esta carpeta:** está sincronizada con Seafile. Para el desarrollo con git, tu idea de un *bare repo* en `~/repos` es la correcta: evita meter un `.git` dentro de una carpeta sincronizada por Seafile (conflictos entre los dos sistemas de sincronización). Trabaja el código en un clon fuera de Seafile, con el bare repo como origen. Puedo ayudarte a montarlo. diff --git a/README.md b/README.md index 86f8af3..12ca078 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# Tanemaki 種まき +# Tane 種 *A local-first, decentralized app for managing and sharing traditional seeds and seedlings.* -**Tanemaki** (種まき, "to sow / scatter seeds" in Japanese; short name **Tane**, 種 "seed") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly. +**Tane** (種, "seed" in Japanese) helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. Its name comes from **tanemaki** (種まき), "to sow / scatter seeds". The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly. -The name honors the old Japanese mutual-aid traditions around rice — *yui* (shared community labor) and *tanomoshi* (reciprocity funds) — that inspired the paper **Plantare**, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare. +The name honors the old Japanese mutual-aid traditions around rice — *yui* (shared community labor) and *tanomoshi* (reciprocity funds) — that inspired the paper **Plantare**, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tane is the digital Plantare. -- Web: https://tanemaki.app +- Web: https://tane.comunes.org - Package id: `org.comunes.tane` ## Status diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md new file mode 100644 index 0000000..0dda4c5 --- /dev/null +++ b/TRANSLATIONS.md @@ -0,0 +1,99 @@ +# Translating Tane (Weblate) + +Runbook for the translation setup. Written so another agent (or a human) can +reproduce it without prior context. + +Tane is translated on **[translate.comunes.org/projects/tane](https://translate.comunes.org/projects/tane/)** +(a self-hosted **Weblate 5.x**, docker-compose at `/data/weblate` on host **tesla**). +The links to the repo and to Weblate are shown both on the landing site +(`site/config.toml` → `sourceURL`, `weblateURL`) and inside the app (About screen). + +## What is translatable + +| Surface | Files | Format | Base locale | Locales today | +|---|---|---|---|---| +| **App** (`app` component) | `apps/app_seeds/lib/i18n/*.i18n.json` | JSON **nested** (slang, `{param}` placeholders) | `en` | en, es, pt, fr, de, ast, ja | +| **Site** (`site` component) | `site/i18n/*.json` | **go-i18n JSON** (`{"key":{"other":"…"}}`) | `en` | en, es | + +Only source strings live in git; app strings use `slang`, so `strings.g.dart` / +`strings_*.g.dart` are **generated** (see the slang caveat below). + +## Git connection + +- Repo: `https://git.comunes.org/comunes/tane.git`, branch **`main`** (private + Gitea/Forgejo behind Cloudflare). +- Weblate must use **HTTPS + an access token** — SSH port 22 is not reachable + through Cloudflare, so the stored `Comunes Translations` SSH key is unusable here. +- Repository URL in Weblate: + `https://:@git.comunes.org/comunes/tane.git` + where `` is a git.comunes.org access token with **read + write** on + `comunes/tane` (Weblate pushes translations back). Generate it in Gitea/Forgejo + → Settings → Applications. + +## The `app` component (create once) + +The `tane` project already exists on Weblate. Add a component (admin UI +*Add new translation component*, or `docker exec weblate-weblate-1 weblate shell`): + +| Field | Value | +|---|---| +| Project | `tane` | +| Name / slug | `app` | +| Source code repository | `https://:@git.comunes.org/comunes/tane.git` | +| Repository branch | `main` | +| File mask | `apps/app_seeds/lib/i18n/*.i18n.json` | +| Monolingual base language file | `apps/app_seeds/lib/i18n/en.i18n.json` | +| Template for new translations | `apps/app_seeds/lib/i18n/en.i18n.json` | +| File format | **JSON nested structure** (`json-nested`) — not flat `json` | +| Source language | English (`en`) | + +Weblate auto-discovers the existing locales from the mask. Leave push-on-commit on +so translations flow back to `main`. + +Sanity check after creating it: +```sh +docker exec weblate-weblate-1 weblate shell -c \ + 'from weblate.trans.models import Project; \ + print(Project.objects.get(slug="tane").component_set.count())' # expect 1 +``` +Then edit a test string in the UI and confirm a commit lands on +`git.comunes.org/comunes/tane` (`main`) — that validates the token has write. + +## slang regeneration caveat (important) + +Weblate only edits the `*.i18n.json` sources. The committed `strings.g.dart` / +`strings_*.g.dart` are generated and go **stale** on every Weblate commit, and +there is no CI to regenerate them. Before a release, a maintainer must: + +```sh +git pull +cd apps/app_seeds && dart run slang # regenerates strings*.g.dart +cd ../.. && dart analyze # workspace gate +git add -A && git commit -m "chore(i18n): regenerate slang after Weblate sync" +``` + +(Adding a new source string manually? Same steps — edit `en.i18n.json` first, then +regenerate. `fallback_strategy: base_locale` means locales missing a key show the +English text until translators fill it in.) + +## The `site` component (landing page) + +The Hugo landing strings live in `site/i18n/*.json`. Weblate has no native Hugo-TOML +format, so these were converted from TOML to **go-i18n JSON** (`{"key":{"other":"…"}}`) +— a shape Hugo 0.140 reads identically and Weblate supports natively. Component settings: + +| Field | Value | +|---|---| +| Project | `tane` | +| Name / slug | `site` | +| Source code repository | *(link to the `app` component: `weblate://tane/app`)* | +| Repository branch | `main` | +| File mask | `site/i18n/*.json` | +| Monolingual base language file | `site/i18n/en.json` | +| Template for new translations | `site/i18n/en.json` | +| File format | **go-i18n v2 JSON** (`go-i18n-json-v2`) | +| Source language | English (`en`) | + +Linking the repo to the `app` component (`weblate://tane/app`) shares one clone/token. +No slang step here — Hugo consumes the JSON directly. Only EN/ES today; adding a locale = +add `site/i18n/.json`. diff --git a/VISION.md b/VISION.md index 458c9e1..f20ad21 100644 --- a/VISION.md +++ b/VISION.md @@ -1,4 +1,4 @@ -# Tanemaki — Visión +# Tane — Visión *Explicación para personas. Fuente de la que derivar el README, la web y las solicitudes de financiación. Lengua: español (se derivará versión en inglés para NLnet/comunidad internacional).* @@ -6,7 +6,7 @@ ## En una frase -**Tanemaki es una app libre para cuidar y compartir semillas tradicionales, que funciona sin conexión y sin intermediarios: tu banco de semillas en el bolsillo, y una red vecinal para hacerlas circular.** +**Tane es una app libre para cuidar y compartir semillas tradicionales, que funciona sin conexión y sin intermediarios: tu banco de semillas en el bolsillo, y una red vecinal para hacerlas circular.** ## El problema @@ -18,13 +18,13 @@ Quien quiere resistir esto —redes de semillas, colectivos agroecológicos, hor ## La idea -Tanemaki hace dos cosas, y las hace fáciles: +Tane hace dos cosas, y las hace fáciles: **Gestionar.** Llevar, de forma sencilla y hasta agradable, el inventario de tu banco de semillas y tu plantel: qué tienes, de qué año, cuánto, de dónde vino, con el nombre que tú usas (no hace falta saber latín). Sirve igual para una persona con cuatro sobres en un cajón que para un banco comunitario con cientos de variedades. **Compartir.** Decir qué ofreces y qué no, y que alguien cerca lo encuentre y te contacte —para regalar, intercambiar, o vender si así lo quieres— **sin una empresa en medio** que controle, censure o se lleve una comisión. La app presenta; el trato lo cierran las personas. -Detrás de todo late una idea: las semillas tradicionales son un bien común de la humanidad. Frente a su privatización, Tanemaki quiere ponértelo fácil para conservarlas, multiplicarlas y hacerlas circular. +Detrás de todo late una idea: las semillas tradicionales son un bien común de la humanidad. Frente a su privatización, Tane quiere ponértelo fácil para conservarlas, multiplicarlas y hacerlas circular. ## Para quién @@ -32,7 +32,7 @@ Para todo el mundo, **de los 10 a los 80 años**. Para la persona mayor que guar ## Cómo funciona (sin tecnicismos) -Tanemaki está pensada por **capas**, y cada una es útil por sí sola: +Tane está pensada por **capas**, y cada una es útil por sí sola: 1. **Tu inventario.** Vive en tu teléfono. Funciona sin internet, sin crear ninguna cuenta. Añades una semilla con una foto y un nombre en veinte segundos; si quieres, le pones año, cantidad, procedencia, notas, o hasta su nombre científico (que la app te sugiere, no te obliga a teclear). Y una **"varilla"** puede ofrecerte rellenar el resto por ti —nombres, cuidados, cómo conservarla, años de viabilidad— a partir de lo poco que has escrito, como propuestas que confirmas o corriges. @@ -61,15 +61,15 @@ Software libre (copyleft). Funciona sin conexión y sin cuenta. Tus datos, en tu ## El nombre y su herencia -**Tanemaki** (種まき) significa "sembrar, esparcir semillas" en japonés. Es un homenaje a las antiguas tradiciones japonesas de ayuda mutua en torno al arroz —el *yui*, el trabajo comunitario compartido, y el *tanomoshi*, los fondos vecinales de reciprocidad— que inspiraron el Plantare original. El apodo corto es **Tane** ("semilla"). Tanemaki continúa el camino del Plantare (BAH-Semillero, 2009), de la Red de Semillas "Resembrando e Intercambiando" y de la cultura libre. *Las semillas del conocimiento libre ya están plantadas.* +**Tane** (種, "semilla") toma su nombre de **tanemaki** (種まき), "sembrar, esparcir semillas" en japonés. Es un homenaje a las antiguas tradiciones japonesas de ayuda mutua en torno al arroz —el *yui*, el trabajo comunitario compartido, y el *tanomoshi*, los fondos vecinales de reciprocidad— que inspiraron el Plantare original. Tane continúa el camino del Plantare (BAH-Semillero, 2009), de la Red de Semillas "Resembrando e Intercambiando" y de la cultura libre. *Las semillas del conocimiento libre ya están plantadas.* ## Sostenibilidad -Tanemaki no tiene modelo de negocio, y es a propósito. Al ser local-first, sigue siendo útil aunque nadie la mantenga ni haya servidores encendidos: la utilidad no caduca. Al ser libre y con datos abiertos, cualquiera puede continuarla. Su desarrollo se sostiene con trabajo voluntario, comunidad (redes de semillas, colectivos) y financiación de bien común (fondos europeos de tecnología libre como NLnet/NGI Zero), nunca cobrando por uso ni extrayendo valor de quien la usa. +Tane no tiene modelo de negocio, y es a propósito. Al ser local-first, sigue siendo útil aunque nadie la mantenga ni haya servidores encendidos: la utilidad no caduca. Al ser libre y con datos abiertos, cualquiera puede continuarla. Su desarrollo se sostiene con trabajo voluntario, comunidad (redes de semillas, colectivos) y financiación de bien común (fondos europeos de tecnología libre como NLnet/NGI Zero), nunca cobrando por uso ni extrayendo valor de quien la usa. ## La visión mayor -Bajo Tanemaki hay un motor común (`commons_core`) que resuelve lo difícil: identidad propia, red descentralizada, confianza, y el préstamo-con-devolución. Ese motor sirve para más que semillas. La misma idea —"qué tengo, qué comparto, con confianza y devolución"— vale para una **biblioteca vecinal de cosas y herramientas**. Tanemaki es la primera app sobre ese motor; otras podrán venir. Generalizamos el motor, no el producto: cada app sigue siendo simple y de un solo propósito. +Bajo Tane hay un motor común (`commons_core`) que resuelve lo difícil: identidad propia, red descentralizada, confianza, y el préstamo-con-devolución. Ese motor sirve para más que semillas. La misma idea —"qué tengo, qué comparto, con confianza y devolución"— vale para una **biblioteca vecinal de cosas y herramientas**. Tane es la primera app sobre ese motor; otras podrán venir. Generalizamos el motor, no el producto: cada app sigue siendo simple y de un solo propósito. ## Estado y camino @@ -77,7 +77,7 @@ En diseño temprano. El orden previsto: primero un **inventario usable** (útil ## Cómo participar -Tanemaki es un proyecto abierto. Se puede contribuir cultivando y probándolo en una red de semillas real, traduciéndolo, aportando conocimiento sobre variedades, o programando. El código y su documentación son públicos. +Tane es un proyecto abierto. Se puede contribuir cultivando y probándolo en una red de semillas real, traduciéndolo, aportando conocimiento sobre variedades, o programando. El código y su documentación son públicos. --- diff --git a/apps/app_seeds/android/app/build.gradle.kts b/apps/app_seeds/android/app/build.gradle.kts index e785816..23baf51 100644 --- a/apps/app_seeds/android/app/build.gradle.kts +++ b/apps/app_seeds/android/app/build.gradle.kts @@ -25,6 +25,8 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + // Required by flutter_local_notifications (uses Java 8+ APIs on older SDKs). + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -71,3 +73,8 @@ android { flutter { source = "../.." } + +dependencies { + // Backport of Java 8+ APIs, required by flutter_local_notifications. + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/apps/app_seeds/android/app/src/main/AndroidManifest.xml b/apps/app_seeds/android/app/src/main/AndroidManifest.xml index 738f046..677d645 100644 --- a/apps/app_seeds/android/app/src/main/AndroidManifest.xml +++ b/apps/app_seeds/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ + + + + ), never blind, and +# never pumpAndSettle a live screen — pump(Duration) a bounded number of times. +# +# A genuinely slower (but finite) test can opt out with @Timeout(Duration(...)). +timeout: 90s + +tags: + # Localized store/landing screenshots (test/screenshots/). These are asset + # GENERATION, not a correctness gate, and golden pixel-matching is sensitive + # to OS/font/Flutter version — so they must NOT run in the normal suite (incl. + # CI's `flutter test --coverage`). Run them explicitly: + # flutter test --run-skipped --tags screenshots test/screenshots/ + # add --update-goldens to (re)write the PNGs. + screenshots: + skip: "asset generation; run with --run-skipped --tags screenshots" diff --git a/apps/app_seeds/drift_schemas/drift_schema_v10.json b/apps/app_seeds/drift_schemas/drift_schema_v10.json new file mode 100644 index 0000000..ec9627c --- /dev/null +++ b/apps/app_seeds/drift_schemas/drift_schema_v10.json @@ -0,0 +1,2151 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "varieties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "label", + "getter_name": "label", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cultivar_name", + "getter_name": "cultivarName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "category", + "getter_name": "category", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_draft", + "getter_name": "isDraft", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_draft\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_draft\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_organic", + "getter_name": "isOrganic", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_organic\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_organic\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "needs_reproduction", + "getter_name": "needsReproduction", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"needs_reproduction\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"needs_reproduction\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sow_months", + "getter_name": "sowMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "transplant_months", + "getter_name": "transplantMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "flowering_months", + "getter_name": "floweringMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fruiting_months", + "getter_name": "fruitingMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seed_harvest_months", + "getter_name": "seedHarvestMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "variety_vernacular_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "region", + "getter_name": "region", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "species", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "scientific_name", + "getter_name": "scientificName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "wikidata_qid", + "getter_name": "wikidataQid", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "gbif_key", + "getter_name": "gbifKey", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "family", + "getter_name": "family", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_bundled", + "getter_name": "isBundled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_bundled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_bundled\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "viability_years", + "getter_name": "viabilityYears", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "species_common_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "lots", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'seed\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(LotType.values)", + "dart_type_name": "LotType" + } + }, + { + "name": "harvest_year", + "getter_name": "harvestYear", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "harvest_month", + "getter_name": "harvestMonth", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "presentation", + "getter_name": "presentation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Presentation.values)", + "dart_type_name": "Presentation" + } + }, + { + "name": "storage_location", + "getter_name": "storageLocation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "offer_status", + "getter_name": "offerStatus", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'private\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OfferStatus.values)", + "dart_type_name": "OfferStatus" + } + }, + { + "name": "seedbank_id", + "getter_name": "seedbankId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_name", + "getter_name": "originName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_place", + "getter_name": "originPlace", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "abundance", + "getter_name": "abundance", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Abundance.values)", + "dart_type_name": "Abundance" + } + }, + { + "name": "preservation_format", + "getter_name": "preservationFormat", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PreservationFormat.values)", + "dart_type_name": "PreservationFormat" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 5, + "references": [], + "type": "table", + "data": { + "name": "germination_tests", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tested_on", + "getter_name": "testedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sample_size", + "getter_name": "sampleSize", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "germinated_count", + "getter_name": "germinatedCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 6, + "references": [], + "type": "table", + "data": { + "name": "condition_checks", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "checked_on", + "getter_name": "checkedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_count", + "getter_name": "containerCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "desiccant_state", + "getter_name": "desiccantState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(DesiccantState.values)", + "dart_type_name": "DesiccantState" + } + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "movements", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(MovementType.values)", + "dart_type_name": "MovementType" + } + }, + { + "name": "occurred_on", + "getter_name": "occurredOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "counterparty_id", + "getter_name": "counterpartyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_movement_id", + "getter_name": "parentMovementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "plantare_id", + "getter_name": "plantareId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [], + "type": "table", + "data": { + "name": "parties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "public_key", + "getter_name": "publicKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'person\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PartyKind.values)", + "dart_type_name": "PartyKind" + } + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "attachments", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(AttachmentKind.values)", + "dart_type_name": "AttachmentKind" + } + }, + { + "name": "uri", + "getter_name": "uri", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "bytes", + "getter_name": "bytes", + "moor_type": "blob", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mime_type", + "getter_name": "mimeType", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sort_order", + "getter_name": "sortOrder", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 10, + "references": [], + "type": "table", + "data": { + "name": "external_links", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "plantares", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareDirection.values)", + "dart_type_name": "PlantareDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owed_description", + "getter_name": "owedDescription", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "made_on", + "getter_name": "madeOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "due_by", + "getter_name": "dueBy", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'open\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareStatus.values)", + "dart_type_name": "PlantareStatus" + } + }, + { + "name": "settled_on", + "getter_name": "settledOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 12, + "references": [], + "type": "table", + "data": { + "name": "sales", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(SaleDirection.values)", + "dart_type_name": "SaleDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "amount", + "getter_name": "amount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sold_on", + "getter_name": "soldOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + } + ], + "fixed_sql": [ + { + "name": "varieties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"varieties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"label\" TEXT NOT NULL, \"species_id\" TEXT NULL, \"cultivar_name\" TEXT NULL, \"category\" TEXT NULL, \"notes\" TEXT NULL, \"is_draft\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_draft\" IN (0, 1)), \"is_organic\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_organic\" IN (0, 1)), \"needs_reproduction\" INTEGER NOT NULL DEFAULT 0 CHECK (\"needs_reproduction\" IN (0, 1)), \"sow_months\" INTEGER NULL, \"transplant_months\" INTEGER NULL, \"flowering_months\" INTEGER NULL, \"fruiting_months\" INTEGER NULL, \"seed_harvest_months\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "variety_vernacular_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"variety_vernacular_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, \"region\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"scientific_name\" TEXT NOT NULL, \"wikidata_qid\" TEXT NULL, \"gbif_key\" INTEGER NULL, \"family\" TEXT NULL, \"is_bundled\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_bundled\" IN (0, 1)), \"viability_years\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species_common_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species_common_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"species_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "lots", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"lots\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL DEFAULT 'seed', \"harvest_year\" INTEGER NULL, \"harvest_month\" INTEGER NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"presentation\" TEXT NULL, \"storage_location\" TEXT NULL, \"offer_status\" TEXT NOT NULL DEFAULT 'private', \"seedbank_id\" TEXT NULL, \"origin_name\" TEXT NULL, \"origin_place\" TEXT NULL, \"abundance\" TEXT NULL, \"preservation_format\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "germination_tests", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"germination_tests\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"tested_on\" INTEGER NULL, \"sample_size\" INTEGER NULL, \"germinated_count\" INTEGER NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "condition_checks", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"condition_checks\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"checked_on\" INTEGER NULL, \"container_count\" INTEGER NULL, \"desiccant_state\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "movements", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"movements\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"last_author\" TEXT NOT NULL, \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"occurred_on\" INTEGER NULL, \"counterparty_id\" TEXT NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"parent_movement_id\" TEXT NULL, \"plantare_id\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "parties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"parties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"display_name\" TEXT NOT NULL, \"public_key\" TEXT NULL, \"kind\" TEXT NOT NULL DEFAULT 'person', \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "attachments", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"attachments\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"kind\" TEXT NOT NULL, \"uri\" TEXT NULL, \"bytes\" BLOB NULL, \"mime_type\" TEXT NULL, \"sort_order\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "external_links", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"external_links\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"title\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "plantares", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"plantares\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"owed_description\" TEXT NULL, \"made_on\" INTEGER NOT NULL, \"due_by\" INTEGER NULL, \"status\" TEXT NOT NULL DEFAULT 'open', \"settled_on\" INTEGER NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "sales", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sales\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"amount\" REAL NULL, \"currency\" TEXT NULL, \"sold_on\" INTEGER NOT NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/app_seeds/drift_schemas/drift_schema_v11.json b/apps/app_seeds/drift_schemas/drift_schema_v11.json new file mode 100644 index 0000000..82170fa --- /dev/null +++ b/apps/app_seeds/drift_schemas/drift_schema_v11.json @@ -0,0 +1,2171 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "varieties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "label", + "getter_name": "label", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cultivar_name", + "getter_name": "cultivarName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "category", + "getter_name": "category", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_draft", + "getter_name": "isDraft", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_draft\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_draft\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_organic", + "getter_name": "isOrganic", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_organic\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_organic\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "needs_reproduction", + "getter_name": "needsReproduction", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"needs_reproduction\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"needs_reproduction\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sow_months", + "getter_name": "sowMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "transplant_months", + "getter_name": "transplantMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "flowering_months", + "getter_name": "floweringMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fruiting_months", + "getter_name": "fruitingMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seed_harvest_months", + "getter_name": "seedHarvestMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "variety_vernacular_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "region", + "getter_name": "region", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "species", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "scientific_name", + "getter_name": "scientificName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "wikidata_qid", + "getter_name": "wikidataQid", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "gbif_key", + "getter_name": "gbifKey", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "family", + "getter_name": "family", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_bundled", + "getter_name": "isBundled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_bundled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_bundled\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "viability_years", + "getter_name": "viabilityYears", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "species_common_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "lots", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'seed\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(LotType.values)", + "dart_type_name": "LotType" + } + }, + { + "name": "harvest_year", + "getter_name": "harvestYear", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "harvest_month", + "getter_name": "harvestMonth", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "presentation", + "getter_name": "presentation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Presentation.values)", + "dart_type_name": "Presentation" + } + }, + { + "name": "storage_location", + "getter_name": "storageLocation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "offer_status", + "getter_name": "offerStatus", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'private\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OfferStatus.values)", + "dart_type_name": "OfferStatus" + } + }, + { + "name": "seedbank_id", + "getter_name": "seedbankId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_name", + "getter_name": "originName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_place", + "getter_name": "originPlace", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "abundance", + "getter_name": "abundance", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Abundance.values)", + "dart_type_name": "Abundance" + } + }, + { + "name": "preservation_format", + "getter_name": "preservationFormat", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PreservationFormat.values)", + "dart_type_name": "PreservationFormat" + } + }, + { + "name": "price_amount", + "getter_name": "priceAmount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "price_currency", + "getter_name": "priceCurrency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 5, + "references": [], + "type": "table", + "data": { + "name": "germination_tests", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tested_on", + "getter_name": "testedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sample_size", + "getter_name": "sampleSize", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "germinated_count", + "getter_name": "germinatedCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 6, + "references": [], + "type": "table", + "data": { + "name": "condition_checks", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "checked_on", + "getter_name": "checkedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_count", + "getter_name": "containerCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "desiccant_state", + "getter_name": "desiccantState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(DesiccantState.values)", + "dart_type_name": "DesiccantState" + } + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "movements", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(MovementType.values)", + "dart_type_name": "MovementType" + } + }, + { + "name": "occurred_on", + "getter_name": "occurredOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "counterparty_id", + "getter_name": "counterpartyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_movement_id", + "getter_name": "parentMovementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "plantare_id", + "getter_name": "plantareId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [], + "type": "table", + "data": { + "name": "parties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "public_key", + "getter_name": "publicKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'person\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PartyKind.values)", + "dart_type_name": "PartyKind" + } + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "attachments", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(AttachmentKind.values)", + "dart_type_name": "AttachmentKind" + } + }, + { + "name": "uri", + "getter_name": "uri", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "bytes", + "getter_name": "bytes", + "moor_type": "blob", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mime_type", + "getter_name": "mimeType", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sort_order", + "getter_name": "sortOrder", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 10, + "references": [], + "type": "table", + "data": { + "name": "external_links", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "plantares", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareDirection.values)", + "dart_type_name": "PlantareDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owed_description", + "getter_name": "owedDescription", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "made_on", + "getter_name": "madeOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "due_by", + "getter_name": "dueBy", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'open\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareStatus.values)", + "dart_type_name": "PlantareStatus" + } + }, + { + "name": "settled_on", + "getter_name": "settledOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 12, + "references": [], + "type": "table", + "data": { + "name": "sales", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(SaleDirection.values)", + "dart_type_name": "SaleDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "amount", + "getter_name": "amount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sold_on", + "getter_name": "soldOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + } + ], + "fixed_sql": [ + { + "name": "varieties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"varieties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"label\" TEXT NOT NULL, \"species_id\" TEXT NULL, \"cultivar_name\" TEXT NULL, \"category\" TEXT NULL, \"notes\" TEXT NULL, \"is_draft\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_draft\" IN (0, 1)), \"is_organic\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_organic\" IN (0, 1)), \"needs_reproduction\" INTEGER NOT NULL DEFAULT 0 CHECK (\"needs_reproduction\" IN (0, 1)), \"sow_months\" INTEGER NULL, \"transplant_months\" INTEGER NULL, \"flowering_months\" INTEGER NULL, \"fruiting_months\" INTEGER NULL, \"seed_harvest_months\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "variety_vernacular_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"variety_vernacular_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, \"region\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"scientific_name\" TEXT NOT NULL, \"wikidata_qid\" TEXT NULL, \"gbif_key\" INTEGER NULL, \"family\" TEXT NULL, \"is_bundled\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_bundled\" IN (0, 1)), \"viability_years\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species_common_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species_common_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"species_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "lots", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"lots\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL DEFAULT 'seed', \"harvest_year\" INTEGER NULL, \"harvest_month\" INTEGER NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"presentation\" TEXT NULL, \"storage_location\" TEXT NULL, \"offer_status\" TEXT NOT NULL DEFAULT 'private', \"seedbank_id\" TEXT NULL, \"origin_name\" TEXT NULL, \"origin_place\" TEXT NULL, \"abundance\" TEXT NULL, \"preservation_format\" TEXT NULL, \"price_amount\" REAL NULL, \"price_currency\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "germination_tests", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"germination_tests\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"tested_on\" INTEGER NULL, \"sample_size\" INTEGER NULL, \"germinated_count\" INTEGER NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "condition_checks", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"condition_checks\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"checked_on\" INTEGER NULL, \"container_count\" INTEGER NULL, \"desiccant_state\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "movements", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"movements\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"last_author\" TEXT NOT NULL, \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"occurred_on\" INTEGER NULL, \"counterparty_id\" TEXT NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"parent_movement_id\" TEXT NULL, \"plantare_id\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "parties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"parties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"display_name\" TEXT NOT NULL, \"public_key\" TEXT NULL, \"kind\" TEXT NOT NULL DEFAULT 'person', \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "attachments", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"attachments\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"kind\" TEXT NOT NULL, \"uri\" TEXT NULL, \"bytes\" BLOB NULL, \"mime_type\" TEXT NULL, \"sort_order\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "external_links", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"external_links\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"title\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "plantares", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"plantares\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"owed_description\" TEXT NULL, \"made_on\" INTEGER NOT NULL, \"due_by\" INTEGER NULL, \"status\" TEXT NOT NULL DEFAULT 'open', \"settled_on\" INTEGER NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "sales", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sales\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"amount\" REAL NULL, \"currency\" TEXT NULL, \"sold_on\" INTEGER NOT NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/app_seeds/drift_schemas/drift_schema_v12.json b/apps/app_seeds/drift_schemas/drift_schema_v12.json new file mode 100644 index 0000000..0cc7408 --- /dev/null +++ b/apps/app_seeds/drift_schemas/drift_schema_v12.json @@ -0,0 +1,2269 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "varieties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "label", + "getter_name": "label", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cultivar_name", + "getter_name": "cultivarName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "category", + "getter_name": "category", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_draft", + "getter_name": "isDraft", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_draft\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_draft\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_organic", + "getter_name": "isOrganic", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_organic\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_organic\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "needs_reproduction", + "getter_name": "needsReproduction", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"needs_reproduction\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"needs_reproduction\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sow_months", + "getter_name": "sowMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "transplant_months", + "getter_name": "transplantMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "flowering_months", + "getter_name": "floweringMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fruiting_months", + "getter_name": "fruitingMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seed_harvest_months", + "getter_name": "seedHarvestMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "variety_vernacular_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "region", + "getter_name": "region", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "species", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "scientific_name", + "getter_name": "scientificName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "wikidata_qid", + "getter_name": "wikidataQid", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "gbif_key", + "getter_name": "gbifKey", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "family", + "getter_name": "family", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_bundled", + "getter_name": "isBundled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_bundled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_bundled\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "viability_years", + "getter_name": "viabilityYears", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "species_common_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "lots", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'seed\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(LotType.values)", + "dart_type_name": "LotType" + } + }, + { + "name": "harvest_year", + "getter_name": "harvestYear", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "harvest_month", + "getter_name": "harvestMonth", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "presentation", + "getter_name": "presentation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Presentation.values)", + "dart_type_name": "Presentation" + } + }, + { + "name": "storage_location", + "getter_name": "storageLocation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "offer_status", + "getter_name": "offerStatus", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'private\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OfferStatus.values)", + "dart_type_name": "OfferStatus" + } + }, + { + "name": "seedbank_id", + "getter_name": "seedbankId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_name", + "getter_name": "originName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_place", + "getter_name": "originPlace", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "abundance", + "getter_name": "abundance", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Abundance.values)", + "dart_type_name": "Abundance" + } + }, + { + "name": "preservation_format", + "getter_name": "preservationFormat", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PreservationFormat.values)", + "dart_type_name": "PreservationFormat" + } + }, + { + "name": "price_amount", + "getter_name": "priceAmount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "price_currency", + "getter_name": "priceCurrency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 5, + "references": [], + "type": "table", + "data": { + "name": "germination_tests", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tested_on", + "getter_name": "testedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sample_size", + "getter_name": "sampleSize", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "germinated_count", + "getter_name": "germinatedCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 6, + "references": [], + "type": "table", + "data": { + "name": "condition_checks", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "checked_on", + "getter_name": "checkedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_count", + "getter_name": "containerCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "desiccant_state", + "getter_name": "desiccantState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(DesiccantState.values)", + "dart_type_name": "DesiccantState" + } + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "movements", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(MovementType.values)", + "dart_type_name": "MovementType" + } + }, + { + "name": "occurred_on", + "getter_name": "occurredOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "counterparty_id", + "getter_name": "counterpartyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_movement_id", + "getter_name": "parentMovementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "plantare_id", + "getter_name": "plantareId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [], + "type": "table", + "data": { + "name": "parties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "public_key", + "getter_name": "publicKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'person\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PartyKind.values)", + "dart_type_name": "PartyKind" + } + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "attachments", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(AttachmentKind.values)", + "dart_type_name": "AttachmentKind" + } + }, + { + "name": "uri", + "getter_name": "uri", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "bytes", + "getter_name": "bytes", + "moor_type": "blob", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mime_type", + "getter_name": "mimeType", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sort_order", + "getter_name": "sortOrder", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 10, + "references": [], + "type": "table", + "data": { + "name": "external_links", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "plantares", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareDirection.values)", + "dart_type_name": "PlantareDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owed_description", + "getter_name": "owedDescription", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "made_on", + "getter_name": "madeOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "due_by", + "getter_name": "dueBy", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'open\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareStatus.values)", + "dart_type_name": "PlantareStatus" + } + }, + { + "name": "settled_on", + "getter_name": "settledOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pledge_id", + "getter_name": "pledgeId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "debtor_key", + "getter_name": "debtorKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "creditor_key", + "getter_name": "creditorKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "debtor_signature", + "getter_name": "debtorSignature", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "creditor_signature", + "getter_name": "creditorSignature", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "movement_id", + "getter_name": "movementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "remote_state", + "getter_name": "remoteState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareRemoteState.values)", + "dart_type_name": "PlantareRemoteState" + } + }, + { + "name": "return_kind", + "getter_name": "returnKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'similar\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareReturnKind.values)", + "dart_type_name": "PlantareReturnKind" + } + }, + { + "name": "work_hours", + "getter_name": "workHours", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 12, + "references": [], + "type": "table", + "data": { + "name": "sales", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(SaleDirection.values)", + "dart_type_name": "SaleDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "amount", + "getter_name": "amount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sold_on", + "getter_name": "soldOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + } + ], + "fixed_sql": [ + { + "name": "varieties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"varieties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"label\" TEXT NOT NULL, \"species_id\" TEXT NULL, \"cultivar_name\" TEXT NULL, \"category\" TEXT NULL, \"notes\" TEXT NULL, \"is_draft\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_draft\" IN (0, 1)), \"is_organic\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_organic\" IN (0, 1)), \"needs_reproduction\" INTEGER NOT NULL DEFAULT 0 CHECK (\"needs_reproduction\" IN (0, 1)), \"sow_months\" INTEGER NULL, \"transplant_months\" INTEGER NULL, \"flowering_months\" INTEGER NULL, \"fruiting_months\" INTEGER NULL, \"seed_harvest_months\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "variety_vernacular_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"variety_vernacular_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, \"region\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"scientific_name\" TEXT NOT NULL, \"wikidata_qid\" TEXT NULL, \"gbif_key\" INTEGER NULL, \"family\" TEXT NULL, \"is_bundled\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_bundled\" IN (0, 1)), \"viability_years\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species_common_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species_common_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"species_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "lots", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"lots\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL DEFAULT 'seed', \"harvest_year\" INTEGER NULL, \"harvest_month\" INTEGER NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"presentation\" TEXT NULL, \"storage_location\" TEXT NULL, \"offer_status\" TEXT NOT NULL DEFAULT 'private', \"seedbank_id\" TEXT NULL, \"origin_name\" TEXT NULL, \"origin_place\" TEXT NULL, \"abundance\" TEXT NULL, \"preservation_format\" TEXT NULL, \"price_amount\" REAL NULL, \"price_currency\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "germination_tests", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"germination_tests\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"tested_on\" INTEGER NULL, \"sample_size\" INTEGER NULL, \"germinated_count\" INTEGER NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "condition_checks", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"condition_checks\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"checked_on\" INTEGER NULL, \"container_count\" INTEGER NULL, \"desiccant_state\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "movements", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"movements\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"last_author\" TEXT NOT NULL, \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"occurred_on\" INTEGER NULL, \"counterparty_id\" TEXT NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"parent_movement_id\" TEXT NULL, \"plantare_id\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "parties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"parties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"display_name\" TEXT NOT NULL, \"public_key\" TEXT NULL, \"kind\" TEXT NOT NULL DEFAULT 'person', \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "attachments", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"attachments\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"kind\" TEXT NOT NULL, \"uri\" TEXT NULL, \"bytes\" BLOB NULL, \"mime_type\" TEXT NULL, \"sort_order\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "external_links", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"external_links\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"title\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "plantares", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"plantares\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"owed_description\" TEXT NULL, \"made_on\" INTEGER NOT NULL, \"due_by\" INTEGER NULL, \"status\" TEXT NOT NULL DEFAULT 'open', \"settled_on\" INTEGER NULL, \"note\" TEXT NULL, \"pledge_id\" TEXT NULL, \"debtor_key\" TEXT NULL, \"creditor_key\" TEXT NULL, \"debtor_signature\" TEXT NULL, \"creditor_signature\" TEXT NULL, \"movement_id\" TEXT NULL, \"remote_state\" TEXT NULL, \"return_kind\" TEXT NOT NULL DEFAULT 'similar', \"work_hours\" REAL NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "sales", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sales\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"amount\" REAL NULL, \"currency\" TEXT NULL, \"sold_on\" INTEGER NOT NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/app_seeds/drift_schemas/drift_schema_v9.json b/apps/app_seeds/drift_schemas/drift_schema_v9.json new file mode 100644 index 0000000..1197f87 --- /dev/null +++ b/apps/app_seeds/drift_schemas/drift_schema_v9.json @@ -0,0 +1,1987 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "varieties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "label", + "getter_name": "label", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cultivar_name", + "getter_name": "cultivarName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "category", + "getter_name": "category", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_draft", + "getter_name": "isDraft", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_draft\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_draft\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_organic", + "getter_name": "isOrganic", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_organic\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_organic\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "needs_reproduction", + "getter_name": "needsReproduction", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"needs_reproduction\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"needs_reproduction\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sow_months", + "getter_name": "sowMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "transplant_months", + "getter_name": "transplantMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "flowering_months", + "getter_name": "floweringMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fruiting_months", + "getter_name": "fruitingMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seed_harvest_months", + "getter_name": "seedHarvestMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "variety_vernacular_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "region", + "getter_name": "region", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "species", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "scientific_name", + "getter_name": "scientificName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "wikidata_qid", + "getter_name": "wikidataQid", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "gbif_key", + "getter_name": "gbifKey", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "family", + "getter_name": "family", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_bundled", + "getter_name": "isBundled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_bundled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_bundled\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "viability_years", + "getter_name": "viabilityYears", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "species_common_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "lots", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'seed\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(LotType.values)", + "dart_type_name": "LotType" + } + }, + { + "name": "harvest_year", + "getter_name": "harvestYear", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "harvest_month", + "getter_name": "harvestMonth", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "presentation", + "getter_name": "presentation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Presentation.values)", + "dart_type_name": "Presentation" + } + }, + { + "name": "storage_location", + "getter_name": "storageLocation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "offer_status", + "getter_name": "offerStatus", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'private\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OfferStatus.values)", + "dart_type_name": "OfferStatus" + } + }, + { + "name": "seedbank_id", + "getter_name": "seedbankId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_name", + "getter_name": "originName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_place", + "getter_name": "originPlace", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "abundance", + "getter_name": "abundance", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Abundance.values)", + "dart_type_name": "Abundance" + } + }, + { + "name": "preservation_format", + "getter_name": "preservationFormat", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PreservationFormat.values)", + "dart_type_name": "PreservationFormat" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 5, + "references": [], + "type": "table", + "data": { + "name": "germination_tests", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tested_on", + "getter_name": "testedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sample_size", + "getter_name": "sampleSize", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "germinated_count", + "getter_name": "germinatedCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 6, + "references": [], + "type": "table", + "data": { + "name": "condition_checks", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "checked_on", + "getter_name": "checkedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_count", + "getter_name": "containerCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "desiccant_state", + "getter_name": "desiccantState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(DesiccantState.values)", + "dart_type_name": "DesiccantState" + } + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "movements", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(MovementType.values)", + "dart_type_name": "MovementType" + } + }, + { + "name": "occurred_on", + "getter_name": "occurredOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "counterparty_id", + "getter_name": "counterpartyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_movement_id", + "getter_name": "parentMovementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "plantare_id", + "getter_name": "plantareId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [], + "type": "table", + "data": { + "name": "parties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "public_key", + "getter_name": "publicKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'person\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PartyKind.values)", + "dart_type_name": "PartyKind" + } + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "attachments", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(AttachmentKind.values)", + "dart_type_name": "AttachmentKind" + } + }, + { + "name": "uri", + "getter_name": "uri", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "bytes", + "getter_name": "bytes", + "moor_type": "blob", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mime_type", + "getter_name": "mimeType", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sort_order", + "getter_name": "sortOrder", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 10, + "references": [], + "type": "table", + "data": { + "name": "external_links", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "plantares", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareDirection.values)", + "dart_type_name": "PlantareDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owed_description", + "getter_name": "owedDescription", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "made_on", + "getter_name": "madeOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "due_by", + "getter_name": "dueBy", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'open\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareStatus.values)", + "dart_type_name": "PlantareStatus" + } + }, + { + "name": "settled_on", + "getter_name": "settledOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + } + ], + "fixed_sql": [ + { + "name": "varieties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"varieties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"label\" TEXT NOT NULL, \"species_id\" TEXT NULL, \"cultivar_name\" TEXT NULL, \"category\" TEXT NULL, \"notes\" TEXT NULL, \"is_draft\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_draft\" IN (0, 1)), \"is_organic\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_organic\" IN (0, 1)), \"needs_reproduction\" INTEGER NOT NULL DEFAULT 0 CHECK (\"needs_reproduction\" IN (0, 1)), \"sow_months\" INTEGER NULL, \"transplant_months\" INTEGER NULL, \"flowering_months\" INTEGER NULL, \"fruiting_months\" INTEGER NULL, \"seed_harvest_months\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "variety_vernacular_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"variety_vernacular_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, \"region\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"scientific_name\" TEXT NOT NULL, \"wikidata_qid\" TEXT NULL, \"gbif_key\" INTEGER NULL, \"family\" TEXT NULL, \"is_bundled\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_bundled\" IN (0, 1)), \"viability_years\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species_common_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species_common_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"species_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "lots", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"lots\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL DEFAULT 'seed', \"harvest_year\" INTEGER NULL, \"harvest_month\" INTEGER NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"presentation\" TEXT NULL, \"storage_location\" TEXT NULL, \"offer_status\" TEXT NOT NULL DEFAULT 'private', \"seedbank_id\" TEXT NULL, \"origin_name\" TEXT NULL, \"origin_place\" TEXT NULL, \"abundance\" TEXT NULL, \"preservation_format\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "germination_tests", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"germination_tests\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"tested_on\" INTEGER NULL, \"sample_size\" INTEGER NULL, \"germinated_count\" INTEGER NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "condition_checks", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"condition_checks\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"checked_on\" INTEGER NULL, \"container_count\" INTEGER NULL, \"desiccant_state\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "movements", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"movements\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"last_author\" TEXT NOT NULL, \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"occurred_on\" INTEGER NULL, \"counterparty_id\" TEXT NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"parent_movement_id\" TEXT NULL, \"plantare_id\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "parties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"parties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"display_name\" TEXT NOT NULL, \"public_key\" TEXT NULL, \"kind\" TEXT NOT NULL DEFAULT 'person', \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "attachments", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"attachments\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"kind\" TEXT NOT NULL, \"uri\" TEXT NULL, \"bytes\" BLOB NULL, \"mime_type\" TEXT NULL, \"sort_order\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "external_links", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"external_links\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"title\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "plantares", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"plantares\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"owed_description\" TEXT NULL, \"made_on\" INTEGER NOT NULL, \"due_by\" INTEGER NULL, \"status\" TEXT NOT NULL DEFAULT 'open', \"settled_on\" INTEGER NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt b/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt index 856980c..c6adfc2 100644 --- a/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt +++ b/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt @@ -1,8 +1,8 @@ -Tanemaki (種まき, "to sow seeds") is a local-first, decentralized app for +Tane (種, "seed"; from tanemaki 種まき, "to sow seeds") is a local-first, decentralized app for keeping and sharing traditional seeds. Every traditional seed is a letter written by thousands of generations, passed -from hand to hand. Tanemaki helps people and seed-saver collectives keep a +from hand to hand. Tane helps people and seed-saver collectives keep a friendly inventory of their bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. @@ -22,6 +22,6 @@ SHARE, THE WAY IT'S ALWAYS BEEN DONE • Mark what you have spare to give away, swap or sell. • Print a catalog of what you share to take to a seed fair. -Tanemaki is free software (AGPL-3.0). No ads, no commissions, no business model +Tane is free software (AGPL-3.0). No ads, no commissions, no business model — it exists to support traditional varieties and push back against the seed monopoly. diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..de3191d Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..6b71397 Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..456b140 Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..578b7c7 Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..b7dd841 Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/title.txt b/apps/app_seeds/fastlane/metadata/android/en-US/title.txt index 772f5e1..f7dc7d8 100644 --- a/apps/app_seeds/fastlane/metadata/android/en-US/title.txt +++ b/apps/app_seeds/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Tanemaki +Tane diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt b/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt index 00934ad..d25105b 100644 --- a/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt +++ b/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt @@ -1,8 +1,8 @@ -Tanemaki (種まき, "sembrar semillas") es una app local-first y descentralizada +Tane (種, "semilla"; de tanemaki 種まき, "sembrar semillas") es una app local-first y descentralizada para guardar y compartir semillas tradicionales. Cada semilla tradicional es una carta escrita por miles de generaciones, que -pasa de mano en mano. Tanemaki ayuda a personas y colectivos de guardianas de +pasa de mano en mano. Tane ayuda a personas y colectivos de guardianas de semillas a llevar un inventario amable de su banco, decidir qué ofrecen y compartirlo localmente, sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. @@ -22,6 +22,6 @@ COMPARTIR, COMO SE HA HECHO SIEMPRE • Marca lo que te sobra para regalar, intercambiar o vender. • Imprime un catálogo de lo que compartes para llevar a una feria de semillas. -Tanemaki es software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo +Tane es software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio: existe para apoyar las variedades tradicionales y plantar cara al monopolio de las semillas. diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/1.png b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/1.png new file mode 100644 index 0000000..f39ef76 Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/1.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/2.png b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/2.png new file mode 100644 index 0000000..8c95790 Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/2.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/3.png b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/3.png new file mode 100644 index 0000000..de0c402 Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/3.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/4.png b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/4.png new file mode 100644 index 0000000..f87fa3e Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/4.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/5.png b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/5.png new file mode 100644 index 0000000..f9e9a92 Binary files /dev/null and b/apps/app_seeds/fastlane/metadata/android/es-ES/images/phoneScreenshots/5.png differ diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/title.txt b/apps/app_seeds/fastlane/metadata/android/es-ES/title.txt index 772f5e1..f7dc7d8 100644 --- a/apps/app_seeds/fastlane/metadata/android/es-ES/title.txt +++ b/apps/app_seeds/fastlane/metadata/android/es-ES/title.txt @@ -1 +1 @@ -Tanemaki +Tane diff --git a/apps/app_seeds/ios/Runner/Info.plist b/apps/app_seeds/ios/Runner/Info.plist index bbce0ac..9dd4501 100644 --- a/apps/app_seeds/ios/Runner/Info.plist +++ b/apps/app_seeds/ios/Runner/Info.plist @@ -68,5 +68,7 @@ UIStatusBarHidden + NSLocationWhenInUseUsageDescription + Used only to set your approximate sharing area — reduced to a coarse zone, never your exact location. diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index b80561a..21c296c 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -1,3 +1,4 @@ +import 'package:commons_core/commons_core.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -7,16 +8,42 @@ import 'package:go_router/go_router.dart'; import 'data/species_repository.dart'; import 'data/variety_repository.dart'; import 'i18n/strings.g.dart'; +import 'services/auto_backup_service.dart'; +import 'services/coarse_location.dart'; +import 'services/inbox_service.dart'; +import 'services/message_store.dart'; +import 'services/notification_service.dart'; +import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; +import 'services/profile_cache.dart'; +import 'services/profile_store.dart'; +import 'services/saved_offers_store.dart'; +import 'services/social_account_store.dart'; +import 'services/social_connection.dart'; +import 'services/social_service.dart'; +import 'services/social_settings.dart'; import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; import 'ui/about_screen.dart'; +import 'ui/auto_backup_gate.dart'; +import 'ui/calendar_screen.dart'; +import 'ui/chat_list_screen.dart'; +import 'ui/chat_screen.dart'; +import 'ui/favorites_screen.dart'; import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; import 'ui/inventory_list_screen.dart'; +import 'ui/legal_screen.dart'; +import 'ui/plantares_screen.dart'; +import 'ui/sales_screen.dart'; +import 'ui/market_offer_detail_screen.dart'; +import 'ui/market_screen.dart'; +import 'ui/offline_banner.dart'; +import 'ui/profile_screen.dart'; import 'ui/settings_screen.dart'; import 'ui/theme.dart'; import 'ui/variety_detail_screen.dart'; +import 'ui/your_people_screen.dart'; /// Root widget. Provides the repositories to the tree and wires go_router: /// `/` is the home menu, `/inventory` the list, `/variety/:id` the item detail. @@ -26,25 +53,196 @@ class TaneApp extends StatelessWidget { required this.repository, required this.species, required this.onboarding, + this.social, + this.socialSettings, + this.connection, + this.location, + this.outbox, + this.messageStore, + this.profileStore, + this.profileCache, + this.savedOffers, + this.socialAccounts, + this.inbox, + this.notifications, this.showIntro = false, + this.autoBackup, super.key, - }) : _router = _buildRouter(repository, onboarding, showIntro); + }) : _router = _buildRouter( + repository, + onboarding, + showIntro, + social, + socialSettings, + connection, + location, + outbox, + messageStore, + profileStore, + profileCache, + savedOffers, + socialAccounts, + inbox, + ) { + // A tapped message notification opens that peer's chat. Wired here because + // the router only exists now; taps only happen while the app is foreground, + // so a live router reference is enough. + notifications?.onTapChat = (pubkey) => _router.push('/chat/$pubkey'); + } final VarietyRepository repository; final SpeciesRepository species; final OnboardingStore onboarding; + + /// Block 2 social layer. Null until the social round is wired in `main`; when + /// null the market stays a "coming soon" card and `/market` is not routed. + final SocialService? social; + final SocialSettings? socialSettings; + + /// The shared relay connection (one per identity) reused by every feature. + final SocialConnection? connection; + + /// Optional device-location source for the market's "use my location". + final CoarseLocationProvider? location; + + /// Optional offline outbox for the market's "share my seeds". + final OfferOutbox? outbox; + + /// Optional persistence for chat history. + final MessageStore? messageStore; + + /// Optional local store for your own profile (name/about). + final ProfileStore? profileStore; + + /// Optional cache of peers' published display names. + final ProfileCache? profileCache; + + /// Optional store of the user's saved ("favorite") market offers. + final SavedOffersStore? savedOffers; + + /// Optional store of the active social identity, for the profile switcher. + final SocialAccountStore? socialAccounts; + + /// App-wide inbox listener; drives the messages list's live refresh. + final InboxService? inbox; + + /// OS notifications for foreground messages; its tap handler is wired to the + /// router here. Null in tests / on platforms without notification support. + final NotificationService? notifications; final bool showIntro; + + /// Drives silent periodic backups off the app lifecycle. Null in widget tests + /// and on platforms without file storage (web) — the gate then does nothing. + final AutoBackupService? autoBackup; final GoRouter _router; static GoRouter _buildRouter( VarietyRepository repository, OnboardingStore onboarding, bool showIntro, + SocialService? social, + SocialSettings? socialSettings, + SocialConnection? connection, + CoarseLocationProvider? location, + OfferOutbox? outbox, + MessageStore? messageStore, + ProfileStore? profileStore, + ProfileCache? profileCache, + SavedOffersStore? savedOffers, + SocialAccountStore? socialAccounts, + InboxService? inbox, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', routes: [ - GoRoute(path: '/', builder: (context, state) => const HomeScreen()), + GoRoute( + path: '/', + builder: (context, state) => + HomeScreen(marketEnabled: social != null), + ), + if (social != null && socialSettings != null && connection != null) + GoRoute( + path: '/market', + builder: (context, state) => MarketScreen( + social: social, + settings: socialSettings, + connection: connection, + location: location, + outbox: outbox, + onboarding: onboarding, + ), + ), + if (social != null && connection != null) + GoRoute( + path: '/market/offer', + builder: (context, state) { + final offer = state.extra as Offer; + return MarketOfferDetailScreen( + offer: offer, + connection: connection, + mine: offer.authorPubkeyHex == social.publicKeyHex, + profileCache: profileCache, + selfPubkey: social.publicKeyHex, + savedOffers: savedOffers, + settings: socialSettings, + ); + }, + ), + if (social != null && savedOffers != null) + GoRoute( + path: '/favorites', + builder: (context, state) => FavoritesScreen( + savedOffers: savedOffers, + connection: connection, + ), + ), + if (messageStore != null) + GoRoute( + path: '/messages', + builder: (context, state) => ChatListScreen( + store: messageStore, + connection: connection, + profileCache: profileCache, + inbox: inbox, + settings: socialSettings, + ), + ), + if (social != null && + connection != null && + profileStore != null && + socialAccounts != null) + GoRoute( + path: '/profile', + builder: (context, state) => ProfileScreen( + social: social, + connection: connection, + profileStore: profileStore, + accounts: socialAccounts, + yourPeopleEnabled: true, + ), + ), + if (social != null && connection != null) + GoRoute( + path: '/your-people', + builder: (context, state) => YourPeopleScreen( + social: social, + connection: connection, + profileCache: profileCache, + ), + ), + if (social != null && connection != null) + GoRoute( + path: '/chat/:pubkey', + builder: (context, state) => ChatScreen( + social: social, + connection: connection, + peerPubkey: state.pathParameters['pubkey']!, + messageStore: messageStore, + profileCache: profileCache, + onboarding: onboarding, + settings: socialSettings, + ), + ), GoRoute( path: '/intro', builder: (context, state) => IntroScreen( @@ -62,6 +260,10 @@ class TaneApp extends StatelessWidget { path: '/about', builder: (context, state) => const AboutScreen(), ), + GoRoute( + path: '/legal', + builder: (context, state) => const LegalScreen(), + ), GoRoute( path: '/inventory', builder: (context, state) => BlocProvider( @@ -69,6 +271,22 @@ class TaneApp extends StatelessWidget { child: const InventoryListScreen(), ), ), + // Local reproduction commitments — inventory-adjacent, no network. + GoRoute( + path: '/plantares', + builder: (context, state) => const PlantaresScreen(), + ), + // Local sales ledger — also inventory-adjacent, no network. + GoRoute( + path: '/sales', + builder: (context, state) => const SalesScreen(), + ), + // "What's due this month" across the inventory — reads the per-variety + // crop calendar, no network. + GoRoute( + path: '/calendar', + builder: (context, state) => const CalendarScreen(), + ), GoRoute( path: '/variety/:id', builder: (context, state) => BlocProvider( @@ -88,27 +306,45 @@ class TaneApp extends StatelessWidget { RepositoryProvider.value(value: repository), RepositoryProvider.value(value: species), ], - child: MaterialApp.router( - onGenerateTitle: (context) => context.t.app.title, - debugShowCheckedModeBanner: false, - theme: buildTaneTheme(), - // Let scrollables & PageViews be dragged with a mouse/trackpad on - // desktop (Flutter disables that by default), so the photo carousel and - // lists swipe there too. - scrollBehavior: const _DragScrollBehavior(), - locale: TranslationProvider.of(context).flutterLocale, - supportedLocales: AppLocaleUtils.supportedLocales, - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - routerConfig: _router, + child: AutoBackupGate( + service: autoBackup, + child: MaterialApp.router( + onGenerateTitle: (context) => context.t.app.title, + debugShowCheckedModeBanner: false, + theme: buildTaneTheme(), + // Let scrollables & PageViews be dragged with a mouse/trackpad on + // desktop (Flutter disables that by default), so the photo carousel and + // lists swipe there too. + scrollBehavior: const _DragScrollBehavior(), + // Flutter ships no Material/Cupertino localizations for Asturian + // (`ast`), so render the framework chrome (date pickers, native dialog + // buttons) in Spanish — the closest shipped language — while the app's + // own text stays Asturian via slang. Every other locale maps 1:1. + locale: materialLocaleFor(TranslationProvider.of(context).flutterLocale), + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + // A global "you're offline" strip above every screen — makes it clear + // why the social layer is paused (the inventory still works). + builder: (context, child) => + OfflineBanner(child: child ?? const SizedBox.shrink()), + routerConfig: _router, + ), ), ); } } +/// Maps the app's active locale to one Flutter's bundled Material/Cupertino +/// localizations can serve. Asturian (`ast`) has none, so it borrows Spanish +/// (`es`) for the framework chrome; the app's own strings stay Asturian (they +/// come from slang, not from `Localizations`). All other locales pass through. +Locale materialLocaleFor(Locale locale) => + locale.languageCode == 'ast' ? const Locale('es') : locale; + /// Enables mouse/trackpad drag scrolling (off by default on desktop) so the /// photo carousel and lists can be swiped with a pointer. class _DragScrollBehavior extends MaterialScrollBehavior { diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart new file mode 100644 index 0000000..81aa9d9 --- /dev/null +++ b/apps/app_seeds/lib/bootstrap.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'app.dart'; +import 'data/species_repository.dart'; +import 'data/variety_repository.dart'; +import 'di/injector.dart'; +import 'i18n/strings.g.dart'; +import 'services/auto_backup_service.dart'; +import 'services/coarse_location.dart'; +import 'services/inbox_service.dart'; +import 'services/locale_store.dart'; +import 'services/message_store.dart'; +import 'services/notification_service.dart'; +import 'services/offer_outbox.dart'; +import 'services/onboarding_store.dart'; +import 'services/plantare_service.dart'; +import 'services/profile_cache.dart'; +import 'services/profile_store.dart'; +import 'services/saved_offers_store.dart'; +import 'services/social_account_store.dart'; +import 'services/social_connection.dart'; +import 'services/social_service.dart'; +import 'services/social_settings.dart'; +import 'services/sync_service.dart'; +import 'ui/theme.dart'; + +/// Boots the app WITHOUT blocking the first frame: paints a splash immediately, +/// then wires the encrypted DB + services and swaps in [TaneApp] once ready. +/// +/// Before this, `main` awaited the whole of `configureDependencies()` (open the +/// encrypted DB, derive the social key, seed/scan the species catalog…) before +/// the first `runApp`, so nothing painted until init finished — the startup jank +/// visible as skipped frames. Now `main` runs `runApp` immediately with this +/// widget; the heavy work runs while the splash is already on screen. +class Bootstrap extends StatefulWidget { + const Bootstrap({super.key}); + + @override + State createState() => _BootstrapState(); +} + +class _BootstrapState extends State { + late Future _app = _boot(); + + Future _boot() async { + await configureDependencies(); + + // The saved language lives in the keystore, so it can only be applied once + // DI is up. Until now the splash showed in the device language (it has no + // text), so there is no visible flip. + final savedLocale = await getIt().saved(); + if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale); + + final onboarding = getIt(); + final connection = getIt.isRegistered() + ? getIt() + : null; + final inbox = + getIt.isRegistered() ? getIt() : null; + final notifications = getIt.isRegistered() + ? getIt() + : null; + // Ask for notification permission and set up the OS channel (no-op on + // unsupported platforms). Done before the inbox starts listening. + if (notifications != null) await notifications.initialize(); + final sync = + getIt.isRegistered() ? getIt() : null; + final plantares = + getIt.isRegistered() ? getIt() : null; + // Subscribe the inbox + sync + plantaré listeners BEFORE the shared + // connection starts connecting, so the first session is caught; then bring + // the connection up. + inbox?.start(); + sync?.start(); + plantares?.start(); + connection?.start(); + + return TaneApp( + repository: getIt(), + species: getIt(), + onboarding: onboarding, + social: + getIt.isRegistered() ? getIt() : null, + socialSettings: getIt(), + connection: connection, + location: const GeolocatorCoarseLocation(), + outbox: getIt(), + messageStore: getIt(), + profileStore: getIt(), + profileCache: getIt(), + savedOffers: getIt(), + socialAccounts: getIt(), + inbox: inbox, + notifications: notifications, + showIntro: !await onboarding.introSeen(), + autoBackup: getIt.isRegistered() + ? getIt() + : null, + ); + } + + // `configureDependencies` is idempotent (and self-heals a half-wired + // container), so retrying after a failed boot is safe. + void _retry() => setState(() => _app = _boot()); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _app, + builder: (context, snapshot) { + if (snapshot.hasData) return snapshot.data!; + return BootstrapSplash( + error: snapshot.hasError, + onRetry: snapshot.hasError ? _retry : null, + ); + }, + ); + } +} + +/// The pre-init frame. A minimal [MaterialApp] so its text has a +/// `Directionality`, theme and localizations. Its background matches the native +/// splash colour ([seedGreen]) for a seamless handoff — one continuous green +/// screen from launch until the app is ready. Shows a spinner while booting and, +/// on [error], the failure message with a [onRetry] action. +class BootstrapSplash extends StatelessWidget { + const BootstrapSplash({required this.error, this.onRetry, super.key}); + + final bool error; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildTaneTheme(), + // Asturian (`ast`) has no bundled Material/Cupertino localizations, so map + // it to Spanish for the framework chrome — same as the main app (app.dart). + locale: materialLocaleFor(TranslationProvider.of(context).flutterLocale), + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + backgroundColor: seedGreen, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/logo.png', width: 120, height: 120), + const SizedBox(height: 32), + if (error) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + context.t.bootstrap.failed, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + ), + const SizedBox(height: 16), + if (onRetry != null) + FilledButton( + onPressed: onRetry, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: seedGreen, + ), + child: Text(context.t.bootstrap.retry), + ), + ] else + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart b/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart index 9d8c3ee..5eb18c2 100644 --- a/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart +++ b/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart @@ -33,6 +33,7 @@ class InventoryJsonCodec { updatedAt: v.updatedAt, lastAuthor: v.lastAuthor, schemaRowVersion: v.schemaRowVersion, + isDeleted: v.isDeleted, ), 'label': v.label, 'speciesId': v.speciesId, @@ -61,6 +62,7 @@ class InventoryJsonCodec { updatedAt: l.updatedAt, lastAuthor: l.lastAuthor, schemaRowVersion: l.schemaRowVersion, + isDeleted: l.isDeleted, ), 'varietyId': l.varietyId, 'type': l.type.name, @@ -77,6 +79,8 @@ class InventoryJsonCodec { 'originPlace': l.originPlace, 'abundance': l.abundance?.name, 'preservationFormat': l.preservationFormat?.name, + 'priceAmount': l.priceAmount, + 'priceCurrency': l.priceCurrency, }, ], 'vernacularNames': [ @@ -88,6 +92,7 @@ class InventoryJsonCodec { updatedAt: n.updatedAt, lastAuthor: n.lastAuthor, schemaRowVersion: n.schemaRowVersion, + isDeleted: n.isDeleted, ), 'varietyId': n.varietyId, 'name': n.name, @@ -104,6 +109,7 @@ class InventoryJsonCodec { updatedAt: e.updatedAt, lastAuthor: e.lastAuthor, schemaRowVersion: e.schemaRowVersion, + isDeleted: e.isDeleted, ), 'parentType': e.parentType.name, 'parentId': e.parentId, @@ -120,6 +126,7 @@ class InventoryJsonCodec { updatedAt: g.updatedAt, lastAuthor: g.lastAuthor, schemaRowVersion: g.schemaRowVersion, + isDeleted: g.isDeleted, ), 'lotId': g.lotId, 'testedOn': g.testedOn, @@ -137,6 +144,7 @@ class InventoryJsonCodec { updatedAt: c.updatedAt, lastAuthor: c.lastAuthor, schemaRowVersion: c.schemaRowVersion, + isDeleted: c.isDeleted, ), 'lotId': c.lotId, 'checkedOn': c.checkedOn, @@ -174,6 +182,7 @@ class InventoryJsonCodec { updatedAt: p.updatedAt, lastAuthor: p.lastAuthor, schemaRowVersion: p.schemaRowVersion, + isDeleted: p.isDeleted, ), 'displayName': p.displayName, 'publicKey': p.publicKey, @@ -190,6 +199,7 @@ class InventoryJsonCodec { updatedAt: a.updatedAt, lastAuthor: a.lastAuthor, schemaRowVersion: a.schemaRowVersion, + isDeleted: a.isDeleted, ), 'parentType': a.parentType.name, 'parentId': a.parentId, @@ -200,6 +210,58 @@ class InventoryJsonCodec { 'sortOrder': a.sortOrder, }, ], + 'plantares': [ + for (final p in snapshot.plantares) + { + ..._syncMeta( + id: p.id, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + lastAuthor: p.lastAuthor, + schemaRowVersion: p.schemaRowVersion, + isDeleted: p.isDeleted, + ), + 'varietyId': p.varietyId, + 'direction': p.direction.name, + 'counterparty': p.counterparty, + 'owedDescription': p.owedDescription, + 'madeOn': p.madeOn, + 'dueBy': p.dueBy, + 'status': p.status.name, + 'settledOn': p.settledOn, + 'note': p.note, + // Bilateral signed form (plantare-bilateral.md). + 'pledgeId': p.pledgeId, + 'debtorKey': p.debtorKey, + 'creditorKey': p.creditorKey, + 'debtorSignature': p.debtorSignature, + 'creditorSignature': p.creditorSignature, + 'movementId': p.movementId, + 'remoteState': p.remoteState?.name, + 'returnKind': p.returnKind.name, + 'workHours': p.workHours, + }, + ], + 'sales': [ + for (final s in snapshot.sales) + { + ..._syncMeta( + id: s.id, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + lastAuthor: s.lastAuthor, + schemaRowVersion: s.schemaRowVersion, + isDeleted: s.isDeleted, + ), + 'varietyId': s.varietyId, + 'direction': s.direction.name, + 'counterparty': s.counterparty, + 'amount': s.amount, + 'currency': s.currency, + 'soldOn': s.soldOn, + 'note': s.note, + }, + ], }); } @@ -211,7 +273,7 @@ class InventoryJsonCodec { throw const FormatException('Not a valid JSON file.'); } if (root is! Map) { - throw const FormatException('Not a Tanemaki inventory file.'); + throw const FormatException('Not a Tane inventory file.'); } final version = root['formatVersion']; if (version is! int) { @@ -236,7 +298,7 @@ class InventoryJsonCodec { createdAt: _int(m, 'createdAt'), updatedAt: _string(m, 'updatedAt'), lastAuthor: _string(m, 'lastAuthor'), - isDeleted: false, + isDeleted: _bool(m, 'isDeleted'), schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), label: _string(m, 'label'), speciesId: speciesId, @@ -263,7 +325,7 @@ class InventoryJsonCodec { createdAt: _int(m, 'createdAt'), updatedAt: _string(m, 'updatedAt'), lastAuthor: _string(m, 'lastAuthor'), - isDeleted: false, + isDeleted: _bool(m, 'isDeleted'), schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), varietyId: _string(m, 'varietyId'), // Unknown enum values → safe defaults (data-model §5.2). @@ -288,6 +350,8 @@ class InventoryJsonCodec { PreservationFormat.values, m['preservationFormat'], ), + priceAmount: (m['priceAmount'] as num?)?.toDouble(), + priceCurrency: m['priceCurrency'] as String?, ); }), vernacularNames: _rows(root, 'vernacularNames', (m) { @@ -296,7 +360,7 @@ class InventoryJsonCodec { createdAt: _int(m, 'createdAt'), updatedAt: _string(m, 'updatedAt'), lastAuthor: _string(m, 'lastAuthor'), - isDeleted: false, + isDeleted: _bool(m, 'isDeleted'), schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), varietyId: _string(m, 'varietyId'), name: _string(m, 'name'), @@ -312,7 +376,7 @@ class InventoryJsonCodec { createdAt: _int(m, 'createdAt'), updatedAt: _string(m, 'updatedAt'), lastAuthor: _string(m, 'lastAuthor'), - isDeleted: false, + isDeleted: _bool(m, 'isDeleted'), schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), parentType: parentType, parentId: _string(m, 'parentId'), @@ -326,7 +390,7 @@ class InventoryJsonCodec { createdAt: _int(m, 'createdAt'), updatedAt: _string(m, 'updatedAt'), lastAuthor: _string(m, 'lastAuthor'), - isDeleted: false, + isDeleted: _bool(m, 'isDeleted'), schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), lotId: _string(m, 'lotId'), testedOn: m['testedOn'] as int?, @@ -341,7 +405,7 @@ class InventoryJsonCodec { createdAt: _int(m, 'createdAt'), updatedAt: _string(m, 'updatedAt'), lastAuthor: _string(m, 'lastAuthor'), - isDeleted: false, + isDeleted: _bool(m, 'isDeleted'), schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), lotId: _string(m, 'lotId'), checkedOn: m['checkedOn'] as int?, @@ -379,7 +443,7 @@ class InventoryJsonCodec { createdAt: _int(m, 'createdAt'), updatedAt: _string(m, 'updatedAt'), lastAuthor: _string(m, 'lastAuthor'), - isDeleted: false, + isDeleted: _bool(m, 'isDeleted'), schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), displayName: _string(m, 'displayName'), publicKey: m['publicKey'] as String?, @@ -397,7 +461,7 @@ class InventoryJsonCodec { createdAt: _int(m, 'createdAt'), updatedAt: _string(m, 'updatedAt'), lastAuthor: _string(m, 'lastAuthor'), - isDeleted: false, + isDeleted: _bool(m, 'isDeleted'), schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), parentType: parentType, parentId: _string(m, 'parentId'), @@ -410,6 +474,57 @@ class InventoryJsonCodec { sortOrder: _int(m, 'sortOrder', fallback: 0), ); }), + plantares: _rows(root, 'plantares', (m) { + return Plantare( + id: _string(m, 'id'), + createdAt: _int(m, 'createdAt'), + updatedAt: _string(m, 'updatedAt'), + lastAuthor: _string(m, 'lastAuthor'), + isDeleted: _bool(m, 'isDeleted'), + schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), + varietyId: m['varietyId'] as String?, + direction: + _enumOr(PlantareDirection.values, m['direction'], + PlantareDirection.iReturn), + counterparty: m['counterparty'] as String?, + owedDescription: m['owedDescription'] as String?, + madeOn: _int(m, 'madeOn'), + dueBy: m['dueBy'] as int?, + status: + _enumOr(PlantareStatus.values, m['status'], PlantareStatus.open), + settledOn: m['settledOn'] as int?, + note: m['note'] as String?, + pledgeId: m['pledgeId'] as String?, + debtorKey: m['debtorKey'] as String?, + creditorKey: m['creditorKey'] as String?, + debtorSignature: m['debtorSignature'] as String?, + creditorSignature: m['creditorSignature'] as String?, + movementId: m['movementId'] as String?, + remoteState: + _enumOrNull(PlantareRemoteState.values, m['remoteState']), + returnKind: _enumOr(PlantareReturnKind.values, m['returnKind'], + PlantareReturnKind.similar), + workHours: (m['workHours'] as num?)?.toDouble(), + ); + }), + sales: _rows(root, 'sales', (m) { + return Sale( + id: _string(m, 'id'), + createdAt: _int(m, 'createdAt'), + updatedAt: _string(m, 'updatedAt'), + lastAuthor: _string(m, 'lastAuthor'), + isDeleted: _bool(m, 'isDeleted'), + schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1), + varietyId: m['varietyId'] as String?, + direction: + _enumOr(SaleDirection.values, m['direction'], SaleDirection.iSold), + counterparty: m['counterparty'] as String?, + amount: (m['amount'] as num?)?.toDouble(), + currency: m['currency'] as String?, + soldOn: _int(m, 'soldOn'), + note: m['note'] as String?, + ); + }), ); } @@ -419,11 +534,15 @@ class InventoryJsonCodec { required String updatedAt, required String lastAuthor, required int schemaRowVersion, + bool isDeleted = false, }) => { 'id': id, 'createdAt': createdAt, 'updatedAt': updatedAt, 'lastAuthor': lastAuthor, + // Only emitted for tombstones — user backups stay tombstone-free, so their + // bytes are unchanged; the sync snapshot carries deletions so they replicate. + if (isDeleted) 'isDeleted': true, 'schemaRowVersion': schemaRowVersion, }; @@ -456,6 +575,8 @@ class InventoryJsonCodec { return value; } + bool _bool(Map m, String key) => m[key] == true; + int _int(Map m, String key, {int? fallback}) { final value = m[key]; if (value is int) return value; diff --git a/apps/app_seeds/lib/data/export_import/inventory_snapshot.dart b/apps/app_seeds/lib/data/export_import/inventory_snapshot.dart index 1fff2dc..68df583 100644 --- a/apps/app_seeds/lib/data/export_import/inventory_snapshot.dart +++ b/apps/app_seeds/lib/data/export_import/inventory_snapshot.dart @@ -30,6 +30,8 @@ class InventorySnapshot { this.movements = const [], this.parties = const [], this.attachments = const [], + this.plantares = const [], + this.sales = const [], this.speciesNamesById = const {}, }); @@ -42,6 +44,8 @@ class InventorySnapshot { final List movements; final List parties; final List attachments; + final List plantares; + final List sales; /// speciesId → scientificName, for the species referenced by [varieties]. final Map speciesNamesById; diff --git a/apps/app_seeds/lib/data/export_import/seed_label_codec.dart b/apps/app_seeds/lib/data/export_import/seed_label_codec.dart new file mode 100644 index 0000000..6ed792c --- /dev/null +++ b/apps/app_seeds/lib/data/export_import/seed_label_codec.dart @@ -0,0 +1,92 @@ +import 'package:equatable/equatable.dart'; + +/// The seed facts a printed label carries in its QR code — just enough to +/// identify the seed offline, so the data travels on the physical packet with +/// no server and no account. Kept deliberately small so the QR stays scannable +/// at sticker size. +class SeedLabelData extends Equatable { + const SeedLabelData({ + required this.varietyLabel, + this.scientificName, + this.commonName, + this.year, + this.origin, + }); + + /// The variety's label, as its keeper writes it (e.g. "Acelga de Perales"). + final String varietyLabel; + + /// Scientific name of the linked species, if any (e.g. "Beta vulgaris"). + final String? scientificName; + + /// The species' common name in the keeper's locale (e.g. "Acelga"). + final String? commonName; + + /// Harvest year of the batch, if known. + final int? year; + + /// Where the seed came from (origin name and/or place), if recorded. + final String? origin; + + @override + List get props => [ + varietyLabel, + scientificName, + commonName, + year, + origin, + ]; +} + +/// Encodes/decodes [SeedLabelData] as a compact, versioned `tane://seed` +/// URI — self-describing and parseable, so a future scan-to-import feature can +/// read a label a keeper printed today. Unicode and RTL text are percent-encoded +/// by [Uri], so any QR reader round-trips them safely. +/// +/// Shape: `tane://seed?v=&s=&c=&y=&o=` +/// Absent fields are omitted, keeping the payload (and the QR) as small as the +/// data allows. +abstract final class SeedLabelCodec { + static const _scheme = 'tane'; + static const _host = 'seed'; + + static String encode(SeedLabelData data) { + final params = { + 'v': data.varietyLabel, + if (_present(data.scientificName)) 's': data.scientificName!.trim(), + if (_present(data.commonName)) 'c': data.commonName!.trim(), + if (data.year != null) 'y': '${data.year}', + if (_present(data.origin)) 'o': data.origin!.trim(), + }; + return Uri( + scheme: _scheme, + host: _host, + queryParameters: params, + ).toString(); + } + + /// Parses a `tane://seed` URI back to [SeedLabelData]. Returns null when + /// [input] is not a well-formed seed label (wrong scheme/host, or no variety). + static SeedLabelData? decode(String input) { + final uri = Uri.tryParse(input.trim()); + if (uri == null || uri.scheme != _scheme || uri.host != _host) return null; + final q = uri.queryParameters; + final variety = q['v']?.trim(); + if (variety == null || variety.isEmpty) return null; + final year = q['y']; + return SeedLabelData( + varietyLabel: variety, + scientificName: _clean(q['s']), + commonName: _clean(q['c']), + year: year == null ? null : int.tryParse(year), + origin: _clean(q['o']), + ); + } + + static bool _present(String? value) => value != null && value.trim().isNotEmpty; + + static String? _clean(String? value) { + final trimmed = value?.trim(); + return (trimmed == null || trimmed.isEmpty) ? null : trimmed; + } +} diff --git a/apps/app_seeds/lib/data/seed_saving_catalog.dart b/apps/app_seeds/lib/data/seed_saving_catalog.dart new file mode 100644 index 0000000..9f6b6bd --- /dev/null +++ b/apps/app_seeds/lib/data/seed_saving_catalog.dart @@ -0,0 +1,35 @@ +import 'package:flutter/services.dart' show rootBundle; + +import '../domain/seed_saving.dart'; + +const _asset = 'assets/catalog/seed_saving.json'; + +/// Loads and parses the bundled seed-saving catalog asset. +Future loadBundledSeedSaving() async { + return parseSeedSaving(await rootBundle.loadString(_asset)); +} + +/// App-wide access to the bundled seed-saving guidance. It's small, read-only +/// reference data (not per-user, never synced), so it lives as a single +/// in-memory instance set once at startup — no DB table, no migration. Tests +/// inject a parsed [SeedSavingData] directly via [data]. +class SeedSavingCatalog { + SeedSavingCatalog._(); + + /// The loaded dataset, or null before [ensureLoaded] runs (then lookups just + /// return null and the UI hides the section). + static SeedSavingData? data; + + /// Loads the asset once. Safe to call repeatedly. + static Future ensureLoaded() async { + data ??= await loadBundledSeedSaving(); + } + + /// The guide for a crop, or null when the catalog isn't loaded or nothing + /// matches. See [SeedSavingData.guideFor]. + static SeedSavingGuide? guideFor({String? scientificName, String? family}) => + data?.guideFor(scientificName: scientificName, family: family); + + /// The attributions for the guidance (empty before load). + static List get sources => data?.sources ?? const []; +} diff --git a/apps/app_seeds/lib/data/species_catalog.dart b/apps/app_seeds/lib/data/species_catalog.dart index f381617..00cc7e7 100644 --- a/apps/app_seeds/lib/data/species_catalog.dart +++ b/apps/app_seeds/lib/data/species_catalog.dart @@ -31,3 +31,16 @@ Future> loadBundledSpecies() async { final jsonString = await rootBundle.loadString(_catalogAsset); return parseSpeciesCatalog(jsonString); } + +/// Version of the bundled catalog, mirrored from the asset's `version` field +/// (written by `tool/gen_species_catalog.dart`). A test asserts the two stay in +/// sync. The app records the version it last seeded and skips the ~1.5 MB parse +/// and the table scan in [SpeciesRepository.seedBundled] on every subsequent +/// launch — so bumping this (alongside the asset) is what triggers a one-time +/// re-seed after the catalog changes. +const int speciesCatalogVersion = 3; + +/// Reads just the `version` field from a catalog JSON string. Used by the sync +/// test to keep [speciesCatalogVersion] aligned with the asset. +int parseSpeciesCatalogVersion(String jsonString) => + (jsonDecode(jsonString) as Map)['version'] as int; diff --git a/apps/app_seeds/lib/data/species_repository.dart b/apps/app_seeds/lib/data/species_repository.dart index d521bc2..7a18f8c 100644 --- a/apps/app_seeds/lib/data/species_repository.dart +++ b/apps/app_seeds/lib/data/species_repository.dart @@ -70,6 +70,24 @@ class SpeciesRepository { /// Reads all existing species once and writes in a single batch: with a /// catalog of ~1000 species a per-row SELECT on every startup would be a real /// cost on the encrypted database. + /// Seeds the bundled catalog only when [readVersion] doesn't already report + /// [version]. [loadSeeds] is awaited lazily and skipped entirely when this + /// install already holds [version] — so the ~1.5 MB catalog parse and the + /// table scan in [seedBundled] are paid once per catalog version, not on + /// every launch (the main startup cost). On a (re)seed, [writeVersion] records + /// [version] only after [seedBundled] commits, so an interrupted seed is + /// retried on the next launch. + Future seedBundledIfNeeded({ + required int version, + required Future Function() readVersion, + required Future Function(String version) writeVersion, + required Future> Function() loadSeeds, + }) async { + if (await readVersion() == version.toString()) return; + await seedBundled(await loadSeeds()); + await writeVersion(version.toString()); + } + Future seedBundled(List seeds) async { final stamp = Hlc.zero(nodeId).pack(); await _db.transaction(() async { diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index b350a06..dfb9a4f 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -11,6 +11,10 @@ import 'export_import/import_reconciler.dart'; import 'export_import/inventory_csv_codec.dart'; import 'export_import/inventory_snapshot.dart'; +/// A commitment plus the label of the variety it's linked to (if any), for the +/// Plantares list — lets a tile name the seed and link back to its detail. +typedef PlantareView = ({Plantare plantare, String? varietyLabel}); + /// A lightweight row for the inventory list (only what the list renders). class VarietyListItem extends Equatable { const VarietyListItem({ @@ -25,6 +29,7 @@ class VarietyListItem extends Equatable { this.needsReproduction = false, this.isShared = false, this.viability = SeedViability.unknown, + this.sowMonths, }); final String id; @@ -62,6 +67,10 @@ class VarietyListItem extends Equatable { /// or there is no reference figure. final SeedViability viability; + /// The grower's recorded sow-months bitmask (see `domain/crop_calendar.dart`), + /// or null when unrecorded — drives the "to sow this month" inventory filter. + final int? sowMonths; + @override List get props => [ id, @@ -75,6 +84,47 @@ class VarietyListItem extends Equatable { needsReproduction, isShared, viability, + sowMonths, + ]; +} + +/// A variety's whole recorded crop calendar, for the aggregate "what's due this +/// month" view. Each field is a 12-bit month mask (see `crop_calendar.dart`); +/// only varieties with at least one recorded phase are emitted. +class CalendarEntry extends Equatable { + const CalendarEntry({ + required this.id, + required this.label, + this.category, + this.photo, + this.sowMonths, + this.transplantMonths, + this.floweringMonths, + this.fruitingMonths, + this.seedHarvestMonths, + }); + + final String id; + final String label; + final String? category; + final Uint8List? photo; + final int? sowMonths; + final int? transplantMonths; + final int? floweringMonths; + final int? fruitingMonths; + final int? seedHarvestMonths; + + @override + List get props => [ + id, + label, + category, + photo, + sowMonths, + transplantMonths, + floweringMonths, + fruitingMonths, + seedHarvestMonths, ]; } @@ -117,6 +167,110 @@ class SharedCatalogEntry extends Equatable { ]; } +/// One printable seed label for a selection of the inventory: a variety batch +/// (lot) with the facts a physical label carries — the species common name in +/// the keeper's locale, the variety label, the scientific name, the harvest +/// year and where it came from. A variety with no lots yields a single +/// name-only entry. Built by [VarietyRepository.labelRows]; unlike +/// [SharedCatalogEntry] it is not restricted to shared lots — you label what +/// you hold, private or not. +class SeedLabelEntry extends Equatable { + const SeedLabelEntry({ + required this.varietyLabel, + this.commonName, + this.scientificName, + this.category, + this.harvestYear, + this.quantity, + this.originName, + this.originPlace, + this.suggestedCopies = 1, + }); + + final String varietyLabel; + + /// The species' common name in the keeper's locale ("Acelga"), if known. + final String? commonName; + final String? scientificName; + final String? category; + final int? harvestYear; + final Quantity? quantity; + final String? originName; + final String? originPlace; + + /// How many copies of this label to suggest — the latest condition check's + /// container count (e.g. 3 jars → 3 labels), or 1 when unknown. Always ≥ 1; + /// the print sheet prefills it and lets the keeper edit. + final int suggestedCopies; + + @override + List get props => [ + varietyLabel, + commonName, + scientificName, + category, + harvestYear, + quantity, + originName, + originPlace, + suggestedCopies, + ]; +} + +/// A lot the user has marked for sharing, carrying the lot [lotId] (so the +/// published offer has a stable, updatable id) plus the summary a network offer +/// needs. Distinct from [SharedCatalogEntry], which is for the printable local +/// catalog and has no id. +class ShareableLot extends Equatable { + const ShareableLot({ + required this.lotId, + required this.varietyId, + required this.summary, + required this.offerStatus, + this.category, + this.harvestYear, + this.isOrganic = false, + this.priceAmount, + this.priceCurrency, + }); + + final String lotId; + + /// The lot's variety, so the publish step can fetch its cover photo to host + /// as the offer image. Not published itself — never leaves the device. + final String varietyId; + + /// What to publish: the variety label (never the full inventory). + final String summary; + + /// Never [OfferStatus.private] — private lots don't leave the device. + final OfferStatus offerStatus; + final String? category; + final int? harvestYear; + + /// The variety's grower-declared organic ("eco") flag, published so peers can + /// filter the market for it. + final bool isOrganic; + + /// Asking price, only populated for [OfferStatus.sell] lots. Null amount on + /// a sale means "price to be agreed" — the offer publishes without a price. + final double? priceAmount; + final String? priceCurrency; + + @override + List get props => [ + lotId, + varietyId, + summary, + offerStatus, + category, + harvestYear, + isOrganic, + priceAmount, + priceCurrency, + ]; +} + /// One germination test on a lot; [rate] is derived (0..1), null when it can't /// be computed. class GerminationEntry extends Equatable { @@ -188,6 +342,8 @@ class VarietyLot extends Equatable { this.abundance, this.preservationFormat, this.offerStatus = OfferStatus.private, + this.priceAmount, + this.priceCurrency, this.germinationTests = const [], this.conditionChecks = const [], }); @@ -220,6 +376,11 @@ class VarietyLot extends Equatable { /// the network is Block 2. final OfferStatus offerStatus; + /// Asking price, meaningful when [offerStatus] is `sell`. Null amount means + /// "price to be agreed". + final double? priceAmount; + final String? priceCurrency; + final List germinationTests; /// Storage-condition checks, most-recent first (`conditionChecks.first` = last). @@ -243,6 +404,8 @@ class VarietyLot extends Equatable { abundance, preservationFormat, offerStatus, + priceAmount, + priceCurrency, germinationTests, conditionChecks, ]; @@ -302,6 +465,7 @@ class VarietyDetail extends Equatable { this.notes, this.speciesId, this.scientificName, + this.family, this.gbifKey, this.wikidataQid, this.viabilityYears, @@ -325,6 +489,11 @@ class VarietyDetail extends Equatable { final String? speciesId; final String? scientificName; + /// Botanical family of the linked species (Latin), the reliable key for + /// seed-saving guidance; null when no species is linked. (The editable + /// [category] is prefilled from this but may be changed by the grower.) + final String? family; + /// Bundled identifiers of the linked species, used to derive verified /// "learn more" reference links (GBIF/Wikipedia/Wikispecies); null when no /// species is linked or the catalog entry lacks the id. @@ -372,6 +541,7 @@ class VarietyDetail extends Equatable { notes, speciesId, scientificName, + family, gbifKey, wikidataQid, viabilityYears, @@ -449,6 +619,20 @@ class VarietyRepository { ); } + /// One-shot list of catalogued (named, non-draft) seeds for pickers — id + + /// label only, no photo/species joins. A Future (not a stream) so a transient + /// sheet doesn't hold a live subscription (which would hang widget tests). + Future> varietyLabels() async { + final rows = await (_db.select(_db.varieties) + ..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(false)) + ..orderBy([(v) => OrderingTerm(expression: v.label)])) + .get(); + return [ + for (final v in rows) + if (v.label.trim().isNotEmpty) (id: v.id, label: v.label), + ]; + } + Future> _loadInventory() async { final rows = await (_db.select(_db.varieties) @@ -518,11 +702,57 @@ class VarietyRepository { : speciesViability[v.speciesId], currentYear: currentYear, ), + sowMonths: v.sowMonths, ), ) .toList(); } + /// Emits every non-draft variety that has any recorded crop-calendar phase, + /// for the aggregate "what's due this month" screen. Re-emits on variety or + /// photo changes. + Stream> watchCalendar() { + final triggers = StreamGroup.merge([ + (_db.select( + _db.varieties, + )..where((v) => v.isDeleted.equals(false))).watch().map((_) {}), + _db.select(_db.attachments).watch().map((_) {}), + ]); + return triggers.asyncMap((_) => _loadCalendar()); + } + + Future> _loadCalendar() async { + final rows = + await (_db.select(_db.varieties) + ..where( + (v) => + v.isDeleted.equals(false) & + v.isDraft.equals(false) & + (v.sowMonths.isNotNull() | + v.transplantMonths.isNotNull() | + v.floweringMonths.isNotNull() | + v.fruitingMonths.isNotNull() | + v.seedHarvestMonths.isNotNull()), + ) + ..orderBy([(v) => OrderingTerm(expression: v.label)])) + .get(); + final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); + return [ + for (final v in rows) + CalendarEntry( + id: v.id, + label: v.label, + category: v.category, + photo: photos[v.id], + sowMonths: v.sowMonths, + transplantMonths: v.transplantMonths, + floweringMonths: v.floweringMonths, + fruitingMonths: v.fruitingMonths, + seedHarvestMonths: v.seedHarvestMonths, + ), + ]; + } + /// Maps each of [speciesIds] to its bundled viability figure (years); species /// without a figure are simply absent from the map. Future> _speciesViabilityFor(Set speciesIds) async { @@ -644,6 +874,12 @@ class VarietyRepository { return byVariety; } + /// The cover photo (lowest `sortOrder`) for a single variety, or null when it + /// has none. Used by the publish step to host an offer's image; reuses the + /// same "first photo" rule as the inventory avatar. + Future coverPhotoFor(String varietyId) async => + (await _firstPhotosFor([varietyId]))[varietyId]; + /// Maps each of [speciesIds] to its scientific name (one query). Future> _scientificNamesFor( Set speciesIds, @@ -836,6 +1072,7 @@ class VarietyRepository { if (v == null) return null; String? scientificName; + String? family; int? viabilityYears; int? gbifKey; String? wikidataQid; @@ -844,6 +1081,7 @@ class VarietyRepository { _db.species, )..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull(); scientificName = species?.scientificName; + family = species?.family; viabilityYears = species?.viabilityYears; gbifKey = species?.gbifKey; wikidataQid = species?.wikidataQid; @@ -933,6 +1171,7 @@ class VarietyRepository { notes: v.notes, speciesId: v.speciesId, scientificName: scientificName, + family: family, gbifKey: gbifKey, wikidataQid: wikidataQid, viabilityYears: viabilityYears, @@ -978,7 +1217,10 @@ class VarietyRepository { Future<({String id, String? family})?> _autoClassifyFromLabel( String label, ) async { - final speciesId = matchSpeciesInLabel(label, await _speciesNamesForMatching()); + final speciesId = matchSpeciesInLabel( + label, + await _speciesNamesForMatching(), + ); if (speciesId == null) return null; final species = await (_db.select( _db.species, @@ -1085,9 +1327,12 @@ class VarietyRepository { Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, + double? priceAmount, + String? priceCurrency, }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); + final forSale = offerStatus == OfferStatus.sell; await _db .into(_db.lots) .insert( @@ -1110,6 +1355,8 @@ class VarietyRepository { abundance: Value(abundance), preservationFormat: Value(preservationFormat), offerStatus: Value(offerStatus), + priceAmount: Value(forSale ? priceAmount : null), + priceCurrency: Value(forSale ? priceCurrency : null), ), ); return id; @@ -1130,8 +1377,13 @@ class VarietyRepository { Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, + double? priceAmount, + String? priceCurrency, }) async { final (_, updated) = await _stamp(); + // Price only makes sense on a lot offered for sale; clearing the sale + // status clears the price with it. + final forSale = offerStatus == OfferStatus.sell; await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( LotsCompanion( type: Value(type), @@ -1147,6 +1399,8 @@ class VarietyRepository { abundance: Value(abundance), preservationFormat: Value(preservationFormat), offerStatus: Value(offerStatus), + priceAmount: Value(forSale ? priceAmount : null), + priceCurrency: Value(forSale ? priceCurrency : null), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), @@ -1210,6 +1464,187 @@ class VarietyRepository { return entries; } + /// Printable label rows for a selection of [varietyIds]: one entry per + /// non-deleted lot (year/origin/quantity are per batch), or a single + /// name-only entry for a selected variety that holds no lots. [languageCode] + /// picks the species common name shown on top. Ordered by category, label + /// then harvest year. Draft and deleted varieties are excluded. + Future> labelRows( + Set varietyIds, { + required String languageCode, + }) async { + if (varietyIds.isEmpty) return const []; + final varieties = + await (_db.select(_db.varieties)..where( + (v) => + v.id.isIn(varietyIds) & + v.isDeleted.equals(false) & + v.isDraft.equals(false), + )) + .get(); + if (varieties.isEmpty) return const []; + + final speciesIds = varieties + .map((v) => v.speciesId) + .whereType() + .toSet(); + final sciNames = await _scientificNamesFor(speciesIds); + final commonNames = await _commonNamesFor(speciesIds, languageCode); + + final lots = + await (_db.select(_db.lots)..where( + (l) => + l.varietyId.isIn(varieties.map((v) => v.id).toList()) & + l.isDeleted.equals(false), + )) + .get(); + final lotsByVariety = >{}; + for (final l in lots) { + (lotsByVariety[l.varietyId] ??= []).add(l); + } + + // Suggest one label per stored container: the latest condition check's + // container count (3 jars → 3 labels). `.first` is the most recent because + // we order by check date descending. + final containersByLot = {}; + if (lots.isNotEmpty) { + final checks = + await (_db.select(_db.conditionChecks) + ..where( + (c) => + c.lotId.isIn(lots.map((l) => l.id).toList()) & + c.isDeleted.equals(false), + ) + ..orderBy([(c) => OrderingTerm.desc(c.checkedOn)])) + .get(); + for (final c in checks) { + final count = c.containerCount; + if (count != null && count > 0) { + containersByLot.putIfAbsent(c.lotId, () => count); + } + } + } + + SeedLabelEntry entryFor(Variety v, Lot? l) { + final hasQuantity = + l != null && + (l.quantityKind != null || + l.quantityPrecise != null || + l.quantityLabel != null); + return SeedLabelEntry( + varietyLabel: v.label, + commonName: v.speciesId == null ? null : commonNames[v.speciesId], + scientificName: v.speciesId == null ? null : sciNames[v.speciesId], + category: v.category, + harvestYear: l?.harvestYear, + quantity: hasQuantity + ? Quantity( + kind: _parseKind(l.quantityKind), + count: l.quantityPrecise, + label: l.quantityLabel, + ) + : null, + originName: l?.originName, + originPlace: l?.originPlace, + suggestedCopies: l == null ? 1 : (containersByLot[l.id] ?? 1), + ); + } + + final entries = []; + for (final v in varieties) { + final vLots = lotsByVariety[v.id]; + if (vLots == null || vLots.isEmpty) { + entries.add(entryFor(v, null)); + } else { + for (final l in vLots) { + entries.add(entryFor(v, l)); + } + } + } + entries.sort((a, b) { + final byCategory = (a.category ?? '').toLowerCase().compareTo( + (b.category ?? '').toLowerCase(), + ); + if (byCategory != 0) return byCategory; + final byLabel = a.varietyLabel.toLowerCase().compareTo( + b.varietyLabel.toLowerCase(), + ); + if (byLabel != 0) return byLabel; + return (a.harvestYear ?? 0).compareTo(b.harvestYear ?? 0); + }); + return entries; + } + + /// Maps each of [speciesIds] to its best common name for [languageCode] (one + /// query), preferring a name in the locale, else any. Species without a + /// common name are absent from the map. + Future> _commonNamesFor( + Set speciesIds, + String languageCode, + ) async { + if (speciesIds.isEmpty) return const {}; + final rows = + await (_db.select(_db.speciesCommonNames)..where( + (n) => n.speciesId.isIn(speciesIds) & n.isDeleted.equals(false), + )) + .get(); + final bySpecies = >{}; + for (final n in rows) { + (bySpecies[n.speciesId] ??= []).add((name: n.name, language: n.language)); + } + final result = {}; + for (final entry in bySpecies.entries) { + final inLocale = entry.value.where((n) => n.language == languageCode); + result[entry.key] = + (inLocale.isEmpty ? entry.value.first : inLocale.first).name; + } + return result; + } + + /// Lots the user marked to share (not private), each with its id and the + /// variety label — the input for publishing offers to the network (Block 2). + Future> shareableLots() async { + final lots = + await (_db.select(_db.lots)..where( + (l) => + l.isDeleted.equals(false) & + l.offerStatus.equalsValue(OfferStatus.private).not(), + )) + .get(); + if (lots.isEmpty) return const []; + + final varietyIds = lots.map((l) => l.varietyId).toSet(); + final varieties = + await (_db.select(_db.varieties)..where( + (v) => + v.id.isIn(varietyIds) & + v.isDeleted.equals(false) & + v.isDraft.equals(false), + )) + .get(); + final byId = {for (final v in varieties) v.id: v}; + + return [ + for (final l in lots) + if (byId[l.varietyId] case final v?) + ShareableLot( + lotId: l.id, + varietyId: v.id, + summary: v.label, + offerStatus: l.offerStatus, + category: v.category, + harvestYear: l.harvestYear, + isOrganic: v.isOrganic, + priceAmount: l.offerStatus == OfferStatus.sell + ? l.priceAmount + : null, + priceCurrency: l.offerStatus == OfferStatus.sell + ? l.priceCurrency + : null, + ), + ]; + } + /// Soft-deletes a lot (tombstone); it leaves the variety's lot list. Future softDeleteLot(String lotId) async { final (_, updated) = await _stamp(); @@ -1435,6 +1870,367 @@ class VarietyRepository { return id; } + // --- Plantares: reproduction commitments (data-model §2.7) ---------------- + + /// Records a Plantare — a promise to reproduce seed and return some (or a + /// promise made TO you). Returns the new commitment id. + Future createPlantare({ + required PlantareDirection direction, + String? varietyId, + String? counterparty, + String? owedDescription, + int? dueBy, + String? note, + // Bilateral signed form (plantare-bilateral.md) — all optional so a plain + // local note stays a v1 row with these null. + String? id, + int? madeOn, + String? pledgeId, + String? debtorKey, + String? creditorKey, + String? debtorSignature, + String? creditorSignature, + String? movementId, + PlantareRemoteState? remoteState, + PlantareReturnKind returnKind = PlantareReturnKind.similar, + double? workHours, + }) async { + final (created, updated) = await _stamp(); + final rowId = id ?? idGen.newId(); + await _db + .into(_db.plantares) + .insert( + PlantaresCompanion.insert( + id: rowId, + createdAt: created, + updatedAt: updated, + lastAuthor: nodeId, + direction: direction, + varietyId: Value(varietyId), + counterparty: Value(counterparty), + owedDescription: Value(owedDescription), + madeOn: madeOn ?? created, + dueBy: Value(dueBy), + note: Value(note), + pledgeId: Value(pledgeId), + debtorKey: Value(debtorKey), + creditorKey: Value(creditorKey), + debtorSignature: Value(debtorSignature), + creditorSignature: Value(creditorSignature), + movementId: Value(movementId), + remoteState: Value(remoteState), + returnKind: Value(returnKind), + workHours: Value(workHours), + ), + ); + return rowId; + } + + /// The local row holding the bilateral pledge [pledgeId], or null. Both parties + /// key their own row by the shared pledge id so accepts/declines reconcile. + Future plantareByPledgeId(String pledgeId) => + (_db.select(_db.plantares) + ..where((p) => p.pledgeId.equals(pledgeId)) + ..limit(1)) + .getSingleOrNull(); + + /// Applies a counterparty's stub(s) and the new handshake [remoteState] to the + /// row for [pledgeId] (e.g. on receiving an `accept`). No-op if unknown. + Future applyPlantareSignatures({ + required String pledgeId, + String? debtorSignature, + String? creditorSignature, + PlantareRemoteState? remoteState, + String? movementId, + }) async { + final (_, updated) = await _stamp(); + await (_db.update(_db.plantares) + ..where((p) => p.pledgeId.equals(pledgeId))) + .write( + PlantaresCompanion( + debtorSignature: + debtorSignature == null ? const Value.absent() : Value(debtorSignature), + creditorSignature: creditorSignature == null + ? const Value.absent() + : Value(creditorSignature), + movementId: + movementId == null ? const Value.absent() : Value(movementId), + remoteState: + remoteState == null ? const Value.absent() : Value(remoteState), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + /// Moves the row for [pledgeId] to a new handshake [state] (e.g. `declined`). + Future setPlantareRemoteState( + String pledgeId, + PlantareRemoteState state, + ) async { + final (_, updated) = await _stamp(); + await (_db.update(_db.plantares) + ..where((p) => p.pledgeId.equals(pledgeId))) + .write( + PlantaresCompanion( + remoteState: Value(state), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + /// All live commitments, newest first (for the Plantares screen). + Stream> watchPlantares() => + (_db.select(_db.plantares) + ..where((p) => p.isDeleted.equals(false)) + ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) + .watch(); + + /// Live commitments joined with the label of the variety each is linked to + /// (null when unlinked or the variety is gone), newest first — so the list + /// can name the seed and offer a tap-through to its detail. + Stream> watchPlantareViews() { + final query = _db.select(_db.plantares).join([ + leftOuterJoin( + _db.varieties, + _db.varieties.id.equalsExp(_db.plantares.varietyId), + ), + ]) + ..where(_db.plantares.isDeleted.equals(false)) + ..orderBy([OrderingTerm.desc(_db.plantares.madeOn)]); + return query.watch().map((rows) => [ + for (final row in rows) + ( + plantare: row.readTable(_db.plantares), + varietyLabel: switch (row.readTableOrNull(_db.varieties)) { + final v? when !v.isDeleted => v.label, + _ => null, + }, + ), + ]); + } + + /// The commitments recorded against one variety. + Stream> watchPlantaresForVariety(String varietyId) => + (_db.select(_db.plantares) + ..where( + (p) => p.varietyId.equals(varietyId) & p.isDeleted.equals(false), + ) + ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) + .watch(); + + /// Moves a commitment to [status]; stamps the settle time for returned/ + /// forgiven, clears it when reopened. + Future setPlantareStatus(String id, PlantareStatus status) async { + final (now, updated) = await _stamp(); + await (_db.update(_db.plantares)..where((p) => p.id.equals(id))).write( + PlantaresCompanion( + status: Value(status), + settledOn: Value(status == PlantareStatus.open ? null : now), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + /// Soft-deletes a commitment (tombstone, so it syncs as a removal). + Future deletePlantare(String id) async { + final (_, updated) = await _stamp(); + await (_db.update(_db.plantares)..where((p) => p.id.equals(id))).write( + PlantaresCompanion( + isDeleted: const Value(true), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + // --- Sales: recorded seed sales (separate from gift/Plantare) -------------- + + /// Records a seed sale (or purchase) — price in ANY currency. Returns its id. + Future createSale({ + required SaleDirection direction, + String? varietyId, + String? counterparty, + double? amount, + String? currency, + String? note, + }) async { + final (created, updated) = await _stamp(); + final id = idGen.newId(); + await _db + .into(_db.sales) + .insert( + SalesCompanion.insert( + id: id, + createdAt: created, + updatedAt: updated, + lastAuthor: nodeId, + direction: direction, + varietyId: Value(varietyId), + counterparty: Value(counterparty), + amount: Value(amount), + currency: Value(currency), + soldOn: created, + note: Value(note), + ), + ); + return id; + } + + /// All live sales, newest first (for the Sales screen). + Stream> watchSales() => + (_db.select(_db.sales) + ..where((s) => s.isDeleted.equals(false)) + ..orderBy([(s) => OrderingTerm.desc(s.soldOn)])) + .watch(); + + /// The sales recorded against one variety. + Stream> watchSalesForVariety(String varietyId) => + (_db.select(_db.sales) + ..where( + (s) => s.varietyId.equals(varietyId) & s.isDeleted.equals(false), + ) + ..orderBy([(s) => OrderingTerm.desc(s.soldOn)])) + .watch(); + + /// Soft-deletes a sale (tombstone, so it syncs as a removal). + Future deleteSale(String id) async { + final (_, updated) = await _stamp(); + await (_db.update(_db.sales)..where((s) => s.id.equals(id))).write( + SalesCompanion( + isDeleted: const Value(true), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + // --- Hand-over: the one moment seeds change hands -------------------------- + + /// Records a hand-over in a single transaction (data-model §2.8: a closed + /// deal produces a `Movement` and, optionally, a `Plantare`): the Movement + /// (given/received) plus — when they came with it — the Sale (money changed + /// hands, informational only, never processed in-app) and the Plantare (a + /// return promise), all sharing the same free-text [counterparty]. + /// + /// With nothing optional this is a plain gift: just the Movement. + /// + /// [gaveAll] is the one-tap "all of it" (the natural path for a whole plant + /// or shrub): the movement carries the lot's current quantity, the lot's + /// precise quantity drops to 0 and it returns to [OfferStatus.private] — + /// withdrawing it from the market on the next publish cycle. Otherwise + /// [quantity] says how much of the batch moved (optional). + Future<({String movementId, String? saleId, String? plantareId})> + recordHandover({ + required String lotId, + required HandoverDirection direction, + bool gaveAll = false, + Quantity? quantity, + String? counterparty, + bool withPayment = false, + double? paymentAmount, + String? paymentCurrency, + bool withPromise = false, + String? promiseOwedDescription, + int? promiseDueBy, + String? note, + }) async { + final gave = direction == HandoverDirection.iGave; + return _db.transaction(() async { + final lot = await (_db.select( + _db.lots, + )..where((l) => l.id.equals(lotId))).getSingle(); + + // The promise first, so the movement can carry its id — the link the + // model reserved for exactly this (data-model §2.4: "a `given` may + // carry a Plantare"). + String? plantareId; + if (withPromise) { + plantareId = await createPlantare( + direction: gave + ? PlantareDirection.owedToMe + : PlantareDirection.iReturn, + varietyId: lot.varietyId, + counterparty: counterparty, + owedDescription: promiseOwedDescription, + dueBy: promiseDueBy, + ); + } + + final movedAll = gaveAll && gave; + final lotHasQuantity = + lot.quantityKind != null || + lot.quantityPrecise != null || + lot.quantityLabel != null; + final movedQuantity = movedAll + ? (lotHasQuantity + ? Quantity( + kind: _parseKind(lot.quantityKind), + count: lot.quantityPrecise, + label: lot.quantityLabel, + ) + : null) + : quantity; + final noteParts = [ + if (counterparty != null && counterparty.trim().isNotEmpty) + counterparty.trim(), + if (note != null && note.trim().isNotEmpty) note.trim(), + ]; + final (created, _) = await _stamp(); + final movementId = idGen.newId(); + await _db + .into(_db.movements) + .insert( + MovementsCompanion.insert( + id: movementId, + createdAt: created, + lastAuthor: nodeId, + lotId: lotId, + type: gave ? MovementType.given : MovementType.received, + occurredOn: Value(created), + quantityKind: Value(movedQuantity?.kind.name), + quantityPrecise: Value(movedQuantity?.count?.toDouble()), + quantityLabel: Value(movedQuantity?.label), + plantareId: Value(plantareId), + notes: Value(noteParts.isEmpty ? null : noteParts.join(' — ')), + ), + ); + + if (movedAll) { + // The batch is gone: an empty jar (0, same unit) with nothing left to + // declare or offer. Price goes with the sale status. + final (_, updated) = await _stamp(); + await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( + LotsCompanion( + quantityPrecise: const Value(0), + abundance: const Value(null), + offerStatus: const Value(OfferStatus.private), + priceAmount: const Value(null), + priceCurrency: const Value(null), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + String? saleId; + if (withPayment) { + saleId = await createSale( + direction: gave ? SaleDirection.iSold : SaleDirection.iBought, + varietyId: lot.varietyId, + counterparty: counterparty, + amount: paymentAmount, + currency: paymentCurrency, + note: note, + ); + } + + return (movementId: movementId, saleId: saleId, plantareId: plantareId); + }); + } + /// Snapshots the live inventory (tombstones excluded) for the interchange /// export — data-model §7. Includes photo bytes; the JSON codec embeds them /// as base64 and the CSV codec ignores them. @@ -1470,6 +2266,37 @@ class VarietyRepository { attachments: await (_db.select( _db.attachments, )..where((a) => a.isDeleted.equals(false))).get(), + plantares: await (_db.select( + _db.plantares, + )..where((p) => p.isDeleted.equals(false))).get(), + sales: await (_db.select( + _db.sales, + )..where((s) => s.isDeleted.equals(false))).get(), + ); + } + + /// Snapshots the inventory for device-to-device SYNC — unlike [exportInventory] + /// this INCLUDES tombstones (so deletions replicate) and EXCLUDES attachment + /// bytes (photos are large and stay device-local for now; syncing media is a + /// separate concern). Everything else — the CRDT rows with their sync metadata + /// — replicates and merges LWW-by-HLC on the other device. + Future exportForSync() async { + final varieties = await _db.select(_db.varieties).get(); // incl. tombstones + return InventorySnapshot( + varieties: varieties, + speciesNamesById: await _scientificNamesFor( + varieties.map((v) => v.speciesId).whereType().toSet(), + ), + lots: await _db.select(_db.lots).get(), + vernacularNames: await _db.select(_db.varietyVernacularNames).get(), + externalLinks: await _db.select(_db.externalLinks).get(), + germinationTests: await _db.select(_db.germinationTests).get(), + conditionChecks: await _db.select(_db.conditionChecks).get(), + movements: await _db.select(_db.movements).get(), + parties: await _db.select(_db.parties).get(), + plantares: await _db.select(_db.plantares).get(), + sales: await _db.select(_db.sales).get(), + // attachments intentionally omitted — photos don't ride the sync wire. ); } @@ -1561,6 +2388,22 @@ class VarietyRepository { updatedAtOf: (r) => r.updatedAt, reconciler: reconciler, ); + summary += await _importMutableRows( + table: _db.plantares, + idColumn: _db.plantares.id, + rows: snapshot.plantares, + idOf: (r) => r.id, + updatedAtOf: (r) => r.updatedAt, + reconciler: reconciler, + ); + summary += await _importMutableRows( + table: _db.sales, + idColumn: _db.sales.id, + rows: snapshot.sales, + idOf: (r) => r.id, + updatedAtOf: (r) => r.updatedAt, + reconciler: reconciler, + ); summary += await _importMovements(snapshot.movements, reconciler); }); @@ -1573,6 +2416,8 @@ class VarietyRepository { for (final c in snapshot.conditionChecks) c.updatedAt, for (final p in snapshot.parties) p.updatedAt, for (final a in snapshot.attachments) a.updatedAt, + for (final pl in snapshot.plantares) pl.updatedAt, + for (final s in snapshot.sales) s.updatedAt, ]); if (maxIncoming != null) { _clock = _clock.receiveEvent(maxIncoming, _now()); @@ -1808,6 +2653,8 @@ class VarietyRepository { abundance: l.abundance, preservationFormat: l.preservationFormat, offerStatus: l.offerStatus, + priceAmount: l.priceAmount, + priceCurrency: l.priceCurrency, germinationTests: germinationTests, conditionChecks: conditionChecks, quantity: hasQuantity diff --git a/apps/app_seeds/lib/db/chat_database.dart b/apps/app_seeds/lib/db/chat_database.dart new file mode 100644 index 0000000..9411178 --- /dev/null +++ b/apps/app_seeds/lib/db/chat_database.dart @@ -0,0 +1,54 @@ +import 'package:drift/drift.dart'; + +part 'chat_database.g.dart'; + +/// A 1:1 chat message, held locally so a conversation survives leaving the +/// screen and going offline. +/// +/// This is deliberately NOT a [SyncColumns] inventory row: chat history is an +/// ephemeral, per-device cache of what the relays delivered — not the user's +/// CRDT-synced inventory. It never enters an inventory snapshot, backup, or the +/// device-to-device sync, so it needs no HLC / tombstone / author metadata. It +/// lives in its own encrypted database ([ChatDatabase]) to keep it fully +/// isolated from the inventory schema, its migrations, and its sync stream. +/// +/// [accountScope] namespaces rows per social identity (empty = the original +/// identity), replacing the old per-key keystore prefix. The unique key makes +/// de-duplication a DB invariant: a relay re-delivers the same gift wrap on +/// every (re)subscribe, and an identical (scope, peer, sender, timestamp, text) +/// row is simply not inserted twice. +@TableIndex( + name: 'messages_by_conversation', + columns: {#accountScope, #peerPubkey, #sentAt}, +) +class Messages extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get accountScope => text()(); + TextColumn get peerPubkey => text()(); + TextColumn get fromPubkey => text()(); + TextColumn get body => text()(); + + /// Milliseconds since epoch — the message's own timestamp (NIP-17), used for + /// ordering and for the read/unread mark. + IntColumn get sentAt => integer()(); + + @override + List> get uniqueKeys => [ + {accountScope, peerPubkey, fromPubkey, sentAt, body}, + ]; +} + +/// The encrypted local chat cache (SQLCipher via an injected executor, exactly +/// like [AppDatabase]). Separate database on purpose — see [Messages]. There is +/// no historical schema to migrate: version 1, created fresh. +@DriftDatabase(tables: [Messages]) +class ChatDatabase extends _$ChatDatabase { + ChatDatabase(super.e); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => + MigrationStrategy(onCreate: (m) async => m.createAll()); +} diff --git a/apps/app_seeds/lib/db/chat_database.g.dart b/apps/app_seeds/lib/db/chat_database.g.dart new file mode 100644 index 0000000..ce9c74a --- /dev/null +++ b/apps/app_seeds/lib/db/chat_database.g.dart @@ -0,0 +1,654 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_database.dart'; + +// ignore_for_file: type=lint +class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MessagesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _accountScopeMeta = const VerificationMeta( + 'accountScope', + ); + @override + late final GeneratedColumn accountScope = GeneratedColumn( + 'account_scope', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _peerPubkeyMeta = const VerificationMeta( + 'peerPubkey', + ); + @override + late final GeneratedColumn peerPubkey = GeneratedColumn( + 'peer_pubkey', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _fromPubkeyMeta = const VerificationMeta( + 'fromPubkey', + ); + @override + late final GeneratedColumn fromPubkey = GeneratedColumn( + 'from_pubkey', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bodyMeta = const VerificationMeta('body'); + @override + late final GeneratedColumn body = GeneratedColumn( + 'body', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sentAtMeta = const VerificationMeta('sentAt'); + @override + late final GeneratedColumn sentAt = GeneratedColumn( + 'sent_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + accountScope, + peerPubkey, + fromPubkey, + body, + sentAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'messages'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('account_scope')) { + context.handle( + _accountScopeMeta, + accountScope.isAcceptableOrUnknown( + data['account_scope']!, + _accountScopeMeta, + ), + ); + } else if (isInserting) { + context.missing(_accountScopeMeta); + } + if (data.containsKey('peer_pubkey')) { + context.handle( + _peerPubkeyMeta, + peerPubkey.isAcceptableOrUnknown(data['peer_pubkey']!, _peerPubkeyMeta), + ); + } else if (isInserting) { + context.missing(_peerPubkeyMeta); + } + if (data.containsKey('from_pubkey')) { + context.handle( + _fromPubkeyMeta, + fromPubkey.isAcceptableOrUnknown(data['from_pubkey']!, _fromPubkeyMeta), + ); + } else if (isInserting) { + context.missing(_fromPubkeyMeta); + } + if (data.containsKey('body')) { + context.handle( + _bodyMeta, + body.isAcceptableOrUnknown(data['body']!, _bodyMeta), + ); + } else if (isInserting) { + context.missing(_bodyMeta); + } + if (data.containsKey('sent_at')) { + context.handle( + _sentAtMeta, + sentAt.isAcceptableOrUnknown(data['sent_at']!, _sentAtMeta), + ); + } else if (isInserting) { + context.missing(_sentAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {accountScope, peerPubkey, fromPubkey, sentAt, body}, + ]; + @override + Message map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Message( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + accountScope: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_scope'], + )!, + peerPubkey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}peer_pubkey'], + )!, + fromPubkey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}from_pubkey'], + )!, + body: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}body'], + )!, + sentAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sent_at'], + )!, + ); + } + + @override + $MessagesTable createAlias(String alias) { + return $MessagesTable(attachedDatabase, alias); + } +} + +class Message extends DataClass implements Insertable { + final int id; + final String accountScope; + final String peerPubkey; + final String fromPubkey; + final String body; + + /// Milliseconds since epoch — the message's own timestamp (NIP-17), used for + /// ordering and for the read/unread mark. + final int sentAt; + const Message({ + required this.id, + required this.accountScope, + required this.peerPubkey, + required this.fromPubkey, + required this.body, + required this.sentAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_scope'] = Variable(accountScope); + map['peer_pubkey'] = Variable(peerPubkey); + map['from_pubkey'] = Variable(fromPubkey); + map['body'] = Variable(body); + map['sent_at'] = Variable(sentAt); + return map; + } + + MessagesCompanion toCompanion(bool nullToAbsent) { + return MessagesCompanion( + id: Value(id), + accountScope: Value(accountScope), + peerPubkey: Value(peerPubkey), + fromPubkey: Value(fromPubkey), + body: Value(body), + sentAt: Value(sentAt), + ); + } + + factory Message.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Message( + id: serializer.fromJson(json['id']), + accountScope: serializer.fromJson(json['accountScope']), + peerPubkey: serializer.fromJson(json['peerPubkey']), + fromPubkey: serializer.fromJson(json['fromPubkey']), + body: serializer.fromJson(json['body']), + sentAt: serializer.fromJson(json['sentAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'accountScope': serializer.toJson(accountScope), + 'peerPubkey': serializer.toJson(peerPubkey), + 'fromPubkey': serializer.toJson(fromPubkey), + 'body': serializer.toJson(body), + 'sentAt': serializer.toJson(sentAt), + }; + } + + Message copyWith({ + int? id, + String? accountScope, + String? peerPubkey, + String? fromPubkey, + String? body, + int? sentAt, + }) => Message( + id: id ?? this.id, + accountScope: accountScope ?? this.accountScope, + peerPubkey: peerPubkey ?? this.peerPubkey, + fromPubkey: fromPubkey ?? this.fromPubkey, + body: body ?? this.body, + sentAt: sentAt ?? this.sentAt, + ); + Message copyWithCompanion(MessagesCompanion data) { + return Message( + id: data.id.present ? data.id.value : this.id, + accountScope: data.accountScope.present + ? data.accountScope.value + : this.accountScope, + peerPubkey: data.peerPubkey.present + ? data.peerPubkey.value + : this.peerPubkey, + fromPubkey: data.fromPubkey.present + ? data.fromPubkey.value + : this.fromPubkey, + body: data.body.present ? data.body.value : this.body, + sentAt: data.sentAt.present ? data.sentAt.value : this.sentAt, + ); + } + + @override + String toString() { + return (StringBuffer('Message(') + ..write('id: $id, ') + ..write('accountScope: $accountScope, ') + ..write('peerPubkey: $peerPubkey, ') + ..write('fromPubkey: $fromPubkey, ') + ..write('body: $body, ') + ..write('sentAt: $sentAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, accountScope, peerPubkey, fromPubkey, body, sentAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Message && + other.id == this.id && + other.accountScope == this.accountScope && + other.peerPubkey == this.peerPubkey && + other.fromPubkey == this.fromPubkey && + other.body == this.body && + other.sentAt == this.sentAt); +} + +class MessagesCompanion extends UpdateCompanion { + final Value id; + final Value accountScope; + final Value peerPubkey; + final Value fromPubkey; + final Value body; + final Value sentAt; + const MessagesCompanion({ + this.id = const Value.absent(), + this.accountScope = const Value.absent(), + this.peerPubkey = const Value.absent(), + this.fromPubkey = const Value.absent(), + this.body = const Value.absent(), + this.sentAt = const Value.absent(), + }); + MessagesCompanion.insert({ + this.id = const Value.absent(), + required String accountScope, + required String peerPubkey, + required String fromPubkey, + required String body, + required int sentAt, + }) : accountScope = Value(accountScope), + peerPubkey = Value(peerPubkey), + fromPubkey = Value(fromPubkey), + body = Value(body), + sentAt = Value(sentAt); + static Insertable custom({ + Expression? id, + Expression? accountScope, + Expression? peerPubkey, + Expression? fromPubkey, + Expression? body, + Expression? sentAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (accountScope != null) 'account_scope': accountScope, + if (peerPubkey != null) 'peer_pubkey': peerPubkey, + if (fromPubkey != null) 'from_pubkey': fromPubkey, + if (body != null) 'body': body, + if (sentAt != null) 'sent_at': sentAt, + }); + } + + MessagesCompanion copyWith({ + Value? id, + Value? accountScope, + Value? peerPubkey, + Value? fromPubkey, + Value? body, + Value? sentAt, + }) { + return MessagesCompanion( + id: id ?? this.id, + accountScope: accountScope ?? this.accountScope, + peerPubkey: peerPubkey ?? this.peerPubkey, + fromPubkey: fromPubkey ?? this.fromPubkey, + body: body ?? this.body, + sentAt: sentAt ?? this.sentAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (accountScope.present) { + map['account_scope'] = Variable(accountScope.value); + } + if (peerPubkey.present) { + map['peer_pubkey'] = Variable(peerPubkey.value); + } + if (fromPubkey.present) { + map['from_pubkey'] = Variable(fromPubkey.value); + } + if (body.present) { + map['body'] = Variable(body.value); + } + if (sentAt.present) { + map['sent_at'] = Variable(sentAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagesCompanion(') + ..write('id: $id, ') + ..write('accountScope: $accountScope, ') + ..write('peerPubkey: $peerPubkey, ') + ..write('fromPubkey: $fromPubkey, ') + ..write('body: $body, ') + ..write('sentAt: $sentAt') + ..write(')')) + .toString(); + } +} + +abstract class _$ChatDatabase extends GeneratedDatabase { + _$ChatDatabase(QueryExecutor e) : super(e); + $ChatDatabaseManager get managers => $ChatDatabaseManager(this); + late final $MessagesTable messages = $MessagesTable(this); + late final Index messagesByConversation = Index( + 'messages_by_conversation', + 'CREATE INDEX messages_by_conversation ON messages (account_scope, peer_pubkey, sent_at)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + messages, + messagesByConversation, + ]; +} + +typedef $$MessagesTableCreateCompanionBuilder = + MessagesCompanion Function({ + Value id, + required String accountScope, + required String peerPubkey, + required String fromPubkey, + required String body, + required int sentAt, + }); +typedef $$MessagesTableUpdateCompanionBuilder = + MessagesCompanion Function({ + Value id, + Value accountScope, + Value peerPubkey, + Value fromPubkey, + Value body, + Value sentAt, + }); + +class $$MessagesTableFilterComposer + extends Composer<_$ChatDatabase, $MessagesTable> { + $$MessagesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get accountScope => $composableBuilder( + column: $table.accountScope, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get peerPubkey => $composableBuilder( + column: $table.peerPubkey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fromPubkey => $composableBuilder( + column: $table.fromPubkey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sentAt => $composableBuilder( + column: $table.sentAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$MessagesTableOrderingComposer + extends Composer<_$ChatDatabase, $MessagesTable> { + $$MessagesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get accountScope => $composableBuilder( + column: $table.accountScope, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get peerPubkey => $composableBuilder( + column: $table.peerPubkey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fromPubkey => $composableBuilder( + column: $table.fromPubkey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sentAt => $composableBuilder( + column: $table.sentAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$MessagesTableAnnotationComposer + extends Composer<_$ChatDatabase, $MessagesTable> { + $$MessagesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get accountScope => $composableBuilder( + column: $table.accountScope, + builder: (column) => column, + ); + + GeneratedColumn get peerPubkey => $composableBuilder( + column: $table.peerPubkey, + builder: (column) => column, + ); + + GeneratedColumn get fromPubkey => $composableBuilder( + column: $table.fromPubkey, + builder: (column) => column, + ); + + GeneratedColumn get body => + $composableBuilder(column: $table.body, builder: (column) => column); + + GeneratedColumn get sentAt => + $composableBuilder(column: $table.sentAt, builder: (column) => column); +} + +class $$MessagesTableTableManager + extends + RootTableManager< + _$ChatDatabase, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, BaseReferences<_$ChatDatabase, $MessagesTable, Message>), + Message, + PrefetchHooks Function() + > { + $$MessagesTableTableManager(_$ChatDatabase db, $MessagesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MessagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MessagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MessagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value accountScope = const Value.absent(), + Value peerPubkey = const Value.absent(), + Value fromPubkey = const Value.absent(), + Value body = const Value.absent(), + Value sentAt = const Value.absent(), + }) => MessagesCompanion( + id: id, + accountScope: accountScope, + peerPubkey: peerPubkey, + fromPubkey: fromPubkey, + body: body, + sentAt: sentAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String accountScope, + required String peerPubkey, + required String fromPubkey, + required String body, + required int sentAt, + }) => MessagesCompanion.insert( + id: id, + accountScope: accountScope, + peerPubkey: peerPubkey, + fromPubkey: fromPubkey, + body: body, + sentAt: sentAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$MessagesTableProcessedTableManager = + ProcessedTableManager< + _$ChatDatabase, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, BaseReferences<_$ChatDatabase, $MessagesTable, Message>), + Message, + PrefetchHooks Function() + >; + +class $ChatDatabaseManager { + final _$ChatDatabase _db; + $ChatDatabaseManager(this._db); + $$MessagesTableTableManager get messages => + $$MessagesTableTableManager(_db, _db.messages); +} diff --git a/apps/app_seeds/lib/db/database.dart b/apps/app_seeds/lib/db/database.dart index 41c3cf7..7119c79 100644 --- a/apps/app_seeds/lib/db/database.dart +++ b/apps/app_seeds/lib/db/database.dart @@ -22,6 +22,8 @@ part 'database.g.dart'; Parties, Attachments, ExternalLinks, + Plantares, + Sales, ], ) class AppDatabase extends _$AppDatabase { @@ -29,7 +31,7 @@ class AppDatabase extends _$AppDatabase { /// Current schema version; also stamped into interchange exports so an /// importer knows which app generation wrote the file (data-model §7). - static const int currentSchemaVersion = 8; + static const int currentSchemaVersion = 12; @override int get schemaVersion => currentSchemaVersion; @@ -138,6 +140,53 @@ class AppDatabase extends _$AppDatabase { await m.createTable(conditionChecks); } } + // v9: Plantares — reproduction commitments (data-model §2.7). Guarded so a + // half-migrated dev database re-runs cleanly (see the v7 note above). + if (from < 9) { + if (!await _hasTable('plantares')) { + await m.createTable(plantares); + } + } + // v10: Sales — recorded seed sales (separate from gift/Plantare). Guarded. + if (from < 10) { + if (!await _hasTable('sales')) { + await m.createTable(sales); + } + } + // v11: asking price on Lots — published with "sell" market offers. + // Guarded (see the v7 note above). + if (from < 11) { + if (!await _hasColumn('lots', 'price_amount')) { + await m.addColumn(lots, lots.priceAmount); + } + if (!await _hasColumn('lots', 'price_currency')) { + await m.addColumn(lots, lots.priceCurrency); + } + } + // v12: the bilateral SIGNED Plantare (plantare-bilateral.md) — keys, both + // signatures, the shared pledge id/Movement, the handshake state, and the + // structured return option. All nullable/defaulted so v1 local rows are + // untouched. Guarded (see the v7 note above). + if (from < 12) { + Future addIfMissing( + String column, + GeneratedColumn definition, + ) async { + if (!await _hasColumn('plantares', column)) { + await m.addColumn(plantares, definition); + } + } + + await addIfMissing('pledge_id', plantares.pledgeId); + await addIfMissing('debtor_key', plantares.debtorKey); + await addIfMissing('creditor_key', plantares.creditorKey); + await addIfMissing('debtor_signature', plantares.debtorSignature); + await addIfMissing('creditor_signature', plantares.creditorSignature); + await addIfMissing('movement_id', plantares.movementId); + await addIfMissing('remote_state', plantares.remoteState); + await addIfMissing('return_kind', plantares.returnKind); + await addIfMissing('work_hours', plantares.workHours); + } }, ); diff --git a/apps/app_seeds/lib/db/database.g.dart b/apps/app_seeds/lib/db/database.g.dart index 3b14863..befae72 100644 --- a/apps/app_seeds/lib/db/database.g.dart +++ b/apps/app_seeds/lib/db/database.g.dart @@ -3266,6 +3266,28 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> { ).withConverter( $LotsTable.$converterpreservationFormatn, ); + static const VerificationMeta _priceAmountMeta = const VerificationMeta( + 'priceAmount', + ); + @override + late final GeneratedColumn priceAmount = GeneratedColumn( + 'price_amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _priceCurrencyMeta = const VerificationMeta( + 'priceCurrency', + ); + @override + late final GeneratedColumn priceCurrency = GeneratedColumn( + 'price_currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -3289,6 +3311,8 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> { originPlace, abundance, preservationFormat, + priceAmount, + priceCurrency, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -3429,6 +3453,24 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> { ), ); } + if (data.containsKey('price_amount')) { + context.handle( + _priceAmountMeta, + priceAmount.isAcceptableOrUnknown( + data['price_amount']!, + _priceAmountMeta, + ), + ); + } + if (data.containsKey('price_currency')) { + context.handle( + _priceCurrencyMeta, + priceCurrency.isAcceptableOrUnknown( + data['price_currency']!, + _priceCurrencyMeta, + ), + ); + } return context; } @@ -3532,6 +3574,14 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> { data['${effectivePrefix}preservation_format'], ), ), + priceAmount: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}price_amount'], + ), + priceCurrency: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}price_currency'], + ), ); } @@ -3600,6 +3650,17 @@ class Lot extends DataClass implements Insertable { /// How the (seed) lot is physically conserved — see [PreservationFormat]. /// Distinct from [storageLocation]. Optional. final PreservationFormat? preservationFormat; + + /// Asking price when [offerStatus] is `sell` (data-model §2.8 puts price on + /// the Offer; with offer state collapsed onto the Lot, it lives here and is + /// published with the market offer). Informational only — money changes + /// hands off-platform, never in-app. Null amount on a sell lot means + /// "price to be agreed" (the offer publishes without a price). + final double? priceAmount; + + /// Free-text currency, like [Sales.currency]: "€", "Ğ1", a local/time + /// currency. Never assumed (sharing-model §6). + final String? priceCurrency; const Lot({ required this.id, required this.createdAt, @@ -3622,6 +3683,8 @@ class Lot extends DataClass implements Insertable { this.originPlace, this.abundance, this.preservationFormat, + this.priceAmount, + this.priceCurrency, }); @override Map toColumns(bool nullToAbsent) { @@ -3683,6 +3746,12 @@ class Lot extends DataClass implements Insertable { $LotsTable.$converterpreservationFormatn.toSql(preservationFormat), ); } + if (!nullToAbsent || priceAmount != null) { + map['price_amount'] = Variable(priceAmount); + } + if (!nullToAbsent || priceCurrency != null) { + map['price_currency'] = Variable(priceCurrency); + } return map; } @@ -3733,6 +3802,12 @@ class Lot extends DataClass implements Insertable { preservationFormat: preservationFormat == null && nullToAbsent ? const Value.absent() : Value(preservationFormat), + priceAmount: priceAmount == null && nullToAbsent + ? const Value.absent() + : Value(priceAmount), + priceCurrency: priceCurrency == null && nullToAbsent + ? const Value.absent() + : Value(priceCurrency), ); } @@ -3773,6 +3848,8 @@ class Lot extends DataClass implements Insertable { preservationFormat: $LotsTable.$converterpreservationFormatn.fromJson( serializer.fromJson(json['preservationFormat']), ), + priceAmount: serializer.fromJson(json['priceAmount']), + priceCurrency: serializer.fromJson(json['priceCurrency']), ); } @override @@ -3808,6 +3885,8 @@ class Lot extends DataClass implements Insertable { 'preservationFormat': serializer.toJson( $LotsTable.$converterpreservationFormatn.toJson(preservationFormat), ), + 'priceAmount': serializer.toJson(priceAmount), + 'priceCurrency': serializer.toJson(priceCurrency), }; } @@ -3833,6 +3912,8 @@ class Lot extends DataClass implements Insertable { Value originPlace = const Value.absent(), Value abundance = const Value.absent(), Value preservationFormat = const Value.absent(), + Value priceAmount = const Value.absent(), + Value priceCurrency = const Value.absent(), }) => Lot( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -3863,6 +3944,10 @@ class Lot extends DataClass implements Insertable { preservationFormat: preservationFormat.present ? preservationFormat.value : this.preservationFormat, + priceAmount: priceAmount.present ? priceAmount.value : this.priceAmount, + priceCurrency: priceCurrency.present + ? priceCurrency.value + : this.priceCurrency, ); Lot copyWithCompanion(LotsCompanion data) { return Lot( @@ -3915,6 +4000,12 @@ class Lot extends DataClass implements Insertable { preservationFormat: data.preservationFormat.present ? data.preservationFormat.value : this.preservationFormat, + priceAmount: data.priceAmount.present + ? data.priceAmount.value + : this.priceAmount, + priceCurrency: data.priceCurrency.present + ? data.priceCurrency.value + : this.priceCurrency, ); } @@ -3941,7 +4032,9 @@ class Lot extends DataClass implements Insertable { ..write('originName: $originName, ') ..write('originPlace: $originPlace, ') ..write('abundance: $abundance, ') - ..write('preservationFormat: $preservationFormat') + ..write('preservationFormat: $preservationFormat, ') + ..write('priceAmount: $priceAmount, ') + ..write('priceCurrency: $priceCurrency') ..write(')')) .toString(); } @@ -3969,6 +4062,8 @@ class Lot extends DataClass implements Insertable { originPlace, abundance, preservationFormat, + priceAmount, + priceCurrency, ]); @override bool operator ==(Object other) => @@ -3994,7 +4089,9 @@ class Lot extends DataClass implements Insertable { other.originName == this.originName && other.originPlace == this.originPlace && other.abundance == this.abundance && - other.preservationFormat == this.preservationFormat); + other.preservationFormat == this.preservationFormat && + other.priceAmount == this.priceAmount && + other.priceCurrency == this.priceCurrency); } class LotsCompanion extends UpdateCompanion { @@ -4019,6 +4116,8 @@ class LotsCompanion extends UpdateCompanion { final Value originPlace; final Value abundance; final Value preservationFormat; + final Value priceAmount; + final Value priceCurrency; final Value rowid; const LotsCompanion({ this.id = const Value.absent(), @@ -4042,6 +4141,8 @@ class LotsCompanion extends UpdateCompanion { this.originPlace = const Value.absent(), this.abundance = const Value.absent(), this.preservationFormat = const Value.absent(), + this.priceAmount = const Value.absent(), + this.priceCurrency = const Value.absent(), this.rowid = const Value.absent(), }); LotsCompanion.insert({ @@ -4066,6 +4167,8 @@ class LotsCompanion extends UpdateCompanion { this.originPlace = const Value.absent(), this.abundance = const Value.absent(), this.preservationFormat = const Value.absent(), + this.priceAmount = const Value.absent(), + this.priceCurrency = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -4094,6 +4197,8 @@ class LotsCompanion extends UpdateCompanion { Expression? originPlace, Expression? abundance, Expression? preservationFormat, + Expression? priceAmount, + Expression? priceCurrency, Expression? rowid, }) { return RawValuesInsertable({ @@ -4118,6 +4223,8 @@ class LotsCompanion extends UpdateCompanion { if (originPlace != null) 'origin_place': originPlace, if (abundance != null) 'abundance': abundance, if (preservationFormat != null) 'preservation_format': preservationFormat, + if (priceAmount != null) 'price_amount': priceAmount, + if (priceCurrency != null) 'price_currency': priceCurrency, if (rowid != null) 'rowid': rowid, }); } @@ -4144,6 +4251,8 @@ class LotsCompanion extends UpdateCompanion { Value? originPlace, Value? abundance, Value? preservationFormat, + Value? priceAmount, + Value? priceCurrency, Value? rowid, }) { return LotsCompanion( @@ -4168,6 +4277,8 @@ class LotsCompanion extends UpdateCompanion { originPlace: originPlace ?? this.originPlace, abundance: abundance ?? this.abundance, preservationFormat: preservationFormat ?? this.preservationFormat, + priceAmount: priceAmount ?? this.priceAmount, + priceCurrency: priceCurrency ?? this.priceCurrency, rowid: rowid ?? this.rowid, ); } @@ -4250,6 +4361,12 @@ class LotsCompanion extends UpdateCompanion { ), ); } + if (priceAmount.present) { + map['price_amount'] = Variable(priceAmount.value); + } + if (priceCurrency.present) { + map['price_currency'] = Variable(priceCurrency.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -4280,6 +4397,8 @@ class LotsCompanion extends UpdateCompanion { ..write('originPlace: $originPlace, ') ..write('abundance: $abundance, ') ..write('preservationFormat: $preservationFormat, ') + ..write('priceAmount: $priceAmount, ') + ..write('priceCurrency: $priceCurrency, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -8503,6 +8622,2178 @@ class ExternalLinksCompanion extends UpdateCompanion { } } +class $PlantaresTable extends Plantares + with TableInfo<$PlantaresTable, Plantare> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PlantaresTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastAuthorMeta = const VerificationMeta( + 'lastAuthor', + ); + @override + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _isDeletedMeta = const VerificationMeta( + 'isDeleted', + ); + @override + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_deleted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _schemaRowVersionMeta = const VerificationMeta( + 'schemaRowVersion', + ); + @override + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _varietyIdMeta = const VerificationMeta( + 'varietyId', + ); + @override + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter + direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($PlantaresTable.$converterdirection); + static const VerificationMeta _counterpartyMeta = const VerificationMeta( + 'counterparty', + ); + @override + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _owedDescriptionMeta = const VerificationMeta( + 'owedDescription', + ); + @override + late final GeneratedColumn owedDescription = GeneratedColumn( + 'owed_description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _madeOnMeta = const VerificationMeta('madeOn'); + @override + late final GeneratedColumn madeOn = GeneratedColumn( + 'made_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _dueByMeta = const VerificationMeta('dueBy'); + @override + late final GeneratedColumn dueBy = GeneratedColumn( + 'due_by', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter status = + GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('open'), + ).withConverter($PlantaresTable.$converterstatus); + static const VerificationMeta _settledOnMeta = const VerificationMeta( + 'settledOn', + ); + @override + late final GeneratedColumn settledOn = GeneratedColumn( + 'settled_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _noteMeta = const VerificationMeta('note'); + @override + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _pledgeIdMeta = const VerificationMeta( + 'pledgeId', + ); + @override + late final GeneratedColumn pledgeId = GeneratedColumn( + 'pledge_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _debtorKeyMeta = const VerificationMeta( + 'debtorKey', + ); + @override + late final GeneratedColumn debtorKey = GeneratedColumn( + 'debtor_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _creditorKeyMeta = const VerificationMeta( + 'creditorKey', + ); + @override + late final GeneratedColumn creditorKey = GeneratedColumn( + 'creditor_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _debtorSignatureMeta = const VerificationMeta( + 'debtorSignature', + ); + @override + late final GeneratedColumn debtorSignature = GeneratedColumn( + 'debtor_signature', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _creditorSignatureMeta = const VerificationMeta( + 'creditorSignature', + ); + @override + late final GeneratedColumn creditorSignature = + GeneratedColumn( + 'creditor_signature', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _movementIdMeta = const VerificationMeta( + 'movementId', + ); + @override + late final GeneratedColumn movementId = GeneratedColumn( + 'movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter + remoteState = GeneratedColumn( + 'remote_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter($PlantaresTable.$converterremoteStaten); + @override + late final GeneratedColumnWithTypeConverter + returnKind = GeneratedColumn( + 'return_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('similar'), + ).withConverter($PlantaresTable.$converterreturnKind); + static const VerificationMeta _workHoursMeta = const VerificationMeta( + 'workHours', + ); + @override + late final GeneratedColumn workHours = GeneratedColumn( + 'work_hours', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + pledgeId, + debtorKey, + creditorKey, + debtorSignature, + creditorSignature, + movementId, + remoteState, + returnKind, + workHours, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plantares'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('last_author')) { + context.handle( + _lastAuthorMeta, + lastAuthor.isAcceptableOrUnknown(data['last_author']!, _lastAuthorMeta), + ); + } else if (isInserting) { + context.missing(_lastAuthorMeta); + } + if (data.containsKey('is_deleted')) { + context.handle( + _isDeletedMeta, + isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta), + ); + } + if (data.containsKey('schema_row_version')) { + context.handle( + _schemaRowVersionMeta, + schemaRowVersion.isAcceptableOrUnknown( + data['schema_row_version']!, + _schemaRowVersionMeta, + ), + ); + } + if (data.containsKey('variety_id')) { + context.handle( + _varietyIdMeta, + varietyId.isAcceptableOrUnknown(data['variety_id']!, _varietyIdMeta), + ); + } + if (data.containsKey('counterparty')) { + context.handle( + _counterpartyMeta, + counterparty.isAcceptableOrUnknown( + data['counterparty']!, + _counterpartyMeta, + ), + ); + } + if (data.containsKey('owed_description')) { + context.handle( + _owedDescriptionMeta, + owedDescription.isAcceptableOrUnknown( + data['owed_description']!, + _owedDescriptionMeta, + ), + ); + } + if (data.containsKey('made_on')) { + context.handle( + _madeOnMeta, + madeOn.isAcceptableOrUnknown(data['made_on']!, _madeOnMeta), + ); + } else if (isInserting) { + context.missing(_madeOnMeta); + } + if (data.containsKey('due_by')) { + context.handle( + _dueByMeta, + dueBy.isAcceptableOrUnknown(data['due_by']!, _dueByMeta), + ); + } + if (data.containsKey('settled_on')) { + context.handle( + _settledOnMeta, + settledOn.isAcceptableOrUnknown(data['settled_on']!, _settledOnMeta), + ); + } + if (data.containsKey('note')) { + context.handle( + _noteMeta, + note.isAcceptableOrUnknown(data['note']!, _noteMeta), + ); + } + if (data.containsKey('pledge_id')) { + context.handle( + _pledgeIdMeta, + pledgeId.isAcceptableOrUnknown(data['pledge_id']!, _pledgeIdMeta), + ); + } + if (data.containsKey('debtor_key')) { + context.handle( + _debtorKeyMeta, + debtorKey.isAcceptableOrUnknown(data['debtor_key']!, _debtorKeyMeta), + ); + } + if (data.containsKey('creditor_key')) { + context.handle( + _creditorKeyMeta, + creditorKey.isAcceptableOrUnknown( + data['creditor_key']!, + _creditorKeyMeta, + ), + ); + } + if (data.containsKey('debtor_signature')) { + context.handle( + _debtorSignatureMeta, + debtorSignature.isAcceptableOrUnknown( + data['debtor_signature']!, + _debtorSignatureMeta, + ), + ); + } + if (data.containsKey('creditor_signature')) { + context.handle( + _creditorSignatureMeta, + creditorSignature.isAcceptableOrUnknown( + data['creditor_signature']!, + _creditorSignatureMeta, + ), + ); + } + if (data.containsKey('movement_id')) { + context.handle( + _movementIdMeta, + movementId.isAcceptableOrUnknown(data['movement_id']!, _movementIdMeta), + ); + } + if (data.containsKey('work_hours')) { + context.handle( + _workHoursMeta, + workHours.isAcceptableOrUnknown(data['work_hours']!, _workHoursMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Plantare map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Plantare( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}updated_at'], + )!, + lastAuthor: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_author'], + )!, + isDeleted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_deleted'], + )!, + schemaRowVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}schema_row_version'], + )!, + varietyId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}variety_id'], + ), + direction: $PlantaresTable.$converterdirection.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}direction'], + )!, + ), + counterparty: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}counterparty'], + ), + owedDescription: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owed_description'], + ), + madeOn: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}made_on'], + )!, + dueBy: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}due_by'], + ), + status: $PlantaresTable.$converterstatus.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + ), + settledOn: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}settled_on'], + ), + note: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note'], + ), + pledgeId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}pledge_id'], + ), + debtorKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}debtor_key'], + ), + creditorKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}creditor_key'], + ), + debtorSignature: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}debtor_signature'], + ), + creditorSignature: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}creditor_signature'], + ), + movementId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}movement_id'], + ), + remoteState: $PlantaresTable.$converterremoteStaten.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}remote_state'], + ), + ), + returnKind: $PlantaresTable.$converterreturnKind.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}return_kind'], + )!, + ), + workHours: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}work_hours'], + ), + ); + } + + @override + $PlantaresTable createAlias(String alias) { + return $PlantaresTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $converterdirection = const EnumNameConverter( + PlantareDirection.values, + ); + static JsonTypeConverter2 $converterstatus = + const EnumNameConverter(PlantareStatus.values); + static JsonTypeConverter2 + $converterremoteState = const EnumNameConverter( + PlantareRemoteState.values, + ); + static JsonTypeConverter2 + $converterremoteStaten = JsonTypeConverter2.asNullable($converterremoteState); + static JsonTypeConverter2 + $converterreturnKind = const EnumNameConverter( + PlantareReturnKind.values, + ); +} + +class Plantare extends DataClass implements Insertable { + final String id; + final int createdAt; + final String updatedAt; + final String lastAuthor; + final bool isDeleted; + final int schemaRowVersion; + + /// The seed the commitment is about — what gets reproduced. Nullable so a + /// commitment can be jotted before it's linked to a catalogued variety. + final String? varietyId; + + /// Whose promise it is (I return / owed to me). + final PlantareDirection direction; + + /// The other party as a human name (v1). A key/Party link arrives with the + /// signed cross-party form. + final String? counterparty; + + /// What's promised back, in the grower's own words + /// ("un puñado la próxima temporada"). + final String? owedDescription; + + /// When the promise was made (ms since epoch). + final int madeOn; + + /// Optional return-by date (ms since epoch) — a gentle reminder, not a deadline. + final int? dueBy; + final PlantareStatus status; + + /// When it was returned or forgiven (ms since epoch). + final int? settledOn; + final String? note; + + /// The shared pledge id both parties agree on (the proposer's id). Distinct + /// from [id] (each side keeps its own local row). Null for a v1 local note. + final String? pledgeId; + + /// Pubkey (hex) of who received the seed and owes a return. + final String? debtorKey; + + /// Pubkey (hex) of who gave the seed. + final String? creditorKey; + + /// The debtor's Schnorr stub over the canonical pledge core. + final String? debtorSignature; + + /// The creditor's Schnorr stub over the canonical pledge core. + final String? creditorSignature; + + /// The shared hand-over `Movement` this Plantare accompanies (provenance DAG). + final String? movementId; + + /// The handshake state (proposed/accepted/declined), null for a v1 local row. + final PlantareRemoteState? remoteState; + + /// How it's promised back (default `similar`: open-pollinated · non-GMO · + /// organically grown), mirroring the paper form's checkboxes. + final PlantareReturnKind returnKind; + + /// Hours for the `workHours` return option (time-as-currency). Null otherwise. + final double? workHours; + const Plantare({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.lastAuthor, + required this.isDeleted, + required this.schemaRowVersion, + this.varietyId, + required this.direction, + this.counterparty, + this.owedDescription, + required this.madeOn, + this.dueBy, + required this.status, + this.settledOn, + this.note, + this.pledgeId, + this.debtorKey, + this.creditorKey, + this.debtorSignature, + this.creditorSignature, + this.movementId, + this.remoteState, + required this.returnKind, + this.workHours, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['last_author'] = Variable(lastAuthor); + map['is_deleted'] = Variable(isDeleted); + map['schema_row_version'] = Variable(schemaRowVersion); + if (!nullToAbsent || varietyId != null) { + map['variety_id'] = Variable(varietyId); + } + { + map['direction'] = Variable( + $PlantaresTable.$converterdirection.toSql(direction), + ); + } + if (!nullToAbsent || counterparty != null) { + map['counterparty'] = Variable(counterparty); + } + if (!nullToAbsent || owedDescription != null) { + map['owed_description'] = Variable(owedDescription); + } + map['made_on'] = Variable(madeOn); + if (!nullToAbsent || dueBy != null) { + map['due_by'] = Variable(dueBy); + } + { + map['status'] = Variable( + $PlantaresTable.$converterstatus.toSql(status), + ); + } + if (!nullToAbsent || settledOn != null) { + map['settled_on'] = Variable(settledOn); + } + if (!nullToAbsent || note != null) { + map['note'] = Variable(note); + } + if (!nullToAbsent || pledgeId != null) { + map['pledge_id'] = Variable(pledgeId); + } + if (!nullToAbsent || debtorKey != null) { + map['debtor_key'] = Variable(debtorKey); + } + if (!nullToAbsent || creditorKey != null) { + map['creditor_key'] = Variable(creditorKey); + } + if (!nullToAbsent || debtorSignature != null) { + map['debtor_signature'] = Variable(debtorSignature); + } + if (!nullToAbsent || creditorSignature != null) { + map['creditor_signature'] = Variable(creditorSignature); + } + if (!nullToAbsent || movementId != null) { + map['movement_id'] = Variable(movementId); + } + if (!nullToAbsent || remoteState != null) { + map['remote_state'] = Variable( + $PlantaresTable.$converterremoteStaten.toSql(remoteState), + ); + } + { + map['return_kind'] = Variable( + $PlantaresTable.$converterreturnKind.toSql(returnKind), + ); + } + if (!nullToAbsent || workHours != null) { + map['work_hours'] = Variable(workHours); + } + return map; + } + + PlantaresCompanion toCompanion(bool nullToAbsent) { + return PlantaresCompanion( + id: Value(id), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + lastAuthor: Value(lastAuthor), + isDeleted: Value(isDeleted), + schemaRowVersion: Value(schemaRowVersion), + varietyId: varietyId == null && nullToAbsent + ? const Value.absent() + : Value(varietyId), + direction: Value(direction), + counterparty: counterparty == null && nullToAbsent + ? const Value.absent() + : Value(counterparty), + owedDescription: owedDescription == null && nullToAbsent + ? const Value.absent() + : Value(owedDescription), + madeOn: Value(madeOn), + dueBy: dueBy == null && nullToAbsent + ? const Value.absent() + : Value(dueBy), + status: Value(status), + settledOn: settledOn == null && nullToAbsent + ? const Value.absent() + : Value(settledOn), + note: note == null && nullToAbsent ? const Value.absent() : Value(note), + pledgeId: pledgeId == null && nullToAbsent + ? const Value.absent() + : Value(pledgeId), + debtorKey: debtorKey == null && nullToAbsent + ? const Value.absent() + : Value(debtorKey), + creditorKey: creditorKey == null && nullToAbsent + ? const Value.absent() + : Value(creditorKey), + debtorSignature: debtorSignature == null && nullToAbsent + ? const Value.absent() + : Value(debtorSignature), + creditorSignature: creditorSignature == null && nullToAbsent + ? const Value.absent() + : Value(creditorSignature), + movementId: movementId == null && nullToAbsent + ? const Value.absent() + : Value(movementId), + remoteState: remoteState == null && nullToAbsent + ? const Value.absent() + : Value(remoteState), + returnKind: Value(returnKind), + workHours: workHours == null && nullToAbsent + ? const Value.absent() + : Value(workHours), + ); + } + + factory Plantare.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Plantare( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + lastAuthor: serializer.fromJson(json['lastAuthor']), + isDeleted: serializer.fromJson(json['isDeleted']), + schemaRowVersion: serializer.fromJson(json['schemaRowVersion']), + varietyId: serializer.fromJson(json['varietyId']), + direction: $PlantaresTable.$converterdirection.fromJson( + serializer.fromJson(json['direction']), + ), + counterparty: serializer.fromJson(json['counterparty']), + owedDescription: serializer.fromJson(json['owedDescription']), + madeOn: serializer.fromJson(json['madeOn']), + dueBy: serializer.fromJson(json['dueBy']), + status: $PlantaresTable.$converterstatus.fromJson( + serializer.fromJson(json['status']), + ), + settledOn: serializer.fromJson(json['settledOn']), + note: serializer.fromJson(json['note']), + pledgeId: serializer.fromJson(json['pledgeId']), + debtorKey: serializer.fromJson(json['debtorKey']), + creditorKey: serializer.fromJson(json['creditorKey']), + debtorSignature: serializer.fromJson(json['debtorSignature']), + creditorSignature: serializer.fromJson( + json['creditorSignature'], + ), + movementId: serializer.fromJson(json['movementId']), + remoteState: $PlantaresTable.$converterremoteStaten.fromJson( + serializer.fromJson(json['remoteState']), + ), + returnKind: $PlantaresTable.$converterreturnKind.fromJson( + serializer.fromJson(json['returnKind']), + ), + workHours: serializer.fromJson(json['workHours']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'lastAuthor': serializer.toJson(lastAuthor), + 'isDeleted': serializer.toJson(isDeleted), + 'schemaRowVersion': serializer.toJson(schemaRowVersion), + 'varietyId': serializer.toJson(varietyId), + 'direction': serializer.toJson( + $PlantaresTable.$converterdirection.toJson(direction), + ), + 'counterparty': serializer.toJson(counterparty), + 'owedDescription': serializer.toJson(owedDescription), + 'madeOn': serializer.toJson(madeOn), + 'dueBy': serializer.toJson(dueBy), + 'status': serializer.toJson( + $PlantaresTable.$converterstatus.toJson(status), + ), + 'settledOn': serializer.toJson(settledOn), + 'note': serializer.toJson(note), + 'pledgeId': serializer.toJson(pledgeId), + 'debtorKey': serializer.toJson(debtorKey), + 'creditorKey': serializer.toJson(creditorKey), + 'debtorSignature': serializer.toJson(debtorSignature), + 'creditorSignature': serializer.toJson(creditorSignature), + 'movementId': serializer.toJson(movementId), + 'remoteState': serializer.toJson( + $PlantaresTable.$converterremoteStaten.toJson(remoteState), + ), + 'returnKind': serializer.toJson( + $PlantaresTable.$converterreturnKind.toJson(returnKind), + ), + 'workHours': serializer.toJson(workHours), + }; + } + + Plantare copyWith({ + String? id, + int? createdAt, + String? updatedAt, + String? lastAuthor, + bool? isDeleted, + int? schemaRowVersion, + Value varietyId = const Value.absent(), + PlantareDirection? direction, + Value counterparty = const Value.absent(), + Value owedDescription = const Value.absent(), + int? madeOn, + Value dueBy = const Value.absent(), + PlantareStatus? status, + Value settledOn = const Value.absent(), + Value note = const Value.absent(), + Value pledgeId = const Value.absent(), + Value debtorKey = const Value.absent(), + Value creditorKey = const Value.absent(), + Value debtorSignature = const Value.absent(), + Value creditorSignature = const Value.absent(), + Value movementId = const Value.absent(), + Value remoteState = const Value.absent(), + PlantareReturnKind? returnKind, + Value workHours = const Value.absent(), + }) => Plantare( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastAuthor: lastAuthor ?? this.lastAuthor, + isDeleted: isDeleted ?? this.isDeleted, + schemaRowVersion: schemaRowVersion ?? this.schemaRowVersion, + varietyId: varietyId.present ? varietyId.value : this.varietyId, + direction: direction ?? this.direction, + counterparty: counterparty.present ? counterparty.value : this.counterparty, + owedDescription: owedDescription.present + ? owedDescription.value + : this.owedDescription, + madeOn: madeOn ?? this.madeOn, + dueBy: dueBy.present ? dueBy.value : this.dueBy, + status: status ?? this.status, + settledOn: settledOn.present ? settledOn.value : this.settledOn, + note: note.present ? note.value : this.note, + pledgeId: pledgeId.present ? pledgeId.value : this.pledgeId, + debtorKey: debtorKey.present ? debtorKey.value : this.debtorKey, + creditorKey: creditorKey.present ? creditorKey.value : this.creditorKey, + debtorSignature: debtorSignature.present + ? debtorSignature.value + : this.debtorSignature, + creditorSignature: creditorSignature.present + ? creditorSignature.value + : this.creditorSignature, + movementId: movementId.present ? movementId.value : this.movementId, + remoteState: remoteState.present ? remoteState.value : this.remoteState, + returnKind: returnKind ?? this.returnKind, + workHours: workHours.present ? workHours.value : this.workHours, + ); + Plantare copyWithCompanion(PlantaresCompanion data) { + return Plantare( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + lastAuthor: data.lastAuthor.present + ? data.lastAuthor.value + : this.lastAuthor, + isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, + schemaRowVersion: data.schemaRowVersion.present + ? data.schemaRowVersion.value + : this.schemaRowVersion, + varietyId: data.varietyId.present ? data.varietyId.value : this.varietyId, + direction: data.direction.present ? data.direction.value : this.direction, + counterparty: data.counterparty.present + ? data.counterparty.value + : this.counterparty, + owedDescription: data.owedDescription.present + ? data.owedDescription.value + : this.owedDescription, + madeOn: data.madeOn.present ? data.madeOn.value : this.madeOn, + dueBy: data.dueBy.present ? data.dueBy.value : this.dueBy, + status: data.status.present ? data.status.value : this.status, + settledOn: data.settledOn.present ? data.settledOn.value : this.settledOn, + note: data.note.present ? data.note.value : this.note, + pledgeId: data.pledgeId.present ? data.pledgeId.value : this.pledgeId, + debtorKey: data.debtorKey.present ? data.debtorKey.value : this.debtorKey, + creditorKey: data.creditorKey.present + ? data.creditorKey.value + : this.creditorKey, + debtorSignature: data.debtorSignature.present + ? data.debtorSignature.value + : this.debtorSignature, + creditorSignature: data.creditorSignature.present + ? data.creditorSignature.value + : this.creditorSignature, + movementId: data.movementId.present + ? data.movementId.value + : this.movementId, + remoteState: data.remoteState.present + ? data.remoteState.value + : this.remoteState, + returnKind: data.returnKind.present + ? data.returnKind.value + : this.returnKind, + workHours: data.workHours.present ? data.workHours.value : this.workHours, + ); + } + + @override + String toString() { + return (StringBuffer('Plantare(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastAuthor: $lastAuthor, ') + ..write('isDeleted: $isDeleted, ') + ..write('schemaRowVersion: $schemaRowVersion, ') + ..write('varietyId: $varietyId, ') + ..write('direction: $direction, ') + ..write('counterparty: $counterparty, ') + ..write('owedDescription: $owedDescription, ') + ..write('madeOn: $madeOn, ') + ..write('dueBy: $dueBy, ') + ..write('status: $status, ') + ..write('settledOn: $settledOn, ') + ..write('note: $note, ') + ..write('pledgeId: $pledgeId, ') + ..write('debtorKey: $debtorKey, ') + ..write('creditorKey: $creditorKey, ') + ..write('debtorSignature: $debtorSignature, ') + ..write('creditorSignature: $creditorSignature, ') + ..write('movementId: $movementId, ') + ..write('remoteState: $remoteState, ') + ..write('returnKind: $returnKind, ') + ..write('workHours: $workHours') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + pledgeId, + debtorKey, + creditorKey, + debtorSignature, + creditorSignature, + movementId, + remoteState, + returnKind, + workHours, + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Plantare && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.lastAuthor == this.lastAuthor && + other.isDeleted == this.isDeleted && + other.schemaRowVersion == this.schemaRowVersion && + other.varietyId == this.varietyId && + other.direction == this.direction && + other.counterparty == this.counterparty && + other.owedDescription == this.owedDescription && + other.madeOn == this.madeOn && + other.dueBy == this.dueBy && + other.status == this.status && + other.settledOn == this.settledOn && + other.note == this.note && + other.pledgeId == this.pledgeId && + other.debtorKey == this.debtorKey && + other.creditorKey == this.creditorKey && + other.debtorSignature == this.debtorSignature && + other.creditorSignature == this.creditorSignature && + other.movementId == this.movementId && + other.remoteState == this.remoteState && + other.returnKind == this.returnKind && + other.workHours == this.workHours); +} + +class PlantaresCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value lastAuthor; + final Value isDeleted; + final Value schemaRowVersion; + final Value varietyId; + final Value direction; + final Value counterparty; + final Value owedDescription; + final Value madeOn; + final Value dueBy; + final Value status; + final Value settledOn; + final Value note; + final Value pledgeId; + final Value debtorKey; + final Value creditorKey; + final Value debtorSignature; + final Value creditorSignature; + final Value movementId; + final Value remoteState; + final Value returnKind; + final Value workHours; + final Value rowid; + const PlantaresCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.lastAuthor = const Value.absent(), + this.isDeleted = const Value.absent(), + this.schemaRowVersion = const Value.absent(), + this.varietyId = const Value.absent(), + this.direction = const Value.absent(), + this.counterparty = const Value.absent(), + this.owedDescription = const Value.absent(), + this.madeOn = const Value.absent(), + this.dueBy = const Value.absent(), + this.status = const Value.absent(), + this.settledOn = const Value.absent(), + this.note = const Value.absent(), + this.pledgeId = const Value.absent(), + this.debtorKey = const Value.absent(), + this.creditorKey = const Value.absent(), + this.debtorSignature = const Value.absent(), + this.creditorSignature = const Value.absent(), + this.movementId = const Value.absent(), + this.remoteState = const Value.absent(), + this.returnKind = const Value.absent(), + this.workHours = const Value.absent(), + this.rowid = const Value.absent(), + }); + PlantaresCompanion.insert({ + required String id, + required int createdAt, + required String updatedAt, + required String lastAuthor, + this.isDeleted = const Value.absent(), + this.schemaRowVersion = const Value.absent(), + this.varietyId = const Value.absent(), + required PlantareDirection direction, + this.counterparty = const Value.absent(), + this.owedDescription = const Value.absent(), + required int madeOn, + this.dueBy = const Value.absent(), + this.status = const Value.absent(), + this.settledOn = const Value.absent(), + this.note = const Value.absent(), + this.pledgeId = const Value.absent(), + this.debtorKey = const Value.absent(), + this.creditorKey = const Value.absent(), + this.debtorSignature = const Value.absent(), + this.creditorSignature = const Value.absent(), + this.movementId = const Value.absent(), + this.remoteState = const Value.absent(), + this.returnKind = const Value.absent(), + this.workHours = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt), + lastAuthor = Value(lastAuthor), + direction = Value(direction), + madeOn = Value(madeOn); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? lastAuthor, + Expression? isDeleted, + Expression? schemaRowVersion, + Expression? varietyId, + Expression? direction, + Expression? counterparty, + Expression? owedDescription, + Expression? madeOn, + Expression? dueBy, + Expression? status, + Expression? settledOn, + Expression? note, + Expression? pledgeId, + Expression? debtorKey, + Expression? creditorKey, + Expression? debtorSignature, + Expression? creditorSignature, + Expression? movementId, + Expression? remoteState, + Expression? returnKind, + Expression? workHours, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (lastAuthor != null) 'last_author': lastAuthor, + if (isDeleted != null) 'is_deleted': isDeleted, + if (schemaRowVersion != null) 'schema_row_version': schemaRowVersion, + if (varietyId != null) 'variety_id': varietyId, + if (direction != null) 'direction': direction, + if (counterparty != null) 'counterparty': counterparty, + if (owedDescription != null) 'owed_description': owedDescription, + if (madeOn != null) 'made_on': madeOn, + if (dueBy != null) 'due_by': dueBy, + if (status != null) 'status': status, + if (settledOn != null) 'settled_on': settledOn, + if (note != null) 'note': note, + if (pledgeId != null) 'pledge_id': pledgeId, + if (debtorKey != null) 'debtor_key': debtorKey, + if (creditorKey != null) 'creditor_key': creditorKey, + if (debtorSignature != null) 'debtor_signature': debtorSignature, + if (creditorSignature != null) 'creditor_signature': creditorSignature, + if (movementId != null) 'movement_id': movementId, + if (remoteState != null) 'remote_state': remoteState, + if (returnKind != null) 'return_kind': returnKind, + if (workHours != null) 'work_hours': workHours, + if (rowid != null) 'rowid': rowid, + }); + } + + PlantaresCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? lastAuthor, + Value? isDeleted, + Value? schemaRowVersion, + Value? varietyId, + Value? direction, + Value? counterparty, + Value? owedDescription, + Value? madeOn, + Value? dueBy, + Value? status, + Value? settledOn, + Value? note, + Value? pledgeId, + Value? debtorKey, + Value? creditorKey, + Value? debtorSignature, + Value? creditorSignature, + Value? movementId, + Value? remoteState, + Value? returnKind, + Value? workHours, + Value? rowid, + }) { + return PlantaresCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastAuthor: lastAuthor ?? this.lastAuthor, + isDeleted: isDeleted ?? this.isDeleted, + schemaRowVersion: schemaRowVersion ?? this.schemaRowVersion, + varietyId: varietyId ?? this.varietyId, + direction: direction ?? this.direction, + counterparty: counterparty ?? this.counterparty, + owedDescription: owedDescription ?? this.owedDescription, + madeOn: madeOn ?? this.madeOn, + dueBy: dueBy ?? this.dueBy, + status: status ?? this.status, + settledOn: settledOn ?? this.settledOn, + note: note ?? this.note, + pledgeId: pledgeId ?? this.pledgeId, + debtorKey: debtorKey ?? this.debtorKey, + creditorKey: creditorKey ?? this.creditorKey, + debtorSignature: debtorSignature ?? this.debtorSignature, + creditorSignature: creditorSignature ?? this.creditorSignature, + movementId: movementId ?? this.movementId, + remoteState: remoteState ?? this.remoteState, + returnKind: returnKind ?? this.returnKind, + workHours: workHours ?? this.workHours, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (lastAuthor.present) { + map['last_author'] = Variable(lastAuthor.value); + } + if (isDeleted.present) { + map['is_deleted'] = Variable(isDeleted.value); + } + if (schemaRowVersion.present) { + map['schema_row_version'] = Variable(schemaRowVersion.value); + } + if (varietyId.present) { + map['variety_id'] = Variable(varietyId.value); + } + if (direction.present) { + map['direction'] = Variable( + $PlantaresTable.$converterdirection.toSql(direction.value), + ); + } + if (counterparty.present) { + map['counterparty'] = Variable(counterparty.value); + } + if (owedDescription.present) { + map['owed_description'] = Variable(owedDescription.value); + } + if (madeOn.present) { + map['made_on'] = Variable(madeOn.value); + } + if (dueBy.present) { + map['due_by'] = Variable(dueBy.value); + } + if (status.present) { + map['status'] = Variable( + $PlantaresTable.$converterstatus.toSql(status.value), + ); + } + if (settledOn.present) { + map['settled_on'] = Variable(settledOn.value); + } + if (note.present) { + map['note'] = Variable(note.value); + } + if (pledgeId.present) { + map['pledge_id'] = Variable(pledgeId.value); + } + if (debtorKey.present) { + map['debtor_key'] = Variable(debtorKey.value); + } + if (creditorKey.present) { + map['creditor_key'] = Variable(creditorKey.value); + } + if (debtorSignature.present) { + map['debtor_signature'] = Variable(debtorSignature.value); + } + if (creditorSignature.present) { + map['creditor_signature'] = Variable(creditorSignature.value); + } + if (movementId.present) { + map['movement_id'] = Variable(movementId.value); + } + if (remoteState.present) { + map['remote_state'] = Variable( + $PlantaresTable.$converterremoteStaten.toSql(remoteState.value), + ); + } + if (returnKind.present) { + map['return_kind'] = Variable( + $PlantaresTable.$converterreturnKind.toSql(returnKind.value), + ); + } + if (workHours.present) { + map['work_hours'] = Variable(workHours.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PlantaresCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastAuthor: $lastAuthor, ') + ..write('isDeleted: $isDeleted, ') + ..write('schemaRowVersion: $schemaRowVersion, ') + ..write('varietyId: $varietyId, ') + ..write('direction: $direction, ') + ..write('counterparty: $counterparty, ') + ..write('owedDescription: $owedDescription, ') + ..write('madeOn: $madeOn, ') + ..write('dueBy: $dueBy, ') + ..write('status: $status, ') + ..write('settledOn: $settledOn, ') + ..write('note: $note, ') + ..write('pledgeId: $pledgeId, ') + ..write('debtorKey: $debtorKey, ') + ..write('creditorKey: $creditorKey, ') + ..write('debtorSignature: $debtorSignature, ') + ..write('creditorSignature: $creditorSignature, ') + ..write('movementId: $movementId, ') + ..write('remoteState: $remoteState, ') + ..write('returnKind: $returnKind, ') + ..write('workHours: $workHours, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $SalesTable extends Sales with TableInfo<$SalesTable, Sale> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SalesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastAuthorMeta = const VerificationMeta( + 'lastAuthor', + ); + @override + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _isDeletedMeta = const VerificationMeta( + 'isDeleted', + ); + @override + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_deleted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _schemaRowVersionMeta = const VerificationMeta( + 'schemaRowVersion', + ); + @override + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _varietyIdMeta = const VerificationMeta( + 'varietyId', + ); + @override + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter direction = + GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($SalesTable.$converterdirection); + static const VerificationMeta _counterpartyMeta = const VerificationMeta( + 'counterparty', + ); + @override + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _amountMeta = const VerificationMeta('amount'); + @override + late final GeneratedColumn amount = GeneratedColumn( + 'amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _currencyMeta = const VerificationMeta( + 'currency', + ); + @override + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _soldOnMeta = const VerificationMeta('soldOn'); + @override + late final GeneratedColumn soldOn = GeneratedColumn( + 'sold_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _noteMeta = const VerificationMeta('note'); + @override + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + amount, + currency, + soldOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sales'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('last_author')) { + context.handle( + _lastAuthorMeta, + lastAuthor.isAcceptableOrUnknown(data['last_author']!, _lastAuthorMeta), + ); + } else if (isInserting) { + context.missing(_lastAuthorMeta); + } + if (data.containsKey('is_deleted')) { + context.handle( + _isDeletedMeta, + isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta), + ); + } + if (data.containsKey('schema_row_version')) { + context.handle( + _schemaRowVersionMeta, + schemaRowVersion.isAcceptableOrUnknown( + data['schema_row_version']!, + _schemaRowVersionMeta, + ), + ); + } + if (data.containsKey('variety_id')) { + context.handle( + _varietyIdMeta, + varietyId.isAcceptableOrUnknown(data['variety_id']!, _varietyIdMeta), + ); + } + if (data.containsKey('counterparty')) { + context.handle( + _counterpartyMeta, + counterparty.isAcceptableOrUnknown( + data['counterparty']!, + _counterpartyMeta, + ), + ); + } + if (data.containsKey('amount')) { + context.handle( + _amountMeta, + amount.isAcceptableOrUnknown(data['amount']!, _amountMeta), + ); + } + if (data.containsKey('currency')) { + context.handle( + _currencyMeta, + currency.isAcceptableOrUnknown(data['currency']!, _currencyMeta), + ); + } + if (data.containsKey('sold_on')) { + context.handle( + _soldOnMeta, + soldOn.isAcceptableOrUnknown(data['sold_on']!, _soldOnMeta), + ); + } else if (isInserting) { + context.missing(_soldOnMeta); + } + if (data.containsKey('note')) { + context.handle( + _noteMeta, + note.isAcceptableOrUnknown(data['note']!, _noteMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Sale map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Sale( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}updated_at'], + )!, + lastAuthor: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_author'], + )!, + isDeleted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_deleted'], + )!, + schemaRowVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}schema_row_version'], + )!, + varietyId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}variety_id'], + ), + direction: $SalesTable.$converterdirection.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}direction'], + )!, + ), + counterparty: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}counterparty'], + ), + amount: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}amount'], + ), + currency: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}currency'], + ), + soldOn: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sold_on'], + )!, + note: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note'], + ), + ); + } + + @override + $SalesTable createAlias(String alias) { + return $SalesTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $converterdirection = + const EnumNameConverter(SaleDirection.values); +} + +class Sale extends DataClass implements Insertable { + final String id; + final int createdAt; + final String updatedAt; + final String lastAuthor; + final bool isDeleted; + final int schemaRowVersion; + + /// The seed sold/bought. Nullable so a sale can be jotted before it's linked + /// to a catalogued variety. + final String? varietyId; + + /// Whether I sold or I bought. + final SaleDirection direction; + + /// The other party as a human name (v1). + final String? counterparty; + + /// Price paid. Nullable — a barter-ish "sale" recorded before a figure is set. + final double? amount; + + /// The currency, free text: "€", "Ğ1", "horas", a local currency name. Never + /// assumed — the seed world uses many (sharing-model §6). + final String? currency; + + /// When the sale happened (ms since epoch). + final int soldOn; + final String? note; + const Sale({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.lastAuthor, + required this.isDeleted, + required this.schemaRowVersion, + this.varietyId, + required this.direction, + this.counterparty, + this.amount, + this.currency, + required this.soldOn, + this.note, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['last_author'] = Variable(lastAuthor); + map['is_deleted'] = Variable(isDeleted); + map['schema_row_version'] = Variable(schemaRowVersion); + if (!nullToAbsent || varietyId != null) { + map['variety_id'] = Variable(varietyId); + } + { + map['direction'] = Variable( + $SalesTable.$converterdirection.toSql(direction), + ); + } + if (!nullToAbsent || counterparty != null) { + map['counterparty'] = Variable(counterparty); + } + if (!nullToAbsent || amount != null) { + map['amount'] = Variable(amount); + } + if (!nullToAbsent || currency != null) { + map['currency'] = Variable(currency); + } + map['sold_on'] = Variable(soldOn); + if (!nullToAbsent || note != null) { + map['note'] = Variable(note); + } + return map; + } + + SalesCompanion toCompanion(bool nullToAbsent) { + return SalesCompanion( + id: Value(id), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + lastAuthor: Value(lastAuthor), + isDeleted: Value(isDeleted), + schemaRowVersion: Value(schemaRowVersion), + varietyId: varietyId == null && nullToAbsent + ? const Value.absent() + : Value(varietyId), + direction: Value(direction), + counterparty: counterparty == null && nullToAbsent + ? const Value.absent() + : Value(counterparty), + amount: amount == null && nullToAbsent + ? const Value.absent() + : Value(amount), + currency: currency == null && nullToAbsent + ? const Value.absent() + : Value(currency), + soldOn: Value(soldOn), + note: note == null && nullToAbsent ? const Value.absent() : Value(note), + ); + } + + factory Sale.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Sale( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + lastAuthor: serializer.fromJson(json['lastAuthor']), + isDeleted: serializer.fromJson(json['isDeleted']), + schemaRowVersion: serializer.fromJson(json['schemaRowVersion']), + varietyId: serializer.fromJson(json['varietyId']), + direction: $SalesTable.$converterdirection.fromJson( + serializer.fromJson(json['direction']), + ), + counterparty: serializer.fromJson(json['counterparty']), + amount: serializer.fromJson(json['amount']), + currency: serializer.fromJson(json['currency']), + soldOn: serializer.fromJson(json['soldOn']), + note: serializer.fromJson(json['note']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'lastAuthor': serializer.toJson(lastAuthor), + 'isDeleted': serializer.toJson(isDeleted), + 'schemaRowVersion': serializer.toJson(schemaRowVersion), + 'varietyId': serializer.toJson(varietyId), + 'direction': serializer.toJson( + $SalesTable.$converterdirection.toJson(direction), + ), + 'counterparty': serializer.toJson(counterparty), + 'amount': serializer.toJson(amount), + 'currency': serializer.toJson(currency), + 'soldOn': serializer.toJson(soldOn), + 'note': serializer.toJson(note), + }; + } + + Sale copyWith({ + String? id, + int? createdAt, + String? updatedAt, + String? lastAuthor, + bool? isDeleted, + int? schemaRowVersion, + Value varietyId = const Value.absent(), + SaleDirection? direction, + Value counterparty = const Value.absent(), + Value amount = const Value.absent(), + Value currency = const Value.absent(), + int? soldOn, + Value note = const Value.absent(), + }) => Sale( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastAuthor: lastAuthor ?? this.lastAuthor, + isDeleted: isDeleted ?? this.isDeleted, + schemaRowVersion: schemaRowVersion ?? this.schemaRowVersion, + varietyId: varietyId.present ? varietyId.value : this.varietyId, + direction: direction ?? this.direction, + counterparty: counterparty.present ? counterparty.value : this.counterparty, + amount: amount.present ? amount.value : this.amount, + currency: currency.present ? currency.value : this.currency, + soldOn: soldOn ?? this.soldOn, + note: note.present ? note.value : this.note, + ); + Sale copyWithCompanion(SalesCompanion data) { + return Sale( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + lastAuthor: data.lastAuthor.present + ? data.lastAuthor.value + : this.lastAuthor, + isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, + schemaRowVersion: data.schemaRowVersion.present + ? data.schemaRowVersion.value + : this.schemaRowVersion, + varietyId: data.varietyId.present ? data.varietyId.value : this.varietyId, + direction: data.direction.present ? data.direction.value : this.direction, + counterparty: data.counterparty.present + ? data.counterparty.value + : this.counterparty, + amount: data.amount.present ? data.amount.value : this.amount, + currency: data.currency.present ? data.currency.value : this.currency, + soldOn: data.soldOn.present ? data.soldOn.value : this.soldOn, + note: data.note.present ? data.note.value : this.note, + ); + } + + @override + String toString() { + return (StringBuffer('Sale(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastAuthor: $lastAuthor, ') + ..write('isDeleted: $isDeleted, ') + ..write('schemaRowVersion: $schemaRowVersion, ') + ..write('varietyId: $varietyId, ') + ..write('direction: $direction, ') + ..write('counterparty: $counterparty, ') + ..write('amount: $amount, ') + ..write('currency: $currency, ') + ..write('soldOn: $soldOn, ') + ..write('note: $note') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + amount, + currency, + soldOn, + note, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Sale && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.lastAuthor == this.lastAuthor && + other.isDeleted == this.isDeleted && + other.schemaRowVersion == this.schemaRowVersion && + other.varietyId == this.varietyId && + other.direction == this.direction && + other.counterparty == this.counterparty && + other.amount == this.amount && + other.currency == this.currency && + other.soldOn == this.soldOn && + other.note == this.note); +} + +class SalesCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value lastAuthor; + final Value isDeleted; + final Value schemaRowVersion; + final Value varietyId; + final Value direction; + final Value counterparty; + final Value amount; + final Value currency; + final Value soldOn; + final Value note; + final Value rowid; + const SalesCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.lastAuthor = const Value.absent(), + this.isDeleted = const Value.absent(), + this.schemaRowVersion = const Value.absent(), + this.varietyId = const Value.absent(), + this.direction = const Value.absent(), + this.counterparty = const Value.absent(), + this.amount = const Value.absent(), + this.currency = const Value.absent(), + this.soldOn = const Value.absent(), + this.note = const Value.absent(), + this.rowid = const Value.absent(), + }); + SalesCompanion.insert({ + required String id, + required int createdAt, + required String updatedAt, + required String lastAuthor, + this.isDeleted = const Value.absent(), + this.schemaRowVersion = const Value.absent(), + this.varietyId = const Value.absent(), + required SaleDirection direction, + this.counterparty = const Value.absent(), + this.amount = const Value.absent(), + this.currency = const Value.absent(), + required int soldOn, + this.note = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt), + lastAuthor = Value(lastAuthor), + direction = Value(direction), + soldOn = Value(soldOn); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? lastAuthor, + Expression? isDeleted, + Expression? schemaRowVersion, + Expression? varietyId, + Expression? direction, + Expression? counterparty, + Expression? amount, + Expression? currency, + Expression? soldOn, + Expression? note, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (lastAuthor != null) 'last_author': lastAuthor, + if (isDeleted != null) 'is_deleted': isDeleted, + if (schemaRowVersion != null) 'schema_row_version': schemaRowVersion, + if (varietyId != null) 'variety_id': varietyId, + if (direction != null) 'direction': direction, + if (counterparty != null) 'counterparty': counterparty, + if (amount != null) 'amount': amount, + if (currency != null) 'currency': currency, + if (soldOn != null) 'sold_on': soldOn, + if (note != null) 'note': note, + if (rowid != null) 'rowid': rowid, + }); + } + + SalesCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? lastAuthor, + Value? isDeleted, + Value? schemaRowVersion, + Value? varietyId, + Value? direction, + Value? counterparty, + Value? amount, + Value? currency, + Value? soldOn, + Value? note, + Value? rowid, + }) { + return SalesCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastAuthor: lastAuthor ?? this.lastAuthor, + isDeleted: isDeleted ?? this.isDeleted, + schemaRowVersion: schemaRowVersion ?? this.schemaRowVersion, + varietyId: varietyId ?? this.varietyId, + direction: direction ?? this.direction, + counterparty: counterparty ?? this.counterparty, + amount: amount ?? this.amount, + currency: currency ?? this.currency, + soldOn: soldOn ?? this.soldOn, + note: note ?? this.note, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (lastAuthor.present) { + map['last_author'] = Variable(lastAuthor.value); + } + if (isDeleted.present) { + map['is_deleted'] = Variable(isDeleted.value); + } + if (schemaRowVersion.present) { + map['schema_row_version'] = Variable(schemaRowVersion.value); + } + if (varietyId.present) { + map['variety_id'] = Variable(varietyId.value); + } + if (direction.present) { + map['direction'] = Variable( + $SalesTable.$converterdirection.toSql(direction.value), + ); + } + if (counterparty.present) { + map['counterparty'] = Variable(counterparty.value); + } + if (amount.present) { + map['amount'] = Variable(amount.value); + } + if (currency.present) { + map['currency'] = Variable(currency.value); + } + if (soldOn.present) { + map['sold_on'] = Variable(soldOn.value); + } + if (note.present) { + map['note'] = Variable(note.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SalesCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastAuthor: $lastAuthor, ') + ..write('isDeleted: $isDeleted, ') + ..write('schemaRowVersion: $schemaRowVersion, ') + ..write('varietyId: $varietyId, ') + ..write('direction: $direction, ') + ..write('counterparty: $counterparty, ') + ..write('amount: $amount, ') + ..write('currency: $currency, ') + ..write('soldOn: $soldOn, ') + ..write('note: $note, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -8523,6 +10814,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $PartiesTable parties = $PartiesTable(this); late final $AttachmentsTable attachments = $AttachmentsTable(this); late final $ExternalLinksTable externalLinks = $ExternalLinksTable(this); + late final $PlantaresTable plantares = $PlantaresTable(this); + late final $SalesTable sales = $SalesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -8539,6 +10832,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { parties, attachments, externalLinks, + plantares, + sales, ]; } @@ -9989,6 +12284,8 @@ typedef $$LotsTableCreateCompanionBuilder = Value originPlace, Value abundance, Value preservationFormat, + Value priceAmount, + Value priceCurrency, Value rowid, }); typedef $$LotsTableUpdateCompanionBuilder = @@ -10014,6 +12311,8 @@ typedef $$LotsTableUpdateCompanionBuilder = Value originPlace, Value abundance, Value preservationFormat, + Value priceAmount, + Value priceCurrency, Value rowid, }); @@ -10138,6 +12437,16 @@ class $$LotsTableFilterComposer extends Composer<_$AppDatabase, $LotsTable> { column: $table.preservationFormat, builder: (column) => ColumnWithTypeConverterFilters(column), ); + + ColumnFilters get priceAmount => $composableBuilder( + column: $table.priceAmount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get priceCurrency => $composableBuilder( + column: $table.priceCurrency, + builder: (column) => ColumnFilters(column), + ); } class $$LotsTableOrderingComposer extends Composer<_$AppDatabase, $LotsTable> { @@ -10252,6 +12561,16 @@ class $$LotsTableOrderingComposer extends Composer<_$AppDatabase, $LotsTable> { column: $table.preservationFormat, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get priceAmount => $composableBuilder( + column: $table.priceAmount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get priceCurrency => $composableBuilder( + column: $table.priceCurrency, + builder: (column) => ColumnOrderings(column), + ); } class $$LotsTableAnnotationComposer @@ -10356,6 +12675,16 @@ class $$LotsTableAnnotationComposer column: $table.preservationFormat, builder: (column) => column, ); + + GeneratedColumn get priceAmount => $composableBuilder( + column: $table.priceAmount, + builder: (column) => column, + ); + + GeneratedColumn get priceCurrency => $composableBuilder( + column: $table.priceCurrency, + builder: (column) => column, + ); } class $$LotsTableTableManager @@ -10408,6 +12737,8 @@ class $$LotsTableTableManager Value abundance = const Value.absent(), Value preservationFormat = const Value.absent(), + Value priceAmount = const Value.absent(), + Value priceCurrency = const Value.absent(), Value rowid = const Value.absent(), }) => LotsCompanion( id: id, @@ -10431,6 +12762,8 @@ class $$LotsTableTableManager originPlace: originPlace, abundance: abundance, preservationFormat: preservationFormat, + priceAmount: priceAmount, + priceCurrency: priceCurrency, rowid: rowid, ), createCompanionCallback: @@ -10457,6 +12790,8 @@ class $$LotsTableTableManager Value abundance = const Value.absent(), Value preservationFormat = const Value.absent(), + Value priceAmount = const Value.absent(), + Value priceCurrency = const Value.absent(), Value rowid = const Value.absent(), }) => LotsCompanion.insert( id: id, @@ -10480,6 +12815,8 @@ class $$LotsTableTableManager originPlace: originPlace, abundance: abundance, preservationFormat: preservationFormat, + priceAmount: priceAmount, + priceCurrency: priceCurrency, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -12506,6 +14843,943 @@ typedef $$ExternalLinksTableProcessedTableManager = ExternalLink, PrefetchHooks Function() >; +typedef $$PlantaresTableCreateCompanionBuilder = + PlantaresCompanion Function({ + required String id, + required int createdAt, + required String updatedAt, + required String lastAuthor, + Value isDeleted, + Value schemaRowVersion, + Value varietyId, + required PlantareDirection direction, + Value counterparty, + Value owedDescription, + required int madeOn, + Value dueBy, + Value status, + Value settledOn, + Value note, + Value pledgeId, + Value debtorKey, + Value creditorKey, + Value debtorSignature, + Value creditorSignature, + Value movementId, + Value remoteState, + Value returnKind, + Value workHours, + Value rowid, + }); +typedef $$PlantaresTableUpdateCompanionBuilder = + PlantaresCompanion Function({ + Value id, + Value createdAt, + Value updatedAt, + Value lastAuthor, + Value isDeleted, + Value schemaRowVersion, + Value varietyId, + Value direction, + Value counterparty, + Value owedDescription, + Value madeOn, + Value dueBy, + Value status, + Value settledOn, + Value note, + Value pledgeId, + Value debtorKey, + Value creditorKey, + Value debtorSignature, + Value creditorSignature, + Value movementId, + Value remoteState, + Value returnKind, + Value workHours, + Value rowid, + }); + +class $$PlantaresTableFilterComposer + extends Composer<_$AppDatabase, $PlantaresTable> { + $$PlantaresTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastAuthor => $composableBuilder( + column: $table.lastAuthor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get schemaRowVersion => $composableBuilder( + column: $table.schemaRowVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get varietyId => $composableBuilder( + column: $table.varietyId, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get direction => $composableBuilder( + column: $table.direction, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get counterparty => $composableBuilder( + column: $table.counterparty, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get owedDescription => $composableBuilder( + column: $table.owedDescription, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get madeOn => $composableBuilder( + column: $table.madeOn, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dueBy => $composableBuilder( + column: $table.dueBy, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get settledOn => $composableBuilder( + column: $table.settledOn, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pledgeId => $composableBuilder( + column: $table.pledgeId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get debtorKey => $composableBuilder( + column: $table.debtorKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get creditorKey => $composableBuilder( + column: $table.creditorKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get debtorSignature => $composableBuilder( + column: $table.debtorSignature, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get creditorSignature => $composableBuilder( + column: $table.creditorSignature, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get movementId => $composableBuilder( + column: $table.movementId, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters< + PlantareRemoteState?, + PlantareRemoteState, + String + > + get remoteState => $composableBuilder( + column: $table.remoteState, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters + get returnKind => $composableBuilder( + column: $table.returnKind, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get workHours => $composableBuilder( + column: $table.workHours, + builder: (column) => ColumnFilters(column), + ); +} + +class $$PlantaresTableOrderingComposer + extends Composer<_$AppDatabase, $PlantaresTable> { + $$PlantaresTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastAuthor => $composableBuilder( + column: $table.lastAuthor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get schemaRowVersion => $composableBuilder( + column: $table.schemaRowVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get varietyId => $composableBuilder( + column: $table.varietyId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get direction => $composableBuilder( + column: $table.direction, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get counterparty => $composableBuilder( + column: $table.counterparty, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get owedDescription => $composableBuilder( + column: $table.owedDescription, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get madeOn => $composableBuilder( + column: $table.madeOn, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dueBy => $composableBuilder( + column: $table.dueBy, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get settledOn => $composableBuilder( + column: $table.settledOn, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pledgeId => $composableBuilder( + column: $table.pledgeId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get debtorKey => $composableBuilder( + column: $table.debtorKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get creditorKey => $composableBuilder( + column: $table.creditorKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get debtorSignature => $composableBuilder( + column: $table.debtorSignature, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get creditorSignature => $composableBuilder( + column: $table.creditorSignature, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get movementId => $composableBuilder( + column: $table.movementId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get remoteState => $composableBuilder( + column: $table.remoteState, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get returnKind => $composableBuilder( + column: $table.returnKind, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get workHours => $composableBuilder( + column: $table.workHours, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$PlantaresTableAnnotationComposer + extends Composer<_$AppDatabase, $PlantaresTable> { + $$PlantaresTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get lastAuthor => $composableBuilder( + column: $table.lastAuthor, + builder: (column) => column, + ); + + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get schemaRowVersion => $composableBuilder( + column: $table.schemaRowVersion, + builder: (column) => column, + ); + + GeneratedColumn get varietyId => + $composableBuilder(column: $table.varietyId, builder: (column) => column); + + GeneratedColumnWithTypeConverter get direction => + $composableBuilder(column: $table.direction, builder: (column) => column); + + GeneratedColumn get counterparty => $composableBuilder( + column: $table.counterparty, + builder: (column) => column, + ); + + GeneratedColumn get owedDescription => $composableBuilder( + column: $table.owedDescription, + builder: (column) => column, + ); + + GeneratedColumn get madeOn => + $composableBuilder(column: $table.madeOn, builder: (column) => column); + + GeneratedColumn get dueBy => + $composableBuilder(column: $table.dueBy, builder: (column) => column); + + GeneratedColumnWithTypeConverter get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get settledOn => + $composableBuilder(column: $table.settledOn, builder: (column) => column); + + GeneratedColumn get note => + $composableBuilder(column: $table.note, builder: (column) => column); + + GeneratedColumn get pledgeId => + $composableBuilder(column: $table.pledgeId, builder: (column) => column); + + GeneratedColumn get debtorKey => + $composableBuilder(column: $table.debtorKey, builder: (column) => column); + + GeneratedColumn get creditorKey => $composableBuilder( + column: $table.creditorKey, + builder: (column) => column, + ); + + GeneratedColumn get debtorSignature => $composableBuilder( + column: $table.debtorSignature, + builder: (column) => column, + ); + + GeneratedColumn get creditorSignature => $composableBuilder( + column: $table.creditorSignature, + builder: (column) => column, + ); + + GeneratedColumn get movementId => $composableBuilder( + column: $table.movementId, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter + get remoteState => $composableBuilder( + column: $table.remoteState, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get returnKind => + $composableBuilder( + column: $table.returnKind, + builder: (column) => column, + ); + + GeneratedColumn get workHours => + $composableBuilder(column: $table.workHours, builder: (column) => column); +} + +class $$PlantaresTableTableManager + extends + RootTableManager< + _$AppDatabase, + $PlantaresTable, + Plantare, + $$PlantaresTableFilterComposer, + $$PlantaresTableOrderingComposer, + $$PlantaresTableAnnotationComposer, + $$PlantaresTableCreateCompanionBuilder, + $$PlantaresTableUpdateCompanionBuilder, + (Plantare, BaseReferences<_$AppDatabase, $PlantaresTable, Plantare>), + Plantare, + PrefetchHooks Function() + > { + $$PlantaresTableTableManager(_$AppDatabase db, $PlantaresTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PlantaresTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PlantaresTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PlantaresTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastAuthor = const Value.absent(), + Value isDeleted = const Value.absent(), + Value schemaRowVersion = const Value.absent(), + Value varietyId = const Value.absent(), + Value direction = const Value.absent(), + Value counterparty = const Value.absent(), + Value owedDescription = const Value.absent(), + Value madeOn = const Value.absent(), + Value dueBy = const Value.absent(), + Value status = const Value.absent(), + Value settledOn = const Value.absent(), + Value note = const Value.absent(), + Value pledgeId = const Value.absent(), + Value debtorKey = const Value.absent(), + Value creditorKey = const Value.absent(), + Value debtorSignature = const Value.absent(), + Value creditorSignature = const Value.absent(), + Value movementId = const Value.absent(), + Value remoteState = const Value.absent(), + Value returnKind = const Value.absent(), + Value workHours = const Value.absent(), + Value rowid = const Value.absent(), + }) => PlantaresCompanion( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + lastAuthor: lastAuthor, + isDeleted: isDeleted, + schemaRowVersion: schemaRowVersion, + varietyId: varietyId, + direction: direction, + counterparty: counterparty, + owedDescription: owedDescription, + madeOn: madeOn, + dueBy: dueBy, + status: status, + settledOn: settledOn, + note: note, + pledgeId: pledgeId, + debtorKey: debtorKey, + creditorKey: creditorKey, + debtorSignature: debtorSignature, + creditorSignature: creditorSignature, + movementId: movementId, + remoteState: remoteState, + returnKind: returnKind, + workHours: workHours, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required int createdAt, + required String updatedAt, + required String lastAuthor, + Value isDeleted = const Value.absent(), + Value schemaRowVersion = const Value.absent(), + Value varietyId = const Value.absent(), + required PlantareDirection direction, + Value counterparty = const Value.absent(), + Value owedDescription = const Value.absent(), + required int madeOn, + Value dueBy = const Value.absent(), + Value status = const Value.absent(), + Value settledOn = const Value.absent(), + Value note = const Value.absent(), + Value pledgeId = const Value.absent(), + Value debtorKey = const Value.absent(), + Value creditorKey = const Value.absent(), + Value debtorSignature = const Value.absent(), + Value creditorSignature = const Value.absent(), + Value movementId = const Value.absent(), + Value remoteState = const Value.absent(), + Value returnKind = const Value.absent(), + Value workHours = const Value.absent(), + Value rowid = const Value.absent(), + }) => PlantaresCompanion.insert( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + lastAuthor: lastAuthor, + isDeleted: isDeleted, + schemaRowVersion: schemaRowVersion, + varietyId: varietyId, + direction: direction, + counterparty: counterparty, + owedDescription: owedDescription, + madeOn: madeOn, + dueBy: dueBy, + status: status, + settledOn: settledOn, + note: note, + pledgeId: pledgeId, + debtorKey: debtorKey, + creditorKey: creditorKey, + debtorSignature: debtorSignature, + creditorSignature: creditorSignature, + movementId: movementId, + remoteState: remoteState, + returnKind: returnKind, + workHours: workHours, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$PlantaresTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $PlantaresTable, + Plantare, + $$PlantaresTableFilterComposer, + $$PlantaresTableOrderingComposer, + $$PlantaresTableAnnotationComposer, + $$PlantaresTableCreateCompanionBuilder, + $$PlantaresTableUpdateCompanionBuilder, + (Plantare, BaseReferences<_$AppDatabase, $PlantaresTable, Plantare>), + Plantare, + PrefetchHooks Function() + >; +typedef $$SalesTableCreateCompanionBuilder = + SalesCompanion Function({ + required String id, + required int createdAt, + required String updatedAt, + required String lastAuthor, + Value isDeleted, + Value schemaRowVersion, + Value varietyId, + required SaleDirection direction, + Value counterparty, + Value amount, + Value currency, + required int soldOn, + Value note, + Value rowid, + }); +typedef $$SalesTableUpdateCompanionBuilder = + SalesCompanion Function({ + Value id, + Value createdAt, + Value updatedAt, + Value lastAuthor, + Value isDeleted, + Value schemaRowVersion, + Value varietyId, + Value direction, + Value counterparty, + Value amount, + Value currency, + Value soldOn, + Value note, + Value rowid, + }); + +class $$SalesTableFilterComposer extends Composer<_$AppDatabase, $SalesTable> { + $$SalesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastAuthor => $composableBuilder( + column: $table.lastAuthor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get schemaRowVersion => $composableBuilder( + column: $table.schemaRowVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get varietyId => $composableBuilder( + column: $table.varietyId, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get direction => $composableBuilder( + column: $table.direction, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get counterparty => $composableBuilder( + column: $table.counterparty, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get amount => $composableBuilder( + column: $table.amount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get currency => $composableBuilder( + column: $table.currency, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get soldOn => $composableBuilder( + column: $table.soldOn, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SalesTableOrderingComposer + extends Composer<_$AppDatabase, $SalesTable> { + $$SalesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastAuthor => $composableBuilder( + column: $table.lastAuthor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get schemaRowVersion => $composableBuilder( + column: $table.schemaRowVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get varietyId => $composableBuilder( + column: $table.varietyId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get direction => $composableBuilder( + column: $table.direction, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get counterparty => $composableBuilder( + column: $table.counterparty, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get amount => $composableBuilder( + column: $table.amount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get currency => $composableBuilder( + column: $table.currency, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get soldOn => $composableBuilder( + column: $table.soldOn, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SalesTableAnnotationComposer + extends Composer<_$AppDatabase, $SalesTable> { + $$SalesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get lastAuthor => $composableBuilder( + column: $table.lastAuthor, + builder: (column) => column, + ); + + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get schemaRowVersion => $composableBuilder( + column: $table.schemaRowVersion, + builder: (column) => column, + ); + + GeneratedColumn get varietyId => + $composableBuilder(column: $table.varietyId, builder: (column) => column); + + GeneratedColumnWithTypeConverter get direction => + $composableBuilder(column: $table.direction, builder: (column) => column); + + GeneratedColumn get counterparty => $composableBuilder( + column: $table.counterparty, + builder: (column) => column, + ); + + GeneratedColumn get amount => + $composableBuilder(column: $table.amount, builder: (column) => column); + + GeneratedColumn get currency => + $composableBuilder(column: $table.currency, builder: (column) => column); + + GeneratedColumn get soldOn => + $composableBuilder(column: $table.soldOn, builder: (column) => column); + + GeneratedColumn get note => + $composableBuilder(column: $table.note, builder: (column) => column); +} + +class $$SalesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $SalesTable, + Sale, + $$SalesTableFilterComposer, + $$SalesTableOrderingComposer, + $$SalesTableAnnotationComposer, + $$SalesTableCreateCompanionBuilder, + $$SalesTableUpdateCompanionBuilder, + (Sale, BaseReferences<_$AppDatabase, $SalesTable, Sale>), + Sale, + PrefetchHooks Function() + > { + $$SalesTableTableManager(_$AppDatabase db, $SalesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SalesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SalesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SalesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastAuthor = const Value.absent(), + Value isDeleted = const Value.absent(), + Value schemaRowVersion = const Value.absent(), + Value varietyId = const Value.absent(), + Value direction = const Value.absent(), + Value counterparty = const Value.absent(), + Value amount = const Value.absent(), + Value currency = const Value.absent(), + Value soldOn = const Value.absent(), + Value note = const Value.absent(), + Value rowid = const Value.absent(), + }) => SalesCompanion( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + lastAuthor: lastAuthor, + isDeleted: isDeleted, + schemaRowVersion: schemaRowVersion, + varietyId: varietyId, + direction: direction, + counterparty: counterparty, + amount: amount, + currency: currency, + soldOn: soldOn, + note: note, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required int createdAt, + required String updatedAt, + required String lastAuthor, + Value isDeleted = const Value.absent(), + Value schemaRowVersion = const Value.absent(), + Value varietyId = const Value.absent(), + required SaleDirection direction, + Value counterparty = const Value.absent(), + Value amount = const Value.absent(), + Value currency = const Value.absent(), + required int soldOn, + Value note = const Value.absent(), + Value rowid = const Value.absent(), + }) => SalesCompanion.insert( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + lastAuthor: lastAuthor, + isDeleted: isDeleted, + schemaRowVersion: schemaRowVersion, + varietyId: varietyId, + direction: direction, + counterparty: counterparty, + amount: amount, + currency: currency, + soldOn: soldOn, + note: note, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SalesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SalesTable, + Sale, + $$SalesTableFilterComposer, + $$SalesTableOrderingComposer, + $$SalesTableAnnotationComposer, + $$SalesTableCreateCompanionBuilder, + $$SalesTableUpdateCompanionBuilder, + (Sale, BaseReferences<_$AppDatabase, $SalesTable, Sale>), + Sale, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -12534,4 +15808,8 @@ class $AppDatabaseManager { $$AttachmentsTableTableManager(_db, _db.attachments); $$ExternalLinksTableTableManager get externalLinks => $$ExternalLinksTableTableManager(_db, _db.externalLinks); + $$PlantaresTableTableManager get plantares => + $$PlantaresTableTableManager(_db, _db.plantares); + $$SalesTableTableManager get sales => + $$SalesTableTableManager(_db, _db.sales); } diff --git a/apps/app_seeds/lib/db/enums.dart b/apps/app_seeds/lib/db/enums.dart index 1c5128a..0ce0653 100644 --- a/apps/app_seeds/lib/db/enums.dart +++ b/apps/app_seeds/lib/db/enums.dart @@ -86,3 +86,43 @@ enum AttachmentKind { photo, doc } /// Polymorphic parent of an Attachment / ExternalLink. enum ParentType { variety, lot, movement } + +/// Whose reproduction promise a Plantare is, seen from this app's owner +/// (data-model §2.7). A Plantare is a commitment to REPRODUCE and return seed — +/// not a sale. +enum PlantareDirection { + /// I received seed and promised to grow it out and return some. + iReturn, + + /// I gave seed and the other person promised to return some. + owedToMe, +} + +/// Lifecycle of a Plantare. Framed as a promise, not a debt (data-model §2.7): +/// `forgiven` (not "defaulted") when it's let go. +enum PlantareStatus { open, returned, forgiven } + +/// State of the bilateral SIGNED handshake, distinct from [PlantareStatus] +/// (which tracks the *promise*, not the *agreement*). A v1 local-only row leaves +/// this null. `proposed` = I sent/received a proposal awaiting the other stub; +/// `accepted` = both signed (a provably closed deal); `declined` = the other +/// party turned my proposal down. Mirrors commons_core `PlantareMessageKind`. +enum PlantareRemoteState { proposed, accepted, declined } + +/// How a Plantare is promised back, mirroring the paper form's checkboxes and +/// commons_core `ReturnKind`. `similar` (the default) means open-pollinated · +/// non-GMO · organically grown; `workHours` is time-as-currency; `other` is +/// free text in `owedDescription`. +enum PlantareReturnKind { similar, workHours, other } + +/// Direction of a recorded seed Sale, seen from this app's owner. A sale is a +/// SEPARATE model from a gift or a Plantare — seed for money (any currency: +/// €, Ğ1, a local/time currency…). No commission is ever taken on seeds. +enum SaleDirection { iSold, iBought } + +/// Direction of a hand-over — the one moment seeds actually change hands +/// (data-model §2.8: a closed deal produces a Movement and, optionally, a +/// Plantare and/or a Sale record). Not stored in a column of its own: it maps +/// to [MovementType.given]/[MovementType.received] plus the derived directions +/// of the optional Sale and Plantare rows. +enum HandoverDirection { iGave, iReceived } diff --git a/apps/app_seeds/lib/db/tables.dart b/apps/app_seeds/lib/db/tables.dart index e496c04..63ddb42 100644 --- a/apps/app_seeds/lib/db/tables.dart +++ b/apps/app_seeds/lib/db/tables.dart @@ -109,6 +109,17 @@ class Lots extends Table with SyncColumns { /// Distinct from [storageLocation]. Optional. TextColumn get preservationFormat => textEnum().nullable()(); + + /// Asking price when [offerStatus] is `sell` (data-model §2.8 puts price on + /// the Offer; with offer state collapsed onto the Lot, it lives here and is + /// published with the market offer). Informational only — money changes + /// hands off-platform, never in-app. Null amount on a sell lot means + /// "price to be agreed" (the offer publishes without a price). + RealColumn get priceAmount => real().nullable()(); + + /// Free-text currency, like [Sales.currency]: "€", "Ğ1", a local/time + /// currency. Never assumed (sharing-model §6). + TextColumn get priceCurrency => text().nullable()(); } /// Optional germination history for a Lot; percent is derived in code. @@ -173,6 +184,106 @@ class Attachments extends Table with SyncColumns { IntColumn get sortOrder => integer().withDefault(const Constant(0))(); } +/// A Plantare — a REPRODUCTION commitment (data-model §2.7), the seed-domain +/// name for the generic `Pledge` (`return_kind = similar`). Seed changes hands +/// and the receiver promises to grow it out and return some. NOT a sale. +/// +/// Local-first v1: your own honest ledger of commitments (from/to a person by +/// name). The bilateral, cryptographically SIGNED, cross-party form +/// (debtor/creditor keys + both signatures, tied to a shared `Movement`) is a +/// later social-layer phase — those columns are deferred, not invented now. +class Plantares extends Table with SyncColumns { + /// The seed the commitment is about — what gets reproduced. Nullable so a + /// commitment can be jotted before it's linked to a catalogued variety. + TextColumn get varietyId => text().nullable()(); // → Variety + + /// Whose promise it is (I return / owed to me). + TextColumn get direction => textEnum()(); + + /// The other party as a human name (v1). A key/Party link arrives with the + /// signed cross-party form. + TextColumn get counterparty => text().nullable()(); + + /// What's promised back, in the grower's own words + /// ("un puñado la próxima temporada"). + TextColumn get owedDescription => text().nullable()(); + + /// When the promise was made (ms since epoch). + IntColumn get madeOn => integer()(); + + /// Optional return-by date (ms since epoch) — a gentle reminder, not a deadline. + IntColumn get dueBy => integer().nullable()(); + + TextColumn get status => + textEnum().withDefault(const Constant('open'))(); + + /// When it was returned or forgiven (ms since epoch). + IntColumn get settledOn => integer().nullable()(); + + TextColumn get note => text().nullable()(); + + // --- Bilateral signed form (plantare-bilateral.md). Nullable so v1 local rows + // (both keys/signatures null, remoteState null) coexist untouched. --- + + /// The shared pledge id both parties agree on (the proposer's id). Distinct + /// from [id] (each side keeps its own local row). Null for a v1 local note. + TextColumn get pledgeId => text().nullable()(); + + /// Pubkey (hex) of who received the seed and owes a return. + TextColumn get debtorKey => text().nullable()(); + + /// Pubkey (hex) of who gave the seed. + TextColumn get creditorKey => text().nullable()(); + + /// The debtor's Schnorr stub over the canonical pledge core. + TextColumn get debtorSignature => text().nullable()(); + + /// The creditor's Schnorr stub over the canonical pledge core. + TextColumn get creditorSignature => text().nullable()(); + + /// The shared hand-over `Movement` this Plantare accompanies (provenance DAG). + TextColumn get movementId => text().nullable()(); // → Movement + + /// The handshake state (proposed/accepted/declined), null for a v1 local row. + TextColumn get remoteState => textEnum().nullable()(); + + /// How it's promised back (default `similar`: open-pollinated · non-GMO · + /// organically grown), mirroring the paper form's checkboxes. + TextColumn get returnKind => + textEnum().withDefault(const Constant('similar'))(); + + /// Hours for the `workHours` return option (time-as-currency). Null otherwise. + RealColumn get workHours => real().nullable()(); +} + +/// A recorded seed Sale — seed for money (data-model §2.8/sharing-model §6). A +/// SEPARATE model from a gift or a Plantare: a completed exchange with a price +/// in ANY currency (€, Ğ1, a local/time currency). Local-first ledger; the +/// cross-party/receipt form is a later social-layer concern. +class Sales extends Table with SyncColumns { + /// The seed sold/bought. Nullable so a sale can be jotted before it's linked + /// to a catalogued variety. + TextColumn get varietyId => text().nullable()(); // → Variety + + /// Whether I sold or I bought. + TextColumn get direction => textEnum()(); + + /// The other party as a human name (v1). + TextColumn get counterparty => text().nullable()(); + + /// Price paid. Nullable — a barter-ish "sale" recorded before a figure is set. + RealColumn get amount => real().nullable()(); + + /// The currency, free text: "€", "Ğ1", "horas", a local currency name. Never + /// assumed — the seed world uses many (sharing-model §6). + TextColumn get currency => text().nullable()(); + + /// When the sale happened (ms since epoch). + IntColumn get soldOn => integer()(); + + TextColumn get note => text().nullable()(); +} + /// Any pasted URL (Wikipedia, forum…). Polymorphic parent. class ExternalLinks extends Table with SyncColumns { TextColumn get parentType => textEnum()(); diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 29f26c5..b88976f 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -1,54 +1,150 @@ +import 'dart:async'; import 'dart:io'; import 'package:commons_core/commons_core.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../data/seed_saving_catalog.dart'; import '../data/species_catalog.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; +import '../db/chat_database.dart'; import '../db/database.dart'; import '../db/encrypted_executor.dart'; import '../i18n/strings.g.dart'; import '../security/secret_store.dart'; import '../security/secure_key_store.dart'; +import '../services/auto_backup_service.dart'; +import '../services/auto_backup_store.dart'; +import '../services/device_id_store.dart'; import '../services/export_import_service.dart'; import '../services/file_picker_file_service.dart'; import '../services/file_service.dart'; +import '../services/inbox_service.dart'; +import '../services/locale_store.dart'; +import '../services/notification_service.dart'; import '../services/ocr/label_text_extractor.dart'; import '../services/ocr/ocr_language.dart'; import '../services/ocr/tesseract_label_extractor.dart'; +import '../services/label_sheet_service.dart'; import '../services/onboarding_store.dart'; import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; +import '../services/message_store.dart'; +import '../services/offer_outbox.dart'; +import '../services/plantare_service.dart'; +import '../services/profile_cache.dart'; +import '../services/profile_store.dart'; +import '../services/saved_offers_store.dart'; +import '../services/social_account_store.dart'; +import '../services/social_connection.dart'; +import '../services/social_service.dart'; +import '../services/social_settings.dart'; +import '../services/sync_service.dart'; +import '../services/unread_service.dart'; /// The app's service locator. Kept to the composition root — widgets get their /// repositories from here (or via BlocProvider), never by reaching into it deep /// in the tree. final GetIt getIt = GetIt.instance; +/// End-of-init sentinel: registered last, so `isRegistered<_DepsReady>()` means +/// the container is fully wired (see [configureDependencies]). Distinct from +/// [SocialService], which is optional and absent in inventory-only mode. +class _DepsReady { + const _DepsReady(); +} + /// Wires the encrypted DB, keystore and repositories. Call once from `main` /// before `runApp`; the DB key must exist before the DB opens. +/// +/// Idempotent AND all-or-nothing: a hot restart (or an Android Activity restart +/// that keeps the isolate) re-runs `main` while GetIt still holds the singletons. +/// +/// A dedicated [_DepsReady] marker is registered LAST and used as the "fully +/// wired" sentinel, so `isRegistered<_DepsReady>()` means the WHOLE container is +/// ready — never a half-built one. If a prior run crashed part-way through init +/// (leaving some singletons registered but not the marker), we reset the +/// container and rebuild from scratch rather than early-returning on a partial +/// container or throwing "already registered" mid-cascade. That partial state is +/// safe to reset because `main` aborts before `runApp` on a partial run, so no +/// live UI holds these singletons. Without this, the app intermittently fell +/// back to the "inventory only" look (market/chat/profile greyed out). Future configureDependencies() async { + if (getIt.isRegistered<_DepsReady>()) return; // fully wired already + // A prior run half-wired the container (crashed after some registrations but + // before the sentinel). Clear it so we rebuild cleanly. + if (getIt.isRegistered()) await getIt.reset(); + final secretStore = FlutterSecretStore(); final keyStore = SecureKeyStore(store: secretStore); final dbKeyHex = await keyStore.databaseKeyHex(); final rootSeedHex = await keyStore.rootSeedHex(); + // Which social identity ("account") of the root seed is active. Namespaces the + // per-identity stores (chats, profile, name cache) so identities never mix. + final accounts = SocialAccountStore(secretStore); + final activeAccount = await accounts.active(); + final scope = socialAccountScope(activeAccount); + + // Stable per-install id for device-to-device sync (distinct from the + // seed-derived CRDT node id, which is shared across a user's devices). + final deviceId = await DeviceIdStore(secretStore).deviceId(); + final database = AppDatabase( openEncryptedExecutor(await _databaseFile(), dbKeyHex), ); - // Until real key derivation lands, the node/author id is a stable per-install - // slice of the root seed. It becomes the user's public key in the social layer. + // Chat history lives in its own encrypted database (same SQLCipher key), + // isolated from the inventory schema/sync. See [Messages]. + final chatDatabase = ChatDatabase( + openEncryptedExecutor(await _chatDatabaseFile(), dbKeyHex), + ); + + // CRDT author id: a stable per-install slice of the root seed. Kept distinct + // from the social-layer public key on purpose — unifying the two would rewrite + // authorship of existing rows, a data-model decision for later. final nodeId = rootSeedHex.substring(0, 16); - // Seed the bundled species catalog (idempotent) before the UI opens. + // Block 2 social identity: a secp256k1 Nostr key derived (one-way) from the + // SAME root seed, so it needs no extra backup. Cheap + offline; no relay is + // contacted here (local-first — the social layer only enriches). Non-fatal: if + // the derivation ever fails, the app still opens on inventory (market/chat/ + // profile stay hidden) rather than blanking the whole app. + SocialService? socialService; + try { + socialService = await SocialService.fromRootSeedHex( + rootSeedHex, + account: activeAccount, + deviceId: deviceId, + ); + } catch (e, s) { + debugPrint( + 'Social identity derivation failed; inventory-only mode: $e\n$s', + ); + } + + // Seed the bundled species catalog before the UI opens — but only once per + // catalog version. Parsing the ~1.5 MB catalog and scanning the species table + // on every launch was the main startup jank; the version marker skips both + // when this install already holds the current catalog. + const catalogVersionKey = 'species_catalog_version'; final speciesRepository = SpeciesRepository(database, idGen: IdGen()); - await speciesRepository.seedBundled(await loadBundledSpecies()); + await speciesRepository.seedBundledIfNeeded( + version: speciesCatalogVersion, + readVersion: () => secretStore.read(catalogVersionKey), + writeVersion: (v) => secretStore.write(catalogVersionKey, v), + loadSeeds: loadBundledSpecies, + ); + + // Small read-only reference data — kept in memory, not the DB. Loaded before + // the UI so the variety detail can show seed-saving guidance synchronously. + await SeedSavingCatalog.ensureLoaded(); final varietyRepository = VarietyRepository( database, @@ -70,11 +166,29 @@ Future configureDependencies() async { getIt ..registerSingleton(keyStore) ..registerSingleton(database) + ..registerSingleton(chatDatabase) ..registerSingleton(speciesRepository) ..registerSingleton(varietyRepository) ..registerSingleton(fileService) ..registerSingleton(labelExtractor) ..registerSingleton(OnboardingStore(secretStore)) + ..registerSingleton(LocaleStore(secretStore)) + ..registerSingleton(SocialSettings(secretStore)) + ..registerSingleton(accounts) + ..registerSingleton(OfferOutbox(secretStore)) + // Per-identity stores are namespaced by the active account's scope. + ..registerSingleton( + MessageStore(chatDatabase, accountScope: scope), + ) + ..registerSingleton( + ProfileStore(secretStore, accountScope: scope), + ) + ..registerSingleton( + ProfileCache(secretStore, accountScope: scope), + ) + ..registerSingleton( + SavedOffersStore(secretStore, accountScope: scope), + ) ..registerSingleton( ExportImportService( repository: varietyRepository, @@ -87,7 +201,194 @@ Future configureDependencies() async { ) ..registerSingleton( RecoverySheetService(files: fileService), + ) + ..registerSingleton( + LabelSheetService(files: fileService), ); + + // Automatic silent backups need real file storage; the web build has none, so + // it simply goes without (the manual "save a copy" still works there). + if (!kIsWeb) { + getIt.registerSingleton( + AutoBackupService( + exporter: getIt(), + store: AutoBackupStore(secretStore), + directory: _backupsDir, + ), + ); + } + + // OS notifications for foreground messages. Registered unconditionally — it + // self-no-ops on unsupported platforms (web/Windows) and when the social layer + // is absent nothing ever calls it. Its tap handler is wired to the router in + // `TaneApp`, after the router exists. + getIt.registerSingleton(NotificationService()); + + // Optional: absent only if the derivation above failed — then the app runs + // inventory-only, by design (market/chat/profile hidden). + if (socialService != null) { + // ONE shared relay connection per identity, reused by every feature. + final connection = SocialConnection( + social: socialService, + settings: getIt(), + ); + getIt + ..registerSingleton(socialService) + ..registerSingleton(connection) + // Tracks unread private messages so the UI can badge them. + ..registerSingleton( + UnreadService(getIt(), secretStore), + ) + // App-wide inbox listener over the shared connection, so private messages + // arrive (and land in the inbox list) even when the chat isn't open. + // Started in `Bootstrap`; it also updates unread and fires notifications. + ..registerSingleton( + InboxService( + connection: connection, + selfPubkey: socialService.publicKeyHex, + store: getIt(), + profileCache: getIt(), + unread: getIt(), + notifications: getIt(), + settings: getIt(), + ), + ) + // Replicates the inventory across this identity's own devices over the + // shared connection. Started in `Bootstrap`. + ..registerSingleton( + SyncService( + connection: connection, + io: getIt(), + localChanges: database.tableUpdates().map((_) {}), + selfDeviceId: deviceId, + ), + ) + // Drives the bilateral signed Plantaré handshake over the shared + // connection, reconciling moves into the local ledger. Started in + // `Bootstrap`. + ..registerSingleton( + PlantareService( + repo: varietyRepository, + selfPubkey: socialService.publicKeyHex, + selfSecretKey: socialService.identity.privateKeyHex, + connection: connection, + ), + ); + } + + // Registered LAST: the "fully wired" sentinel the guard above checks. Anything + // that throws before here leaves the marker unregistered, so the next run + // rebuilds cleanly instead of reusing a half-built container. + getIt.registerSingleton<_DepsReady>(const _DepsReady()); +} + +/// Switches the active social identity to [account], re-deriving the Nostr key +/// from the SAME root seed and re-scoping the per-identity stores (chats, +/// profile, name cache) so nothing leaks between identities. Restarts the inbox +/// listener on the new identity. The caller must rebuild the widget tree +/// (`RestartWidget.restart`) so screens and router pick up the new singletons. +/// The DB, inventory and relay settings are untouched — only the social slice. +Future switchSocialAccount(int account) async { + final secretStore = FlutterSecretStore(); + await getIt().setActive(account); + final scope = socialAccountScope(account); + final rootSeedHex = await getIt().rootSeedHex(); + // Per-install, so it survives an identity switch. + final deviceId = await DeviceIdStore(secretStore).deviceId(); + + // Tear down the old identity's listener + sync + shared connection first. + if (getIt.isRegistered()) { + await getIt().stop(); + await getIt.unregister(); + } + if (getIt.isRegistered()) { + await getIt().stop(); + await getIt.unregister(); + } + if (getIt.isRegistered()) { + await getIt().stop(); + await getIt.unregister(); + } + if (getIt.isRegistered()) { + await getIt().dispose(); + await getIt.unregister(); + } + if (getIt.isRegistered()) { + await getIt.unregister(); + } + if (getIt.isRegistered()) { + await getIt.unregister(); + } + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt().close(); + await getIt.unregister(); + + // Re-register the per-identity stores under the new scope, then the identity. + getIt + ..registerSingleton( + MessageStore(getIt(), accountScope: scope), + ) + ..registerSingleton( + ProfileStore(secretStore, accountScope: scope), + ) + ..registerSingleton( + ProfileCache(secretStore, accountScope: scope), + ) + ..registerSingleton( + SavedOffersStore(secretStore, accountScope: scope), + ) + ..registerSingleton( + UnreadService(getIt(), secretStore), + ); + + final social = await SocialService.fromRootSeedHex( + rootSeedHex, + account: account, + deviceId: deviceId, + ); + final connection = SocialConnection( + social: social, + settings: getIt(), + ); + final inbox = InboxService( + connection: connection, + selfPubkey: social.publicKeyHex, + store: getIt(), + profileCache: getIt(), + unread: getIt(), + notifications: getIt(), + settings: getIt(), + ); + final sync = SyncService( + connection: connection, + io: getIt(), + localChanges: getIt().tableUpdates().map((_) {}), + selfDeviceId: deviceId, + ); + final plantares = PlantareService( + repo: getIt(), + selfPubkey: social.publicKeyHex, + selfSecretKey: social.identity.privateKeyHex, + connection: connection, + ); + getIt + ..registerSingleton(social) + ..registerSingleton(connection) + ..registerSingleton(inbox) + ..registerSingleton(sync) + ..registerSingleton(plantares); + // Subscribe the listeners BEFORE the connection starts connecting. + inbox.start(); + sync.start(); + plantares.start(); + connection.start(); +} + +Future _backupsDir() async { + final dir = await getApplicationSupportDirectory(); + return Directory(p.join(dir.path, 'backups')); } Future _databaseFile() async { @@ -95,6 +396,11 @@ Future _databaseFile() async { return File(p.join(dir.path, 'tane_inventory.sqlite')); } +Future _chatDatabaseFile() async { + final dir = await getApplicationDocumentsDirectory(); + return File(p.join(dir.path, 'tane_chat.sqlite')); +} + /// Picks the Tesseract language pack(s) for the current locale(s), limited to /// the bundled packs listed in `tessdata_config.json`. Prefers the app's chosen /// language, then the device's ordered locales. diff --git a/apps/app_seeds/lib/domain/chat_timeline.dart b/apps/app_seeds/lib/domain/chat_timeline.dart new file mode 100644 index 0000000..1fe0862 --- /dev/null +++ b/apps/app_seeds/lib/domain/chat_timeline.dart @@ -0,0 +1,70 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:intl/intl.dart'; + +/// One row of the rendered chat: either a day separator or a message. Pure data +/// so the grouping can be unit-tested without widgets. +sealed class ChatTimelineItem { + const ChatTimelineItem(); +} + +/// A "— Today —" style divider before the first message of a calendar day. +class ChatDaySeparator extends ChatTimelineItem { + const ChatDaySeparator(this.day); + + /// Local midnight of the day this divider introduces. + final DateTime day; +} + +/// A chat bubble. +class ChatMessageRow extends ChatTimelineItem { + const ChatMessageRow(this.message); + + final PrivateMessage message; +} + +/// Groups [messages] (oldest first) into a timeline, inserting a +/// [ChatDaySeparator] before the first message of each local calendar day. +/// Result stays oldest-first. +List chatTimeline(List messages) { + final out = []; + DateTime? lastDay; + for (final m in messages) { + final day = DateTime(m.at.year, m.at.month, m.at.day); + if (lastDay == null || day != lastDay) { + out.add(ChatDaySeparator(day)); + lastDay = day; + } + out.add(ChatMessageRow(m)); + } + return out; +} + +/// The human label for a day separator: "Today"/"Yesterday" for the two most +/// recent days (localized by the caller), otherwise a locale-formatted date +/// (weekday + day + month, plus year when it differs from [now]). +/// +/// [localeCode] must be an `intl`-known locale — pass the *Localizations* locale +/// (which maps Asturian → Spanish), never the raw app locale, since `ast` has no +/// `intl` date symbols and would throw. Mirrors backup_section.dart. +String chatDayLabel( + DateTime day, { + required DateTime now, + required String today, + required String yesterday, + required String localeCode, +}) { + final startOfToday = DateTime(now.year, now.month, now.day); + final startOfDay = DateTime(day.year, day.month, day.day); + final diffDays = startOfToday.difference(startOfDay).inDays; + if (diffDays == 0) return today; + if (diffDays == 1) return yesterday; + final format = day.year == now.year + ? DateFormat.MMMMEEEEd(localeCode) + : DateFormat.yMMMMEEEEd(localeCode); + return format.format(day); +} + +/// The clock time shown on a bubble, in the locale's own 12/24-hour convention. +/// See [chatDayLabel] for the [localeCode] caveat. +String chatBubbleTime(DateTime at, String localeCode) => + DateFormat.jm(localeCode).format(at); diff --git a/apps/app_seeds/lib/domain/message_rules.dart b/apps/app_seeds/lib/domain/message_rules.dart new file mode 100644 index 0000000..74f1a49 --- /dev/null +++ b/apps/app_seeds/lib/domain/message_rules.dart @@ -0,0 +1,19 @@ +/// Content rules for chat messages. +/// +/// Links are not allowed in messages: it keeps the local-first, offline chat +/// free of phishing/scam URLs between people who may not know each other yet +/// (the web of trust is still forming). Enforced on send; incoming text is never +/// rendered as a tappable link either (the bubble uses plain selectable text). +library; + +/// Matches an obvious URL: an explicit scheme, a `www.` host, or a +/// `domain.tld` with a common TLD. Kept deliberately conservative so ordinary +/// text ("3.5kg", "F1 hybrid") doesn't trip it, while real links do. +final RegExp _urlPattern = RegExp( + r'(https?://|www\.)\S+' + r'|\b[\w-]+\.(com|org|net|io|app|dev|es|eu|info|xyz|co|me|gg|link|shop|store|online|site|page|ru|cn|tk)\b', + caseSensitive: false, +); + +/// Whether [text] contains something that looks like a URL. +bool containsUrl(String text) => _urlPattern.hasMatch(text); diff --git a/apps/app_seeds/lib/domain/seed_saving.dart b/apps/app_seeds/lib/domain/seed_saving.dart new file mode 100644 index 0000000..4ed4646 --- /dev/null +++ b/apps/app_seeds/lib/domain/seed_saving.dart @@ -0,0 +1,180 @@ +/// Seed-saving know-how per crop: how a plant reproduces and what it takes to +/// keep a variety true when saving its seed. Bundled reference data (curated +/// from public seed-saving literature), keyed by botanical family with +/// per-species overrides. Pure Dart (only `dart:convert`) — no Flutter binding +/// — so it is trivially testable. +library; + +import 'dart:convert'; + +/// How long the plant takes to make seed. +enum LifeCycle { annual, biennial, perennial } + +/// Whether a plant fertilises itself or needs another plant. +enum Pollination { self, cross, mixed } + +/// What carries the pollen when it does cross. +enum PollinationVector { self, insect, wind } + +/// How the seed is cleaned once ripe. +enum SeedProcessing { dry, wet } + +/// Roughly how hard it is to save true seed at home. +enum SavingDifficulty { easy, medium, hard } + +/// The saving guidance for one crop. Every field is optional — a starter entry +/// may only know some of them. Distances are in metres; plant counts are how +/// many plants to save seed from to keep enough genetic diversity. +class SeedSavingGuide { + const SeedSavingGuide({ + this.lifeCycle, + this.pollination, + this.vector, + this.isolationMinM, + this.isolationMaxM, + this.minPlants, + this.recommendedPlants, + this.processing, + this.difficulty, + this.note = const {}, + }); + + final LifeCycle? lifeCycle; + final Pollination? pollination; + final PollinationVector? vector; + final int? isolationMinM; + final int? isolationMaxM; + final int? minPlants; + final int? recommendedPlants; + final SeedProcessing? processing; + final SavingDifficulty? difficulty; + + /// Locale-keyed short tip (`{"es": "…", "en": "…"}`), extensible to any + /// language — never assume es/en only. + final Map note; + + /// True when there's at least one fact worth showing. + bool get hasAny => + lifeCycle != null || + pollination != null || + vector != null || + isolationMinM != null || + minPlants != null || + processing != null || + difficulty != null || + note.isNotEmpty; + + /// This guide with [other]'s non-null fields laid over it (species over + /// family). Notes merge, with [other]'s languages winning. + SeedSavingGuide mergedWith(SeedSavingGuide other) => SeedSavingGuide( + lifeCycle: other.lifeCycle ?? lifeCycle, + pollination: other.pollination ?? pollination, + vector: other.vector ?? vector, + isolationMinM: other.isolationMinM ?? isolationMinM, + isolationMaxM: other.isolationMaxM ?? isolationMaxM, + minPlants: other.minPlants ?? minPlants, + recommendedPlants: other.recommendedPlants ?? recommendedPlants, + processing: other.processing ?? processing, + difficulty: other.difficulty ?? difficulty, + note: {...note, ...other.note}, + ); + + /// The tip in [lang], falling back to English then any language. + String? noteFor(String lang) { + if (note.isEmpty) return null; + return note[lang] ?? note['en'] ?? note.values.first; + } +} + +/// An attribution for the bundled guidance (the facts are drawn from these). +class SeedSavingSource { + const SeedSavingSource({required this.title, this.author, this.url}); + final String title; + final String? author; + final String? url; +} + +/// The whole bundled dataset: family defaults plus per-taxon overrides (keyed by +/// scientific name OR bare genus). Lookups are case-insensitive. +class SeedSavingData { + SeedSavingData({ + required Map families, + required Map taxa, + this.sources = const [], + }) : _families = { + for (final e in families.entries) e.key.toLowerCase().trim(): e.value, + }, + _taxa = { + for (final e in taxa.entries) e.key.toLowerCase().trim(): e.value, + }; + + final Map _families; + final Map _taxa; + + /// Where the guidance comes from — shown as a small credit under the section. + final List sources; + + /// The best guide for a crop: the family default, overlaid by a per-species + /// (or per-genus) entry when one exists. Returns null when nothing matches. + SeedSavingGuide? guideFor({String? scientificName, String? family}) { + SeedSavingGuide? base = + family == null ? null : _families[family.toLowerCase().trim()]; + + if (scientificName != null) { + final sci = scientificName.toLowerCase().trim(); + final genus = sci.split(RegExp(r'\s+')).first; + final species = _taxa[sci] ?? (genus.isEmpty ? null : _taxa[genus]); + if (species != null) base = base == null ? species : base.mergedWith(species); + } + return base; + } +} + +T? _enumByName(List values, Object? name) { + if (name is! String) return null; + for (final v in values) { + if (v.name == name) return v; + } + return null; +} + +/// Parses one guide from a JSON map (already decoded). +SeedSavingGuide parseGuide(Map m) => SeedSavingGuide( + lifeCycle: _enumByName(LifeCycle.values, m['lifeCycle']), + pollination: _enumByName(Pollination.values, m['pollination']), + vector: _enumByName(PollinationVector.values, m['vector']), + isolationMinM: (m['isolationMinM'] as num?)?.toInt(), + isolationMaxM: (m['isolationMaxM'] as num?)?.toInt(), + minPlants: (m['minPlants'] as num?)?.toInt(), + recommendedPlants: (m['recommendedPlants'] as num?)?.toInt(), + processing: _enumByName(SeedProcessing.values, m['processing']), + difficulty: _enumByName(SavingDifficulty.values, m['difficulty']), + note: (m['note'] as Map? ?? const {}) + .map((k, v) => MapEntry(k, v as String)), + ); + +/// Parses the bundled seed-saving catalog JSON into [SeedSavingData]. +SeedSavingData parseSeedSaving(String jsonString) { + final root = jsonDecode(jsonString) as Map; + Map section(String key) { + final raw = root[key] as Map? ?? const {}; + return raw.map( + (k, v) => MapEntry(k, parseGuide((v as Map).cast())), + ); + } + + final sources = [ + for (final s in (root['sources'] as List? ?? const []).cast()) + SeedSavingSource( + title: s['title'] as String, + author: s['author'] as String?, + url: s['url'] as String?, + ), + ]; + + return SeedSavingData( + families: section('families'), + taxa: section('species'), + sources: sources, + ); +} diff --git a/apps/app_seeds/lib/domain/species_autoclassify.dart b/apps/app_seeds/lib/domain/species_autoclassify.dart index 7736ef7..ded156b 100644 --- a/apps/app_seeds/lib/domain/species_autoclassify.dart +++ b/apps/app_seeds/lib/domain/species_autoclassify.dart @@ -46,12 +46,12 @@ String? matchSpeciesInLabel(String label, List names) { if (!_containsSequence(haystack, needle)) continue; final chars = needle.fold(0, (sum, t) => sum + t.length); final score = (tokens: needle.length, chars: chars); - if (best == null || _isBetter(score, best!)) { + if (best == null || _isBetter(score, best)) { best = score; winners ..clear() ..add(entry.speciesId); - } else if (score.tokens == best!.tokens && score.chars == best!.chars) { + } else if (score.tokens == best.tokens && score.chars == best.chars) { winners.add(entry.speciesId); } } diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json new file mode 100644 index 0000000..80d39a1 --- /dev/null +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -0,0 +1,730 @@ +{ + "avatar": { + "title": "La to semeya o avatar", + "fromPhoto": "Facer o escoyer una semeya", + "illustration": "O escueyi un dibuxu", + "remove": "Quitar" + }, + "favorites": { + "title": "Favoritos", + "empty": "Entá nun tienes favoritos. Guarda ofertes que te presten del mercáu.", + "save": "Guardar en favoritos", + "remove": "Quitar de favoritos", + "unavailable": "Yá nun ta disponible" + }, + "seedSaving": { + "title": "Guardar la so semiente", + "subtitle": "Lo que fai falta pa caltener la variedá fiel", + "lifeCycle": "Ciclu", + "cycleAnnual": "Añal", + "cycleBiennial": "Bienal — da semiente el 2.u añu", + "cyclePerennial": "Perenne", + "pollination": "Polinización", + "pollSelf": "Autopolinízase", + "pollCross": "Crúciase con otres", + "pollMixed": "Autopolinízase, pero crúciase dacuando", + "byInsect": "por inseutos", + "byWind": "col vientu", + "isolation": "Xébrala", + "isolationRange": "{min}–{max} m d'otres variedaes", + "isolationSingle": "{min} m d'otres variedaes", + "plants": "Guarda de delles plantes", + "plantsValue": "D'a lo menos {n} plantes", + "processing": "Cómo llimpiala", + "procDry": "Semiente seca (mayar)", + "procWet": "Semiente húmeda (fermentar y llavar)", + "difficulty": "Dificultá", + "diffEasy": "Fácil", + "diffMedium": "Media", + "diffHard": "Difícil", + "advisory": "Orientativu — afaílu al to clima y variedá.", + "sourcePrefix": "Fonte" + }, + "calendar": { + "title": "Esti mes", + "filterChip": "Esti mes", + "selfNote": "Lo qu'anotasti nes tos variedaes.", + "nothing": "Nada anotao pa {month}." + }, + "app": { + "title": "Tane" + }, + "bootstrap": { + "failed": "Tane nun pudo aniciar", + "retry": "Reintentar" + }, + "common": { + "save": "Guardar", + "cancel": "Encaboxar", + "delete": "Desaniciar", + "edit": "Editar", + "type": "Triba", + "comingSoon": "Aína" + }, + "home": { + "tagline": "Comparte y cultiva simiente llocal", + "openMarket": "Mercáu", + "openMarketSubtitle": "Descubri y comparte simiente cerca", + "yourInventory": "El to inventariu", + "yourInventorySubtitle": "Remana la to simiente" + }, + "photo": { + "camera": "Facer una semeya", + "gallery": "Escoyer de la galería", + "setAsCover": "Poner de portada", + "isCover": "Semeya de portada", + "deleteConfirm": "¿Desaniciar esta semeya?" + }, + "menu": { + "tagline": "el to bancu de simiente", + "inventory": "Inventariu", + "market": "Mercáu", + "profile": "El to perfil", + "chat": "Charra", + "wishlist": "Favoritos", + "following": "Siguiendo", + "plantares": "Plantares", + "sales": "Ventes", + "calendar": "Calendariu", + "settings": "Axustes" + }, + "settings": { + "language": "Llingua", + "systemLanguage": "Llingua del sistema", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langAst": "Asturianu", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "Tocante a", + "aboutText": "Inventariu llocal y cifráu pa simiente tradicional. AGPL-3.0.", + "aboutOpen": "Tocante a Tane" + }, + "backup": { + "title": "Copia de seguranza", + "autoBackupTitle": "Copies automátiques", + "autoBackupLast": "Cabera copia'l {date} · cada {days} díes", + "autoBackupNone": "Guárdase una copia automáticamente cada {days} díes", + "exportJson": "Guardar una copia de seguranza", + "exportJsonSubtitle": "Una copia completa pa guardar a salvo, restaurar dempués o pasar a otru preséu", + "importJson": "Restaurar una copia", + "importJsonSubtitle": "Recupera una copia guardada — nun se dobla nada", + "exportCsv": "Esportar a una fueya de cálculu", + "exportCsvSubtitle": "Una llista cenciella pa Excel o LibreOffice — ensin semeyes", + "importCsv": "Importar una llista", + "importCsvSubtitle": "Amiesta entraes dende una fueya de cálculu", + "importConfirmTitle": "¿Restaurar una copia?", + "importConfirmBody": "Les entraes fusiónense col to inventariu; si una entrada esiste en dambos llaos, gana la versión más nueva. Nun se dobla nada.", + "importCsvConfirmTitle": "¿Importar una llista?", + "importCsvConfirmBody": "Cada filera amiéstase como una entrada nueva. Nun fusiona nin troca, asina qu'importar el mesmu ficheru dos veces amiéstalu dos veces.", + "importAction": "Importar", + "exportSaved": "Copia guardada", + "cancelled": "Encaboxáu", + "importDone": "Importáu: {added} nueves, {updated} anovaes", + "importCsvDone": "Amestaes {count} entraes", + "importFailed": "Esti ficheru nun se pudo lleer como una copia de Tane", + "failed": "Daqué salió mal", + "recoveryTitle": "El to códigu de recuperación", + "recoverySubtitle": "Imprémelu y guárdalu bien: abre les tos copies n'otru preséu", + "recoveryIntro": "Esti códigu abre les tos copies guardaes y recupera'l to bancu en cualquier preséu. Guarda dos copies en papel en sitios seguros, como la to meyor simiente. Quien lu tenga pue lleer les tos copies, asina que nun lu compartas con naide.", + "recoveryCopy": "Copiar", + "recoverySave": "Guardar la fueya", + "recoverySheetTitle": "Tane — la to fueya de recuperación", + "recoveryPromptTitle": "Escribi'l to códigu de recuperación", + "recoveryPromptBody": "Esta copia guardóse con otru códigu. Escribi'l códigu de la to fueya de recuperación p'abrilla.", + "recoveryWrongCode": "Esi códigu nun abre esta copia" + }, + "about": { + "title": "Tocante a", + "kanji": "種", + "tagline": "Una app local-first y descentralizada pa remanar y compartir simiente y plantones tradicionales.", + "intro": "Tane (種, «grana» en xaponés) ayuda a persones y coleutivos a llevar un inventariu amable del so bancu de simiente, decidir qué ufierten y compartilo llocalmente — ensin un intermediariu central que pueda controlalo, censuralo o ser multáu por ello. El so nome vien de tanemaki (種まき), «semar / esparder simiente». L'oxetivu ye a la vez práuticu y políticu: sofitar les variedaes tradicionales de simiente y plantar cara al monopoliu simienteru.", + "heritage": "El nome honra les vieyes tradiciones xaponeses d'ayuda mutua alredor del arroz — yui (trabayu comunitariu compartíu) y tanomoshi (fondos de reciprocidá) — qu'inspiraron el papel Plantare, la «moneda comunitaria pal intercambiu de simiente» (BAH-Semilleru, 2009, CC-BY-SA). Tane ye'l Plantare dixital.", + "version": "Versión", + "license": "Llicencia", + "licenseValue": "AGPL-3.0", + "website": "Sitiu web", + "sourceCode": "Códigu fonte", + "translate": "Ayuda a traducir", + "translateSubtitle": "Ayuda a traer Tane a la to llingua", + "openSourceLicenses": "Llicencies de códigu abiertu", + "openSourceLicensesSubtitle": "Biblioteques de terceros y les sos llicencies", + "copyright": "© {years} Asociación Comunes, baxo AGPLv3" + }, + "intro": { + "skip": "Saltar", + "next": "Siguiente", + "start": "Entamar", + "menuEntry": "Cómo furrula Tane", + "slides": { + "welcome": { + "title": "La grana que te traxo hasta equí", + "body": "Cada simiente tradicional ye una carta escrita por miles de xeneraciones, que pasó de mano en mano. Somos lo que somos gracies a esi intercambiu." + }, + "inventory": { + "title": "El to bancu de simiente, nel bolsu", + "body": "Apunta qué tienes, de qué añu, cuánto y d'ónde vieno, col nome que tu uses. Una semeya y un nome abasten pa entamar." + }, + "privacy": { + "title": "Tuyo y namás tuyo", + "body": "Ensin cuenta, ensin internet, ensin rastrexadores. Los tos datos viven cifraos nel to preséu y namás se comparte lo que tu decidas." + }, + "share": { + "title": "Compartir, como siempres se fizo", + "body": "Ufierta lo que te sobra —regalu, truecu o venta— y qu'alguien cerca lo atope. Namás s'amuesa la distancia averada, enxamás la to direición; el tratu ciérraslu en persona." + }, + "plantare": { + "title": "Semar ye multiplicar", + "body": "Col Plantare, quien recibe promete devolver simiente más alantre. Y como pa devolvela hai que cultivala, cada préstamu multiplica'l común." + } + } + }, + "inventory": { + "title": "Inventariu", + "searchHint": "Guetar simiente", + "empty": "Entá nun hai simiente. Toca + p'amestar la primera.", + "noMatches": "Nenguna simiente concasa colos filtros.", + "clearFilters": "Quitar filtros", + "uncategorized": "Ensin categoría", + "needsReproductionFilter": "Por reproducir", + "loadError": "Nun se pudo abrir el to bancu de granes. Seique taba ocupáu: prueba otra vuelta.", + "retry": "Volver probar" + }, + "draft": { + "capture": "Capturar semeyes", + "captured": "{n} capturaes por catalogar", + "triageTitle": "Por catalogar", + "triageCount": "{n} por catalogar", + "untitled": "Ensin nome", + "nameField": "Ponle nome a esta simiente", + "nameHint": "¿Qué ye?", + "suggestFromPhoto": "Suxerir nome de la semeya", + "discard": "Descartar" + }, + "quickAdd": { + "title": "Amestar una simiente", + "labelField": "Nome", + "labelRequired": "Ponle un nome", + "addPhoto": "Amestar semeya", + "quantity": "¿Cuánta?", + "more": "Amestar más…", + "save": "Guardar", + "saveAndAddAnother": "Guardar y amestar otra", + "addedCount": "{n} amestaes", + "cancel": "Encaboxar" + }, + "detail": { + "notFound": "Esta simiente yá nun ta equí.", + "lots": "Llotes", + "noLots": "Entá nun hai llotes.", + "names": "Conocida tamién como", + "addName": "Amestar nome", + "links": "Enllaces", + "addLink": "Amestar enllaz", + "linkUrl": "URL", + "linkTitle": "Títulu (opcional)", + "reference": "Saber más", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", + "notes": "Notes", + "addLot": "Amestar llote", + "editLot": "Editar llote", + "deleteConfirm": "¿Desaniciar esta simiente?", + "year": "Añu {year}", + "noYear": "Añu desconocíu" + }, + "germination": { + "title": "Germinación", + "add": "Amestar prueba", + "sampleSize": "Amuesa", + "germinated": "Germinaes", + "none": "Entá nun hai pruebes de germinación.", + "result": "{percent}%" + }, + "viability": { + "expiringSoon": "Úsala o multiplícala esta temporada", + "expiringSoonYears": "Úsala o multiplícala esta temporada · dura ~{years} años", + "expired": "Pasa la so viabilidá típica — a multiplicar", + "expiredYears": "Pasa la so viabilidá típica (~{years} años) — a multiplicar" + }, + "editVariety": { + "title": "Editar simiente", + "name": "Nome", + "category": "Categoría", + "notes": "Notes", + "species": "Especie (del catálogu)", + "speciesHint": "Guetar una especie…", + "speciesSuggested": "Suxerida pol nome", + "organic": "Ecolóxica", + "organicHint": "Cultivada de mou ecolóxicu (eco)" + }, + "addLot": { + "title": "Amestar llote", + "year": "Data de coyecha", + "quantity": "¿Cuánta?", + "amount": "Cantidá" + }, + "harvest": { + "pickTitle": "Escoyer mes / añu", + "anyMonth": "Cualquier mes", + "noDate": "Indicar data de coyecha", + "monthNames": [ + "xineru", + "febreru", + "marzu", + "abril", + "mayu", + "xunu", + "xunetu", + "agostu", + "setiembre", + "ochobre", + "payares", + "avientu" + ] + }, + "lotType": { + "seed": "Simiente", + "plant": "Planta", + "seedling": "Planton", + "tree": "Árbol / matu", + "bulb": "Bulbu / tubérculu", + "cutting": "Esqueje" + }, + "presentation": { + "title": "Envase", + "none": "Ensin especificar", + "pot": "Tiestu", + "tray": "Bandexa", + "plug": "Alvéolu", + "bareRoot": "Raíz esnuda", + "rootBall": "Cepellón" + }, + "provenance": { + "section": "D'ónde vien", + "seedsFrom": "Simiente de", + "seedsFromHint": "Quién la cultivó o la dio", + "place": "Llugar", + "placeHint": "D'ónde vien (con provincia)", + "addSeedsFrom": "Simiente de", + "addPlace": "Llugar" + }, + "abundance": { + "add": "Cuánta tengo", + "title": "Cuánta tengo", + "none": "Ensin indicar", + "plentyToShare": "A esgaya pa compartir", + "enoughToShare": "Abondo, pa compartir con mesura", + "enoughForMe": "Abondo pa min", + "runningLow": "Queda poca" + }, + "share": { + "add": "¿Compártesla?", + "title": "¿Compártesla?", + "nudge": "Tienes a esgaya: podríes compartir un poco.", + "price": "Preciu", + "priceHint": "Déxalu baleru p'alcordalu depués", + "private": "Namás pa min", + "gift": "Pa regalar", + "exchange": "Pa trocar", + "sell": "En venta", + "filterChip": "Comparto", + "printCatalog": "Imprentar lo que comparto", + "catalogTitle": "Lo que comparto", + "catalogSaved": "Catálogu guardáu", + "cancelled": "Encaboxáu" + }, + "printLabels": { + "action": "Imprentar etiquetes", + "title": "Imprentar etiquetes", + "selectHint": "Escueyi les semientes pa imprentar les sos etiquetes", + "selectAll": "Seleicionar too", + "selected": "{n} seleicionaes", + "none": "Seleiciona semientes primero", + "format": "Tamañu d'etiqueta", + "formatStickers": "Pegatines pequeñes", + "formatStickersHint": "Munches etiquetes pequeñes per fueya — pa pegar en sobres", + "formatCards": "Tarxetes grandes", + "formatCardsHint": "Menos etiquetes, más grandes — pa botes y caxes", + "count": "{n} etiquetes", + "save": "Guardar etiquetes", + "saved": "Etiquetes guardaes", + "cancelled": "Encaboxáu" + }, + "cropCalendar": { + "add": "Calendariu de cultivu", + "title": "Calendariu de cultivu", + "sow": "Sema", + "transplant": "Tresplante", + "flowering": "Floriamientu", + "fruiting": "Fructificación", + "seedHarvest": "Coyecha de simiente", + "editorHint": "Anota tú los meses típicos d'esta variedá na to zona — son notes tuyes.", + "unset": "—" + }, + "needsReproduction": { + "label": "Reproducir esta temporada", + "hint": "Cultívala enantes de que s'acabe la simiente", + "badge": "Por reproducir" + }, + "preservation": { + "add": "Cómo se caltién", + "title": "Cómo se caltién", + "none": "Ensin especificar", + "jarWithDesiccant": "Bote con desecante", + "glassJar": "Bote de cristal", + "paperEnvelope": "Sobre de papel", + "paperBag": "Bolsa de papel", + "plasticBag": "Bolsa de plásticu" + }, + "conditionCheck": { + "advanced": "Caltenimientu y detalles del bancu", + "title": "Revisiones de caltenimientu", + "add": "Amestar revisión", + "containers": "Botes / recipientes", + "desiccant": "Desecante", + "none": "Entá nun hai revisiones.", + "summary": "{count} bote(s) · {state}" + }, + "desiccant": { + "none": "Nun tien", + "add": "Pónse", + "replace": "Cámbiase", + "dry": "Azul — secu", + "fresh": "Recién puestu" + }, + "unit": { + "aFew": "delles poques", + "some": "dalgunes", + "plenty": "munches", + "pinch": "una migaya", + "handful": { + "singular": "puñáu", + "plural": "puñaos" + }, + "teaspoon": { + "singular": "cuyaradina", + "plural": "cuyaradines" + }, + "spoon": { + "singular": "cuyar", + "plural": "cuyares" + }, + "cup": { + "singular": "taza", + "plural": "tazes" + }, + "jar": { + "singular": "bote", + "plural": "botes" + }, + "sack": { + "singular": "sacu", + "plural": "sacos" + }, + "packet": { + "singular": "sobre", + "plural": "sobres" + }, + "cob": { + "singular": "panoya", + "plural": "panoyes" + }, + "pod": { + "singular": "vaina", + "plural": "vaines" + }, + "ear": { + "singular": "espiga", + "plural": "espigues" + }, + "head": { + "singular": "cabezuela", + "plural": "cabezueles" + }, + "fruit": { + "singular": "frutu", + "plural": "frutos" + }, + "bulb": { + "singular": "bulbu", + "plural": "bulbos" + }, + "tuber": { + "singular": "tubérculu", + "plural": "tubérculos" + }, + "seedHead": { + "singular": "cabeza de simiente", + "plural": "cabeces de simiente" + }, + "bunch": { + "singular": "manoyu", + "plural": "manoyos" + }, + "plant": { + "singular": "planta", + "plural": "plantes" + }, + "pot": { + "singular": "tiestu", + "plural": "tiestos" + }, + "tray": { + "singular": "bandexa", + "plural": "bandexes" + }, + "seedling": { + "singular": "plántula", + "plural": "plántules" + }, + "tree": { + "singular": "árbol", + "plural": "árboles" + }, + "cutting": { + "singular": "esqueje", + "plural": "esquejes" + }, + "grams": { + "singular": "gramu", + "plural": "gramos" + }, + "count": { + "singular": "grana", + "plural": "granes" + } + }, + "market": { + "title": "Simiente cerca de ti", + "subtitle": "Lo qu'otres persones comparten cerca", + "notSetUp": "Entá nun configurasti'l compartir", + "notSetUpBody": "Activa'l compartir pa ver y dar simiente a xente cercano. Caltiénse averao — la to zona, enxamás la to direición esacta.", + "setUp": "Configurar el compartir", + "cantReach": "Nun se pue coneutar colos servidores agora mesmo", + "cantReachBody": "Inténtalo otra vez, o revisa los servidores na configuración avanzada.", + "retry": "Retentar", + "setArea": "Indica la to zona", + "setAreaBody": "Dile al mercáu la to zona averada pa ver simiente cerca.", + "searching": "Guetando pela to zona…", + "empty": "Entá nun hai simiente compartida cerca de ti", + "near": "Cerca de ti", + "contact": "Mensaxe", + "mine": "Tu", + "configTitle": "Configuración de compartir", + "setupIntro": "Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu'otres persones atopen lo qu'ufiertes, ensin nenguna empresa en mediu.", + "areaLabel": "La to zona", + "areaHelp": "Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.", + "areaSet": "La to zona ta puesta — averada, enxamás el to puntu esactu", + "areaNotSet": "Zona ensin definir — usa'l to allugamientu, o amiesta un códigu n'Avanzáu", + "advanced": "Avanzáu", + "areaCodeLabel": "Códigu de zona", + "areaCodeHint": "Un códigu curtiu como sp3e9 — non un nome de llugar", + "serversLabel": "Servidores de la comunidá", + "serversHelp": "Escueyi qué servidores usar. Déxalo asina si nun sabes.", + "serversAdvanced": "Amestar otru servidor", + "serverAddress": "Direición del servidor", + "serverInvalid": "Introduz una direición válida (wss://…)", + "save": "Guardar", + "saved": "Guardáu", + "wanted": "Gueto", + "shareMine": "Compartir la mio simiente", + "sharedCount": "Compartíes {n} simientes", + "nothingToShare": "Marca primero dalguna simiente pa regalar, trocar o vender", + "useLocation": "Usar el mio allugamientu averáu", + "locationFailed": "Nun se pudo consiguir el to allugamientu — comprueba que l'allugamientu ta activáu y el permisu concedíu", + "queued": "Guardáu — compartirémosles cuando tengas conexón", + "shareFailed": "Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.", + "rangeLabel": "Hasta ónde guetar", + "rangeNear": "Bien cerca", + "rangeArea": "Pela mio zona", + "rangeRegion": "La mio rexón", + "sharedBy": "Compartíu por", + "noProfile": "Esta persona entá nun compartió'l so perfil", + "copyId": "Copiar códigu", + "idCopied": "Códigu copiáu", + "photo": "Semeya" + }, + "profile": { + "title": "El to perfil", + "name": "Nome", + "nameHint": "Cómo te ven los demás", + "about": "Sobre ti", + "aboutHint": "Una llinia — qué cultives, ónde", + "g1": "Direición Ğ1 (opcional)", + "g1Hint": "Pa que te paguen en Ğ1 — aparte de la to clave", + "yourId": "La to identidá", + "idHelp": "Compártela pa que te reconozan", + "copy": "Copiar", + "copied": "Copiao", + "save": "Guardar", + "saved": "Perfil guardáu", + "identities": "Les tos identidaes", + "identitiesHelp": "Ten identidaes separaes — cada una coles sos mensaxes y contactos. Toes salen de la to única copia de seguridá, asina que camudar nun amiesta nada que recordar.", + "identityLabel": "Identidá {n}", + "current": "N'usu", + "newIdentity": "Identidá nueva", + "switchTitle": "¿Camudar d'identidá?", + "switchBody": "Los mensaxes y contactos guárdense per separao pa cada identidá. Pues volver cuando quieras.", + "switchAction": "Camudar" + }, + "chatList": { + "title": "Mensaxes", + "empty": "Entá nun hai charres. Escríbi-y a alguien dende'l mercáu." + }, + "chat": { + "title": "Charra", + "hint": "Escribi un mensaxe…", + "send": "Unviar", + "empty": "Entá nun hai mensaxes — saluda", + "offline": "Configura'l compartir pa unviar mensaxes", + "payG1": "Pagar en Ğ1", + "g1Copied": "Direición Ğ1 copiada — apégala na to cartera", + "today": "Güei", + "yesterday": "Ayeri", + "sendError": "Nun se pudo unviar — comprueba la conexón", + "noLinks": "Nun se permiten enllaces nos mensaxes" + }, + "trust": { + "none": "Naide los avala entá", + "count": "Avalada por {n}", + "vouch": "Conozo a esta persona", + "vouched": "Avales a esta persona", + "circle": "Nel to círculu" + }, + "yourPeople": { + "title": "La to xente", + "help": "Persones que conoces y avales, y persones que t'avalen.", + "youVouchFor": "Tu avales a", + "vouchesForYou": "Aválente", + "youVouchForEmpty": "Entá nun avales a naide. Cuando conozas a daquién, abri la vuestra conversación y calca \"Conozo a esta persona\".", + "vouchesForYouEmpty": "Naide t'avala entá", + "revoke": "Dexar d'avalar", + "revokeConfirm": "¿Dexar d'avalar a esta persona?", + "offline": "Ensin conexón — vuelvi tentalo cuando teas en llinia" + }, + "ratings": { + "rate": "Valorar a esta persona", + "edit": "Editar la to valoración", + "commentHint": "¿Qué tal foi? (opcional)", + "fromYourCircle": "{n} de xente que conoces", + "retract": "Quitar la to valoración", + "saved": "Valoración guardada" + }, + "notifications": { + "newMessageFrom": "Mensaxe nuevu de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Un Plantare ye'l compromisu de reproducir una semilla y devolver una parte — asina una variedá sigue viaxando de mano en mano. Nun ye una venta.", + "add": "Amestar compromisu", + "empty": "Entá nun hai compromisos. Cuando compartas o recibas semilla col compromisu de reproducila y devolver daqué, anótalo equí.", + "iReturn": "Reprodúzola y devuélvola yo", + "owedToMe": "Devuélvenmela a min", + "direction": "Quién reproduz y devuelve", + "counterparty": "¿Con quién?", + "counterpartyHint": "Una persona o un coleutivu (opcional)", + "owed": "¿Qué se devuelve?", + "owedHint": "p. ex. un puñáu la próxima temporada (opcional)", + "note": "Nota (opcional)", + "save": "Guardar", + "markReturned": "Marcar devueltu", + "markForgiven": "Dar por saldáu", + "reopen": "Reabrir", + "delete": "Quitar", + "statusReturned": "Devueltu", + "statusForgiven": "Saldáu", + "openSection": "Pendientes", + "settledSection": "Fechos", + "removeConfirm": "¿Quitar esti compromisu?", + "returnBy": "Devolver enantes del {date}", + "overdue": "vencíu", + "dueByLabel": "Devolver enantes de (opcional)", + "dueByHint": "Un recordatoriu suave, nunca obligatoriu", + "pickDate": "Escoyer fecha", + "clearDate": "Quitar fecha", + "sectionTitle": "Compromisos" + }, + "handover": { + "title": "Di o recibí semientes", + "help": "Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.", + "iGave": "Di semientes", + "iReceived": "Recibí semientes", + "whichLot": "¿De qué llote?", + "howMuch": "¿Cuánto?", + "allOfIt": "Too", + "partOfIt": "Una parte", + "paymentChip": "Hubo perres pel mediu", + "promiseGave": "Van devolveme semiente", + "promiseReceived": "Voi devolver semiente" + }, + "sale": { + "title": "Ventes", + "help": "Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.", + "add": "Rexistrar venta", + "empty": "Entá nun hai ventes. Anota equí lo que vendes o merques.", + "iSold": "Vendí", + "iBought": "Mercué", + "direction": "¿Vendes o merques?", + "counterparty": "¿Con quién?", + "counterpartyHint": "Una persona o un coleutivu (opcional)", + "amount": "Importe", + "currency": "Moneda", + "currencyHint": "€, Ğ1, hores… (opcional)", + "hours": "hores", + "note": "Nota (opcional)", + "save": "Guardar", + "delete": "Quitar", + "removeConfirm": "¿Quitar esta venta?" + }, + "legal": { + "title": "Privacidá y normes", + "subtitle": "La to privacidá, les normes del mercáu y la llegalidá de la simiente", + "privacyTitle": "La to privacidá", + "privacyBody": "Tane funciona ensin cuenta, y tolo que rexistres queda nel to preséu, cifrao. Nun hai publicidá, nin rastreadores, nin sirvidor de Tane.\n\nNun se comparte nada sacantes que tu quieras: cuando espublices una ofierta o'l to perfil, o unvíes un mensaxe, viaxa per sirvidores comuñales. Les ofiertes namái lleven una zona averada — enxamás la to direición.\n\nLo qu'espubliques ye público, y puen quedar copies mesmo dempués de retiralo — asina que comparte con procuru.", + "rulesTitle": "Les regles del xuegu", + "rulesBody": "Tane ye una ferramienta, non una tienda: los intercambeos alcuérdense direutamente ente persones y naide lleva comisión. Eso tamién quier dicir que tu respuendes de lo qu'ufiertes y unvíes.\n\nNel mercáu: sé honestu cola to simiente, ufierta namái lo que puedas compartir, trata bien a la xente y nun faigas spam. Puedes bloquiar a cualquiera y denunciar ofiertes o persones qu'incumplan les normes — les denuncies atiéndense nos sirvidores comuñales.", + "seedsTitle": "Tocante a compartir simiente y plantones", + "seedsBody": "Regalar ya intercambiar simiente ente aficionaos ta reconocío ampliamente na mayoría de países. Vender pue ser distinto: en munchos sitios, vender simiente de variedaes non rexistraes oficialmente ta restrinxío — consulta la to normativa enantes de pidir un preciu.\n\nUnviar simiente a otru país tamién suel tar restrinxío, y les variedaes protexíes comercialmente nun puen propagase ensin permisu. Ante la dulda, meyor local y meyor regalu.\n\nLo mesmo val pa plantones y plantes moces, pero la planta viva pue tener regles de tresporte y fitosanitaries más estrictes que la simiente: movela ente rexones o países pue requerir controles adicionales.", + "readFull": "Lleer los documentos completos na web" + }, + "marketGate": { + "title": "Enantes d'entrar al mercáu", + "intro": "El mercáu ye un espaciu compartíu ente vecines y vecinos. Al siguir, aceptes unes poques normes cencielles:", + "ruleHonest": "Sé honestu cola simiente qu'ufiertes", + "ruleLegal": "Comparte namái lo que puedas compartir onde vives", + "ruleRespect": "Trata bien a la xente — ensin spam nin abusos", + "publicNote": "Lo qu'espubliques equí ye público, y puen quedar copies anque lo retires dempués.", + "viewLegal": "Privacidá y normes", + "accept": "Acepto", + "decline": "Agora non" + }, + "report": { + "offer": "Denunciar esta ofierta", + "person": "Denunciar a esta persona", + "title": "Denunciar", + "prompt": "¿Qué pasa?", + "reasonSpam": "Spam o un engañu", + "reasonAbuse": "Abusivo o irrespetuoso", + "reasonIllegal": "Simiente que nun tendría d'ufiertase", + "reasonOther": "Otra cosa", + "detailsHint": "Amiesta detalles (opcional)", + "send": "Unviar denuncia", + "sentHidden": "Denuncia unviada — yá nun vas ver esto", + "failed": "Nun se pudo unviar la denuncia — revisa la conexón", + "alsoBlock": "Bloquiar tamién a esta persona" + }, + "block": { + "action": "Bloquiar a esta persona", + "confirmTitle": "¿Bloquiar a esta persona?", + "confirmBody": "Vas dexar de ver les sos ofiertes y mensaxes. Puedes desbloquiala más alantre en Persones bloquiaes, na configuración de compartir.", + "confirm": "Bloquiar", + "blockedToast": "Persona bloquiada — les sos ofiertes y mensaxes queden anubríos", + "manageTitle": "Persones bloquiaes", + "manageEmpty": "Nun bloquiesti a naide", + "unblock": "Desbloquiar" + } +} diff --git a/apps/app_seeds/lib/i18n/de.i18n.json b/apps/app_seeds/lib/i18n/de.i18n.json new file mode 100644 index 0000000..444b08b --- /dev/null +++ b/apps/app_seeds/lib/i18n/de.i18n.json @@ -0,0 +1,726 @@ +{ + "avatar": { + "title": "Dein Foto oder Avatar", + "fromPhoto": "Foto machen oder auswählen", + "illustration": "Oder wähle eine Illustration", + "remove": "Entfernen" + }, + "favorites": { + "title": "Favoriten", + "empty": "Noch keine Favoriten. Speichere Angebote, die dir auf dem Markt gefallen.", + "save": "Zu Favoriten speichern", + "remove": "Aus Favoriten entfernen", + "unavailable": "Nicht mehr verfügbar" + }, + "seedSaving": { + "title": "Samen vervielfältigen", + "subtitle": "Was nötig ist, um die Sorte echt zu erhalten", + "lifeCycle": "Zyklus", + "cycleAnnual": "Einjährig", + "cycleBiennial": "Zweijährig - Samen im 2. Jahr", + "cyclePerennial": "Mehrjährig", + "pollination": "Bestäubung", + "pollSelf": "Selbstbestäubend", + "pollCross": "Kreuzt mit anderen", + "pollMixed": "Selbstbestäubend, manchmal Fremdbefruchtung", + "byInsect": "durch Insekten", + "byWind": "durch Wind", + "isolation": "Abstand halten", + "isolationRange": "{min}–{max} m zu anderen Sorten", + "isolationSingle": "{min} m zu anderen Sorten", + "plants": "Samen von mehreren Pflanzen sammeln", + "plantsValue": "Von mindestens {n} Pflanzen", + "processing": "Samen reinigen", + "procDry": "Trockensamen (ausdreschen)", + "procWet": "Feuchtsamen (fermentieren und spülen)", + "difficulty": "Schwierigkeit", + "diffEasy": "Leicht", + "diffMedium": "Mittel", + "diffHard": "Schwer", + "advisory": "Allgemeine Hinweise - passe sie an dein Klima und deine Sorte an.", + "sourcePrefix": "Quelle" + }, + "calendar": { + "title": "Dieser Monat", + "filterChip": "Dieser Monat", + "selfNote": "Was du in deinen Sorten notiert hast.", + "nothing": "Nichts für {month} notiert." + }, + "app": { + "title": "Tane" + }, + "bootstrap": { + "failed": "Tane konnte nicht starten", + "retry": "Versuchen" + }, + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "type": "Typ", + "comingSoon": "Bald", + "offline": "Offline - Teilen ist unterbrochen" + }, + "home": { + "tagline": "Teile und baue lokale Samen an", + "openMarket": "Markt", + "openMarketSubtitle": "Entdecke und teile Samen in deiner Nähe", + "yourInventory": "Dein Bestand", + "yourInventorySubtitle": "Verwalte deine Samen" + }, + "photo": { + "camera": "Foto machen", + "gallery": "Aus Galerie wählen", + "setAsCover": "Als Deckblatt setzen", + "isCover": "Deckblatt-Foto", + "deleteConfirm": "Dieses Foto löschen?" + }, + "menu": { + "tagline": "deine Saatgutbank", + "inventory": "Bestand", + "market": "Markt", + "profile": "Dein Profil", + "chat": "Chat", + "wishlist": "Favoriten", + "following": "Gefolgt", + "plantares": "Plantares", + "sales": "Verkäufe", + "calendar": "Kalender", + "settings": "Einstellungen" + }, + "settings": { + "language": "Sprache", + "systemLanguage": "Systemsprache", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langAst": "Asturianu", + "about": "Über", + "aboutText": "Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.", + "aboutOpen": "Über Tane", + "langDe": "Deutsch", + "langFr": "Français", + "langJa": "日本語" + }, + "backup": { + "title": "Sicherung und Wiederherstellung", + "autoBackupTitle": "Automatische Sicherungen", + "autoBackupLast": "Letzte Sicherung {date} · alle {days} Tage", + "autoBackupNone": "Eine Kopie wird automatisch alle {days} Tage gespeichert", + "exportJson": "Sicherung speichern", + "exportJsonSubtitle": "Eine komplette Kopie zum sicheren Aufbewahren, später wiederherstellen oder auf ein anderes Gerät übertragen", + "importJson": "Sicherung wiederherstellen", + "importJsonSubtitle": "Bringe eine gespeicherte Kopie zurück - nichts wird dupliziert", + "exportCsv": "In eine Tabelle exportieren", + "exportCsvSubtitle": "Eine einfache Liste für Excel oder LibreOffice - ohne Fotos", + "importCsv": "Eine Liste importieren", + "importCsvSubtitle": "Einträge aus einer Tabelle hinzufügen", + "importConfirmTitle": "Sicherung wiederherstellen?", + "importConfirmBody": "Einträge werden mit deinem Bestand zusammengeführt; wenn ein Eintrag auf beiden Seiten existiert, gewinnt die neuere Version. Nichts wird dupliziert.", + "importCsvConfirmTitle": "Liste importieren?", + "importCsvConfirmBody": "Jede Zeile wird als neuer Eintrag hinzugefügt. Dies führt nicht zusammen und ersetzt nicht, also bringt zweimaliger Import dieselbe Datei zweimal ein.", + "importAction": "Importieren", + "exportSaved": "Kopie gespeichert", + "cancelled": "Abgebrochen", + "importDone": "Importiert: {added} neu, {updated} aktualisiert", + "importCsvDone": "{count} Einträge hinzugefügt", + "importFailed": "Diese Datei konnte nicht als Tane-Kopie gelesen werden", + "failed": "Etwas ist schief gelaufen", + "recoveryTitle": "Dein Wiederherstellungscode", + "recoverySubtitle": "Drucke ihn und bewahre ihn auf - er öffnet deine Kopien auf einem neuen Gerät", + "recoveryIntro": "Dieser Code öffnet deine gespeicherten Kopien und stellt deine Bank auf jedem Gerät wieder her. Bewahre zwei Papierkopien an sicheren Orten auf, wie dein bestes Saatgut. Jeder, der ihn hat, kann deine Kopien lesen, also teile ihn mit niemandem.", + "recoveryCopy": "Kopieren", + "recoverySave": "Blatt speichern", + "recoverySheetTitle": "Tane - dein Wiederherstellungsblatt", + "recoveryPromptTitle": "Gib deinen Wiederherstellungscode ein", + "recoveryPromptBody": "Diese Kopie wurde mit einem anderen Code gespeichert. Gib den Code von deinem Wiederherstellungsblatt ein, um sie zu öffnen.", + "recoveryWrongCode": "Dieser Code öffnet diese Kopie nicht" + }, + "about": { + "title": "Über", + "kanji": "種", + "tagline": "Eine lokal speichernde, dezentralisierte App zum Verwalten und Teilen traditioneller Samen und Setzlinge.", + "intro": "Tane (種, 'Samen' auf Japanisch) hilft Menschen und Gemeinschaften, eine freundliche Saatgutbank zu führen, zu entscheiden, was sie anbieten, und es lokal zu teilen - ohne einen zentralen Vermittler, der es kontrollieren, zensieren oder dafür bestraft werden könnte. Der Name kommt von tanemaki (種まき), 'säen / Samen verstreuen'. Das Ziel ist praktisch und politisch zugleich: traditionelle Samensorten unterstützen und gegen das Saatgutmonopol angehen.", + "heritage": "Der Name würdigt alte japanische Traditionen der gegenseitigen Hilfe rund um Reis - yui (gemeinsame Gemeinschaftsarbeit) und tanomoshi (Gegenseitigkeitsfonds) - die das Papier Plantare inspiriert haben, die 'Gemeinschaftswährung für Samenaustausch' (BAH-Semillero, 2009, CC-BY-SA). Tane ist das digitale Plantare.", + "version": "Version", + "license": "Lizenz", + "licenseValue": "AGPL-3.0", + "website": "Webseite", + "sourceCode": "Quellcode", + "translate": "Beim Übersetzen helfen", + "translateSubtitle": "Hilf, Tane in deine Sprache zu bringen", + "openSourceLicenses": "Open-Source-Lizenzen", + "openSourceLicensesSubtitle": "Bibliotheken von Drittanbietern und ihre Lizenzen", + "copyright": "© {years} Comunes Association, unter AGPLv3" + }, + "intro": { + "skip": "Überspringen", + "next": "Nächste", + "start": "Anfangen", + "menuEntry": "Wie Tane funktioniert", + "slides": { + "welcome": { + "title": "Der Samen, der dich hierher brachte", + "body": "Jeder traditionelle Samen ist ein Brief, geschrieben von Tausenden von Generationen, weitergegeben von Hand zu Hand. Wir sind, was wir sind, dank dieses Teilens." + }, + "inventory": { + "title": "Deine Saatgutbank in deiner Tasche", + "body": "Notiere, was du hast, von welchem Jahr, wie viel und woher es kam - mit dem Namen, den du verwendest. Ein Foto und ein Name reichen aus, um zu beginnen." + }, + "privacy": { + "title": "Deins und nur deins", + "body": "Kein Konto, kein Internet, keine Tracker. Deine Daten leben verschlüsselt auf deinem Gerät, und nur das, das du wählst, wird je geteilt." + }, + "share": { + "title": "Teilen, wie es immer gemacht wurde", + "body": "Biete an, was du übrig hast - Geschenk, Tausch oder Verkauf - und lass jemanden in deiner Nähe es finden. Nur ein ungefährer Abstand wird gezeigt, nie deine Adresse; du schließt das Geschäft von Angesicht zu Angesicht." + }, + "plantare": { + "title": "Säen ist vervielfältigen", + "body": "Bei einem Plantare verspricht, wer einen Samen erhält, später etwas zurückzugeben. Und da Zurückgeben bedeutet, es anzubauen, vervielfältigt jede Leihe das Gemeingut." + } + } + }, + "inventory": { + "title": "Bestand", + "searchHint": "Samen suchen", + "empty": "Noch keine Samen. Tippe +, um deine erste hinzuzufügen.", + "noMatches": "Keine Samen passen zu deinen Filtern.", + "clearFilters": "Filter löschen", + "uncategorized": "Unkategorisiert", + "needsReproductionFilter": "Zu vermehren", + "loadError": "Konnte deine Saatgutbank nicht öffnen. Sie war vielleicht beschäftigt - versuche es erneut.", + "retry": "Versuchen" + }, + "draft": { + "capture": "Fotos erfassen", + "captured": "{n} erfasst zum Katalogisieren", + "triageTitle": "Zum Katalogisieren", + "triageCount": "{n} zum Katalogisieren", + "untitled": "Unbenannt", + "nameField": "Benenne diesen Samen", + "nameHint": "Was ist es?", + "suggestFromPhoto": "Namen aus Foto vorschlagen", + "discard": "Verwerfen" + }, + "quickAdd": { + "title": "Einen Samen hinzufügen", + "labelField": "Name", + "labelRequired": "Gib ihm einen Namen", + "addPhoto": "Foto hinzufügen", + "quantity": "Wie viel?", + "more": "Mehr hinzufügen…", + "save": "Speichern", + "saveAndAddAnother": "Speichern und noch einen hinzufügen", + "addedCount": "{n} hinzugefügt", + "cancel": "Abbrechen" + }, + "detail": { + "notFound": "Dieser Samen ist nicht mehr hier.", + "lots": "Partien", + "noLots": "Noch keine Partien.", + "names": "Auch bekannt als", + "addName": "Namen hinzufügen", + "links": "Links", + "addLink": "Link hinzufügen", + "linkUrl": "URL", + "linkTitle": "Titel (optional)", + "reference": "Mehr erfahren", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", + "notes": "Notizen", + "addLot": "Partie hinzufügen", + "editLot": "Partie bearbeiten", + "deleteConfirm": "Diesen Samen löschen?", + "year": "Jahr {year}", + "noYear": "Jahr unbekannt" + }, + "germination": { + "title": "Keimung", + "add": "Test hinzufügen", + "sampleSize": "Stichprobengröße", + "germinated": "Gekeimt", + "none": "Noch keine Keimtests.", + "result": "{percent}%" + }, + "viability": { + "expiringSoon": "Verwende oder vervielfältige diese Saison", + "expiringSoonYears": "Verwende oder vervielfältige diese Saison · hält ~{years} Jahre", + "expired": "Über typische Keimfähigkeit hinaus - zu vervielfältigen", + "expiredYears": "Über typische Keimfähigkeit (~{years} Jahre) - zu vervielfältigen" + }, + "editVariety": { + "title": "Samen bearbeiten", + "name": "Name", + "category": "Kategorie", + "notes": "Notizen", + "species": "Art (aus Katalog)", + "speciesHint": "Durchsuche eine Art…", + "speciesSuggested": "Aus dem Namen vorgeschlagen", + "organic": "Bio", + "organicHint": "Biologisch angebaut (Öko)" + }, + "addLot": { + "title": "Partie hinzufügen", + "year": "Erntedate", + "quantity": "Wie viel?", + "amount": "Menge" + }, + "harvest": { + "pickTitle": "Wähle Monat / Jahr", + "anyMonth": "Jeder Monat", + "noDate": "Erntedate setzen", + "monthNames": [ + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember" + ] + }, + "lotType": { + "seed": "Samen", + "plant": "Pflanze", + "seedling": "Setzling", + "tree": "Baum / Strauch", + "bulb": "Zwiebel / Knolle", + "cutting": "Steckling" + }, + "presentation": { + "title": "Verpackung", + "none": "Unspezifisch", + "pot": "Topf", + "tray": "Tablett", + "plug": "Topfplatte", + "bareRoot": "Nacktwurzel", + "rootBall": "Wurzelballen" + }, + "provenance": { + "section": "Woher es kommt", + "seedsFrom": "Samen von", + "seedsFromHint": "Wer sie anbaute oder gab", + "place": "Ort", + "placeHint": "Woher sie kommen (mit Gegend)", + "addSeedsFrom": "Samen von", + "addPlace": "Ort" + }, + "abundance": { + "add": "Wie viel ich habe", + "title": "Wie viel ich habe", + "none": "Nicht gesetzt", + "plentyToShare": "Reichlich zum Teilen", + "enoughToShare": "Genug zum etwas Teilen", + "enoughForMe": "Genug für mich", + "runningLow": "Wird knapp" + }, + "share": { + "add": "Teilst du es?", + "title": "Teilst du es?", + "nudge": "Du hast reichlich - du könntest etwas teilen.", + "price": "Preis", + "priceHint": "Lass es leer, um es später zu vereinbaren", + "private": "Nur für mich", + "gift": "Zum Verschenken", + "exchange": "Zum Tauschen", + "sell": "Zum Verkauf", + "filterChip": "Ich teile", + "printCatalog": "Drucke, was ich teile", + "catalogTitle": "Was ich teile", + "catalogSaved": "Katalog gespeichert", + "cancelled": "Abgebrochen" + }, + "printLabels": { + "action": "Etiketten drucken", + "title": "Etiketten drucken", + "selectHint": "Wähle die Samen, für die du Etiketten drucken möchtest", + "selectAll": "Alles auswählen", + "selected": "{n} ausgewählt", + "none": "Wähle zuerst Samen aus", + "format": "Etikettengröße", + "formatStickers": "Kleine Aufkleber", + "formatStickersHint": "Viele kleine Etiketten pro Seite - zum Aufkleben auf Päckchen", + "formatCards": "Große Karten", + "formatCardsHint": "Weniger, größere Etiketten - für Gläser und Boxen", + "count": "{n} Etiketten", + "save": "Etiketten speichern", + "saved": "Etiketten gespeichert", + "cancelled": "Abgebrochen" + }, + "cropCalendar": { + "add": "Anbaukalender", + "title": "Anbaukalender", + "sow": "Säen", + "transplant": "Pflanzen", + "flowering": "Blüte", + "fruiting": "Fruchtbildung", + "seedHarvest": "Samenernte", + "editorHint": "Notiere die typischen Monate für diese Sorte in deinem Gebiet - das sind deine eigenen Notizen.", + "unset": "—" + }, + "needsReproduction": { + "label": "Diese Saison vermehren", + "hint": "Baue es an, bevor der Samen ausgeht", + "badge": "Zu vermehren" + }, + "preservation": { + "add": "Wie es aufbewahrt wird", + "title": "Wie es aufbewahrt wird", + "none": "Unspezifisch", + "jarWithDesiccant": "Glas mit Trockenmittel", + "glassJar": "Glasgefäß", + "paperEnvelope": "Papierumschlag", + "paperBag": "Papierbeutel", + "plasticBag": "Plastikbeutel" + }, + "conditionCheck": { + "advanced": "Lagerung und Saatgutbank-Details", + "title": "Lagerchecks", + "add": "Check hinzufügen", + "containers": "Gläser / Behälter", + "desiccant": "Trockenmittel", + "none": "Noch keine Lagerchecks.", + "summary": "{count} Glas(gläser) · {state}" + }, + "desiccant": { + "none": "Keine", + "add": "Eine hinzufügen", + "replace": "Austauschen", + "dry": "Blau - trocken", + "fresh": "Gerade erneuert" + }, + "unit": { + "aFew": "einige", + "some": "einige", + "plenty": "viele", + "pinch": "eine Prise", + "handful": { + "singular": "Handvoll", + "plural": "Handvoll" + }, + "teaspoon": { + "singular": "Teelöffel", + "plural": "Teelöffel" + }, + "spoon": { + "singular": "Löffel", + "plural": "Löffel" + }, + "cup": { + "singular": "Tasse", + "plural": "Tassen" + }, + "jar": { + "singular": "Glas", + "plural": "Gläser" + }, + "sack": { + "singular": "Sack", + "plural": "Säcke" + }, + "packet": { + "singular": "Päckchen", + "plural": "Päckchen" + }, + "cob": { + "singular": "Kolben", + "plural": "Kolben" + }, + "pod": { + "singular": "Schote", + "plural": "Schoten" + }, + "ear": { + "singular": "Ähre", + "plural": "Ähren" + }, + "head": { + "singular": "Kopf", + "plural": "Köpfe" + }, + "fruit": { + "singular": "Frucht", + "plural": "Früchte" + }, + "bulb": { + "singular": "Zwiebel", + "plural": "Zwiebeln" + }, + "tuber": { + "singular": "Knolle", + "plural": "Knollen" + }, + "seedHead": { + "singular": "Samenkopf", + "plural": "Samenköpfe" + }, + "bunch": { + "singular": "Bund", + "plural": "Bunde" + }, + "plant": { + "singular": "Pflanze", + "plural": "Pflanzen" + }, + "pot": { + "singular": "Topf", + "plural": "Töpfe" + }, + "tray": { + "singular": "Tablett", + "plural": "Tabletts" + }, + "seedling": { + "singular": "Setzling", + "plural": "Setzlinge" + }, + "tree": { + "singular": "Baum", + "plural": "Bäume" + }, + "cutting": { + "singular": "Steckling", + "plural": "Stecklinge" + }, + "grams": { + "singular": "Gramm", + "plural": "Gramm" + }, + "count": { + "singular": "Samen", + "plural": "Samen" + } + }, + "market": { + "title": "Samen in deiner Nähe", + "subtitle": "Was andere in der Nähe teilen", + "notSetUp": "Teilen ist noch nicht eingerichtet", + "notSetUpBody": "Aktiviere Teilen, um Samen zu sehen und mit Menschen in der Nähe zu teilen. Es bleibt ungefähr - deine Zone, nie deine genaue Adresse.", + "setUp": "Teilen einrichten", + "cantReach": "Kann die Gemeinschaftsserver gerade nicht erreichen", + "cantReachBody": "Versuche es erneut, oder überprüfe die Server unter Erweiterte Einrichtung.", + "retry": "Erneut versuchen", + "setArea": "Stelle dein Gebiet ein", + "setAreaBody": "Teile dem Markt ungefähr mit, wo du bist, um Samen in deiner Nähe zu sehen.", + "searching": "Schaue nach deinem Gebiet…", + "empty": "Noch keine Samen in deiner Nähe geteilt", + "searchHint": "Suche nach diesen Samen", + "noMatches": "Keine geteilten Samen passen zu deiner Suche", + "near": "In deiner Nähe", + "contact": "Nachricht", + "mine": "Du", + "configTitle": "Teilen-Einrichtung", + "setupIntro": "Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.", + "areaLabel": "Dein Gebiet", + "areaHelp": "Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.", + "areaSet": "Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt", + "areaNotSet": "Gebiet noch nicht gesetzt - verwende deinen Standort oder füge einen Code unter Erweiterte hinzu", + "advanced": "Erweiterte", + "areaCodeLabel": "Gebiets-Code", + "areaCodeHint": "Ein kurzer Code wie sp3e9 - kein Ortsname", + "serversLabel": "Gemeinschaftsserver", + "serversHelp": "Wähle, welche Server verwendet werden. Lass die Standardwerte, wenn du dir nicht sicher bist.", + "serversAdvanced": "Füge einen weiteren Server hinzu", + "serverAddress": "Serveradresse", + "serverInvalid": "Gib eine gültige Adresse ein (wss://…)", + "save": "Speichern", + "saved": "Gespeichert", + "wanted": "Gesucht", + "shareMine": "Meine Samen teilen", + "sharedCount": "{n} Samen in der Nähe geteilt", + "nothingToShare": "Markiere zuerst einige Samen zum Verschenken, Tauschen oder Verkaufen", + "useLocation": "Meinen ungefähren Standort verwenden", + "locationFailed": "Konnte deinen Standort nicht ermitteln - überprüfe, dass Standort aktiviert ist und die Berechtigung erteilt wurde", + "queued": "Gespeichert - wir werden diese teilen, wenn du verbunden bist", + "shareFailed": "Konnte die Gemeinschaftsserver nicht erreichen - deine Samen wurden nicht geteilt. Versuche es gleich erneut.", + "rangeLabel": "Wie weit zum Suchen", + "rangeNear": "Sehr nah", + "rangeArea": "Um mich herum", + "rangeRegion": "Meine Region", + "sharedBy": "Geteilt von", + "noProfile": "Diese Person hat noch kein Profil geteilt", + "copyId": "Code kopieren", + "idCopied": "Code kopiert", + "photo": "Foto" + }, + "profile": { + "title": "Dein Profil", + "name": "Anzeigename", + "nameHint": "Wie dich andere sehen", + "about": "Über", + "aboutHint": "Eine kurze Zeile - was du anbaust, wo", + "g1": "Ğ1-Adresse (optional)", + "g1Hint": "Damit dich Leute in Ğ1 bezahlen können - getrennt von deinem Schlüssel", + "yourId": "Deine Identität", + "idHelp": "Teile dies, damit dich Leute erkennen", + "copy": "Kopieren", + "copied": "Kopiert", + "save": "Speichern", + "saved": "Profil gespeichert", + "identities": "Deine Identitäten", + "identitiesHelp": "Halte separate Identitäten - jede mit ihren eigenen Nachrichten und Kontakten. Alle kommen aus deiner einen Sicherung, also Wechsel fügt nichts hinzu, das man sich merken muss.", + "identityLabel": "Identität {n}", + "current": "In Gebrauch", + "newIdentity": "Neue Identität", + "switchTitle": "Identität wechseln?", + "switchBody": "Nachrichten und Kontakte werden für jede Identität getrennt aufbewahrt. Du kannst jederzeit zurückwechseln.", + "switchAction": "Wechsel" + }, + "chatList": { + "title": "Nachrichten", + "empty": "Noch keine Gespräche. Schreibe jemanden vom Markt an." + }, + "chat": { + "title": "Chat", + "hint": "Schreibe eine Nachricht…", + "send": "Senden", + "empty": "Noch keine Nachrichten - sag hallo", + "offline": "Richte Teilen ein, um Nachrichten zu senden", + "payG1": "In Ğ1 bezahlen", + "g1Copied": "Ğ1-Adresse kopiert - füge sie in dein Portemonnaie ein", + "today": "Heute", + "yesterday": "Gestern", + "sendError": "Konnte nicht senden - überprüfe deine Verbindung", + "noLinks": "Links sind in Nachrichten nicht erlaubt" + }, + "trust": { + "none": "Noch niemand bürgt für sie", + "count": "Von {n} bestätigt", + "vouch": "Ich kenne diese Person", + "vouched": "Du bürgst für sie", + "circle": "In deinem Kreis" + }, + "yourPeople": { + "title": "Deine Leute", + "help": "Menschen, die du kennst und für die du einstehst, und Menschen, die für dich einstehen.", + "youVouchFor": "Du stehst ein für", + "vouchesForYou": "Sie stehen für dich ein", + "youVouchForEmpty": "Du stehst noch für niemanden ein. Wenn du jemanden triffst, öffne deinen Chat mit ihnen und tippe 'Ich kenne diese Person'.", + "vouchesForYouEmpty": "Noch niemand steht für dich ein", + "revoke": "Nicht mehr einstehen", + "revokeConfirm": "Nicht mehr für diese Person einstehen?", + "offline": "Du bist offline - versuche es, wenn du verbunden bist" + }, + "ratings": { + "rate": "Bewerte diese Person", + "edit": "Bearbeite deine Bewertung", + "commentHint": "Wie war es? (optional)", + "fromYourCircle": "{n} von Leuten, die du kennst", + "retract": "Entferne deine Bewertung", + "saved": "Bewertung gespeichert" + }, + "notifications": { + "newMessageFrom": "Neue Nachricht von {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Ein Plantare ist ein Versprechen, einen Samen zu vervielfältigen und etwas zurückzugeben - wie eine Sorte von Hand zu Hand reist. Es ist kein Verkauf.", + "add": "Verpflichtung hinzufügen", + "empty": "Noch keine Verpflichtungen. Wenn du einen Samen mit dem Versprechen teilst oder erhältst, ihn anzubauen und etwas zurückzugeben, notiere es hier.", + "iReturn": "Ich baue es an und gebe es zurück", + "owedToMe": "Sie geben mir etwas zurück", + "direction": "Wer baut an und gibt zurück", + "counterparty": "Mit wem?", + "counterpartyHint": "Eine Person oder ein Kollektiv (optional)", + "owed": "Was kommt zurück?", + "owedHint": "z.B. eine Handvoll nächste Saison (optional)", + "note": "Notiz (optional)", + "save": "Speichern", + "markReturned": "Als zurückgegeben markieren", + "markForgiven": "Absehen lassen", + "reopen": "Erneut öffnen", + "delete": "Entfernen", + "statusReturned": "Zurückgegeben", + "statusForgiven": "Erledigt", + "openSection": "Offen", + "settledSection": "Erledigt", + "removeConfirm": "Diese Verpflichtung entfernen?" + }, + "handover": { + "title": "Samen wechselten den Besitzer", + "help": "Ein Geschenk, ein Tausch oder ein Verkauf - notiere es auf, mit oder ohne Rückgabeversprechen.", + "iGave": "Ich gab Samen", + "iReceived": "Ich erhielt Samen", + "whichLot": "Welche Partie?", + "howMuch": "Wie viel?", + "allOfIt": "Alles", + "partOfIt": "Ein Teil", + "paymentChip": "Geld wechselte den Besitzer", + "promiseGave": "Sie geben mir Samen zurück", + "promiseReceived": "Ich gebe Samen zurück" + }, + "sale": { + "title": "Verkäufe", + "help": "Notiere Samen, die du verkauft oder gekauft hast - Geld, Ğ1 oder jede Währung. Ein eigenes Modell abseits Geschenk und Plantare. Es wird nie eine Kommission auf Samen erhoben.", + "add": "Verkauf notieren", + "empty": "Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.", + "iSold": "Ich verkaufte", + "iBought": "Ich kaufte", + "direction": "Verkauft oder gekauft?", + "counterparty": "Mit wem?", + "counterpartyHint": "Eine Person oder ein Kollektiv (optional)", + "amount": "Betrag", + "currency": "Währung", + "currencyHint": "€, Ğ1, Stunden… (optional)", + "hours": "Stunden", + "note": "Notiz (optional)", + "save": "Speichern", + "delete": "Entfernen", + "removeConfirm": "Diesen Verkauf entfernen?" + }, + "legal": { + "title": "Datenschutz und Regeln", + "subtitle": "Dein Datenschutz, die Marktregeln und die Legalität von Samen", + "privacyTitle": "Dein Datenschutz", + "privacyBody": "Tane funktioniert ohne Konto, und alles, das du notierst, bleibt auf deinem Gerät, verschlüsselt. Es gibt keine Werbung, keine Tracker und keinen Tane-Server.\n\nNichts wird geteilt, es sei denn, du wählst es: Wenn du ein Angebot oder dein Profil veröffentlichst oder eine Nachricht sendest, reist es durch Gemeinschaftsserver. Angebote tragen nur eine ungefähre Zone - nie deine Adresse.\n\nWas du veröffentlichst, ist öffentlich, und Kopien können bleiben, selbst nachdem du es entfernt hast - teile also mit Bedacht.", + "rulesTitle": "Die Spielregeln", + "rulesBody": "Tane ist ein Werkzeug, kein Laden: Austausch wird direkt zwischen Menschen vereinbart, und niemand nimmt eine Provision. Das bedeutet auch, dass du verantwortlich bist für das, das du anbietest und sendest.\n\nAuf dem Markt: Sei ehrlich mit deinen Samen, biete nur an, was du teilen darfst, behandle Menschen gut und spamme nicht. Du kannst jeden blockieren und Angebote oder Menschen melden, die die Regeln brechen - Meldungen werden auf den Gemeinschaftsservern bearbeitet.", + "seedsTitle": "Über das Teilen von Samen und Setzlingen", + "seedsBody": "Das Verschenken und Tauschen von Samen zwischen Hobbyisten ist in den meisten Ländern weit verbreitet. Verkaufen kann anders sein: An vielen Orten ist der Verkauf von Samen nicht offiziell registrierter Sorten eingeschränkt - überprüfe deine örtlichen Vorschriften, bevor du einen Preis fragst.\n\nSamen in ein anderes Land zu schicken ist auch oft eingeschränkt, und kommerziell geschützte Sorten dürfen nicht ohne Genehmigung vermehrt werden. Im Zweifelsfall lieber lokal und lieber Geschenk.\n\nDasselbe gilt für Setzlinge und Jungpflanzen, aber lebende Pflanzen können strengeren Transport- und Pflanzenschutzregeln unterliegen als Samen — sie über Regionen oder Grenzen zu bewegen kann zusätzliche Kontrollen erfordern.", + "readFull": "Lese die vollständigen Dokumente online" + }, + "marketGate": { + "title": "Bevor du den Markt betrittst", + "intro": "Der Markt ist ein gemeinsamer Raum zwischen Nachbarn. Wenn du fortfährst, akzeptierst du einige einfache Regeln:", + "ruleHonest": "Sei ehrlich mit den Samen, die du anbietest", + "ruleLegal": "Teile nur, was du teilen darfst, wo du lebst", + "ruleRespect": "Behandle Menschen gut - kein Spam, kein Missbrauch", + "publicNote": "Was du hier veröffentlichst, ist öffentlich, und Kopien können bleiben, selbst wenn du es später entfernst.", + "viewLegal": "Datenschutz und Regeln", + "accept": "Ich akzeptiere", + "decline": "Nicht jetzt" + }, + "report": { + "offer": "Melde dieses Angebot", + "person": "Melde diese Person", + "title": "Melden", + "prompt": "Was ist falsch?", + "reasonSpam": "Spam oder Betrug", + "reasonAbuse": "Beleidigend oder respektlos", + "reasonIllegal": "Samen, die nicht angeboten werden sollten", + "reasonOther": "Etwas anderes", + "detailsHint": "Füge Details hinzu (optional)", + "send": "Meldung senden", + "sentHidden": "Meldung gesendet - du wirst dies nicht mehr sehen", + "failed": "Konnte die Meldung nicht senden - überprüfe deine Verbindung", + "alsoBlock": "Diese Person auch blockieren" + }, + "block": { + "action": "Diese Person blockieren", + "confirmTitle": "Diese Person blockieren?", + "confirmBody": "Du wirst ihre Angebote und Nachrichten nicht mehr sehen. Du kannst sie später unter Blockierte Leute in der Teilen-Einrichtung entsperren.", + "confirm": "Blockieren", + "blockedToast": "Blockiert - ihre Angebote und Nachrichten sind verborgen", + "manageTitle": "Blockierte Leute", + "manageEmpty": "Du hast niemanden blockiert", + "unblock": "Entsperren" + } +} \ No newline at end of file diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 1d90cab..97d0ccc 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -1,333 +1,756 @@ { - "app": { - "title": "Tanemaki" - }, - "common": { - "save": "Save", - "cancel": "Cancel", - "delete": "Delete", - "edit": "Edit", - "type": "Type", - "comingSoon": "Coming soon" - }, - "home": { - "tagline": "Share and grow local seeds", - "openMarket": "Open market", - "openMarketSubtitle": "Browse and exchange", - "yourInventory": "Your inventory", - "yourInventorySubtitle": "Manage your seeds" - }, - "photo": { - "camera": "Take a photo", - "gallery": "Choose from gallery", - "setAsCover": "Set as cover", - "isCover": "Cover photo", - "deleteConfirm": "Delete this photo?" - }, - "menu": { - "tagline": "your seed bank", - "inventory": "Inventory", - "market": "Market", - "profile": "Your profile", - "chat": "Chat", - "wishlist": "Wishlist", - "following": "Following", - "settings": "Settings" - }, - "settings": { - "language": "Language", - "systemLanguage": "System language", - "langEs": "Español", - "langEn": "English", - "langPt": "Português", - "about": "About", - "aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.", - "aboutOpen": "About Tanemaki" - }, - "backup": { - "title": "Backup & restore", - "exportJson": "Save a backup", - "exportJsonSubtitle": "A complete copy to keep safe, restore later or move to another device", - "importJson": "Restore a backup", - "importJsonSubtitle": "Bring a saved copy back — nothing gets duplicated", - "exportCsv": "Export to a spreadsheet", - "exportCsvSubtitle": "A simple list for Excel or LibreOffice — no photos", - "importCsv": "Import a list", - "importCsvSubtitle": "Add entries from a spreadsheet", - "importConfirmTitle": "Restore a backup?", - "importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.", - "importCsvConfirmTitle": "Import a list?", - "importCsvConfirmBody": "Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.", - "importAction": "Import", - "exportSaved": "Copy saved", - "cancelled": "Cancelled", - "importDone": "Imported: {added} new, {updated} updated", - "importCsvDone": "Added {count} entries", - "importFailed": "This file could not be read as a Tanemaki copy", - "failed": "Something went wrong", - "recoveryTitle": "Your recovery code", - "recoverySubtitle": "Print it and keep it safe — it opens your copies on a new device", - "recoveryIntro": "This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.", - "recoveryCopy": "Copy", - "recoverySave": "Save the sheet", - "recoverySheetTitle": "Tanemaki — your recovery sheet", - "recoveryPromptTitle": "Enter your recovery code", - "recoveryPromptBody": "This copy was saved with another code. Type the code from your recovery sheet to open it.", - "recoveryWrongCode": "That code doesn't open this copy" - }, - "about": { - "title": "About", - "kanji": "種まき", - "tagline": "A local-first, decentralized app for managing and sharing traditional seeds and seedlings.", - "intro": "Tanemaki (種まき, \"to sow / scatter seeds\" in Japanese; short name Tane, 種 \"seed\") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.", - "heritage": "The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the \"community currency for seed exchange\" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare.", - "version": "Version", - "license": "License", - "licenseValue": "AGPL-3.0", - "website": "Website", - "openSourceLicenses": "Open-source licenses", - "openSourceLicensesSubtitle": "Third-party libraries and their licenses", - "copyright": "© {years} Comunes Association, under AGPLv3" - }, - "intro": { - "skip": "Skip", - "next": "Next", - "start": "Get started", - "menuEntry": "How Tanemaki works", - "slides": { - "welcome": { - "title": "The seed that brought you here", - "body": "Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing." - }, - "inventory": { - "title": "Your seed bank, in your pocket", - "body": "Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start." - }, - "privacy": { - "title": "Yours, and only yours", - "body": "No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared." - }, - "share": { - "title": "Share, the way it's always been done", - "body": "Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person." - }, - "plantare": { - "title": "To sow is to multiply", - "body": "With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons." - } - } - }, - "inventory": { - "title": "Inventory", - "searchHint": "Search seeds", - "empty": "No seeds yet. Tap + to add your first.", - "noMatches": "No seeds match your filters.", - "clearFilters": "Clear filters", - "uncategorized": "Uncategorized", - "needsReproductionFilter": "To regrow" - }, - "draft": { - "capture": "Capture photos", - "captured": "{n} captured to catalogue", - "triageTitle": "To catalogue", - "triageCount": "{n} to catalogue", - "untitled": "Unnamed", - "nameField": "Name this seed", - "nameHint": "What is it?", - "suggestFromPhoto": "Suggest name from photo", - "discard": "Discard" - }, - "quickAdd": { - "title": "Add a seed", - "labelField": "Name", - "labelRequired": "Give it a name", - "addPhoto": "Add photo", - "quantity": "How much?", - "more": "Add more…", - "save": "Save", - "saveAndAddAnother": "Save & add another", - "addedCount": "{n} added", - "cancel": "Cancel" - }, - "detail": { - "notFound": "This seed is no longer here.", - "lots": "Lots", - "noLots": "No lots yet.", - "names": "Also known as", - "addName": "Add name", - "links": "Links", - "addLink": "Add link", - "linkUrl": "URL", - "linkTitle": "Title (optional)", - "reference": "Learn more", - "refGbif": "GBIF", - "refWikipedia": "Wikipedia", - "refWikispecies": "Wikispecies", - "notes": "Notes", - "addLot": "Add lot", - "editLot": "Edit lot", - "deleteConfirm": "Delete this seed?", - "year": "Year {year}", - "noYear": "Year unknown" - }, - "germination": { - "title": "Germination", - "add": "Add test", - "sampleSize": "Sample size", - "germinated": "Germinated", - "none": "No germination tests yet.", - "result": "{percent}%" - }, - "viability": { - "expiringSoon": "Use or reproduce this season", - "expiringSoonYears": "Use or reproduce this season · keeps ~{years} yr", - "expired": "Past typical viability — reproduce", - "expiredYears": "Past typical viability (~{years} yr) — reproduce" - }, - "editVariety": { - "title": "Edit seed", - "name": "Name", - "category": "Category", - "notes": "Notes", - "species": "Species (from catalog)", - "speciesHint": "Search a species…", - "speciesSuggested": "Suggested from the name", - "organic": "Organic", - "organicHint": "Grown organically (eco)" - }, - "addLot": { - "title": "Add lot", - "year": "Harvest date", - "quantity": "How much?", - "amount": "Amount" - }, - "harvest": { - "pickTitle": "Select month / year", - "anyMonth": "Any month", - "noDate": "Set harvest date", - "monthNames": [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December" - ] - }, - "lotType": { - "seed": "Seeds", - "plant": "Plant", - "seedling": "Seedling", - "tree": "Tree / shrub", - "bulb": "Bulb / tuber", - "cutting": "Cutting" - }, - "presentation": { - "title": "Packaging", - "none": "Unspecified", - "pot": "Pot", - "tray": "Tray", - "plug": "Plug", - "bareRoot": "Bare-root", - "rootBall": "Root-ball" - }, - "provenance": { - "section": "Where it's from", - "seedsFrom": "Seeds from", - "seedsFromHint": "Who grew or gave them", - "place": "Place", - "placeHint": "Where they come from (with region)", - "addSeedsFrom": "Seeds from", - "addPlace": "Place" - }, - "abundance": { - "add": "How much I have", - "title": "How much I have", - "none": "Not set", - "plentyToShare": "Plenty to share", - "enoughToShare": "Enough to share a little", - "enoughForMe": "Enough for me", - "runningLow": "Running low" - }, - "share": { - "add": "Do you share it?", - "title": "Do you share it?", - "nudge": "You have plenty — you could share some.", - "private": "Just for me", - "gift": "To give away", - "exchange": "To swap", - "sell": "For sale", - "filterChip": "I share", - "printCatalog": "Print what I share", - "catalogTitle": "What I share", - "catalogSaved": "Catalog saved", - "cancelled": "Cancelled" - }, - "cropCalendar": { - "add": "Crop calendar", - "title": "Crop calendar", - "sow": "Sow", - "transplant": "Transplant", - "flowering": "Flowering", - "fruiting": "Fruiting", - "seedHarvest": "Seed harvest", - "unset": "—" - }, - "needsReproduction": { - "label": "To regrow this season", - "hint": "Grow it out before the seed runs out", - "badge": "To regrow" - }, - "preservation": { - "add": "How it's kept", - "title": "How it's kept", - "none": "Unspecified", - "jarWithDesiccant": "Jar with drying agent", - "glassJar": "Glass jar", - "paperEnvelope": "Paper envelope", - "paperBag": "Paper bag", - "plasticBag": "Plastic bag" - }, - "conditionCheck": { - "advanced": "Storage & seed-bank details", - "title": "Storage checks", - "add": "Add check", - "containers": "Jars / containers", - "desiccant": "Drying agent", - "none": "No storage checks yet.", - "summary": "{count} jar(s) · {state}" - }, - "desiccant": { - "none": "None", - "add": "Add some", - "replace": "Replace it", - "dry": "Blue — dry", - "fresh": "Just renewed" - }, - "unit": { - "aFew": "a few", - "some": "some", - "plenty": "plenty", - "pinch": "a pinch", - "handful": { "singular": "handful", "plural": "handfuls" }, - "teaspoon": { "singular": "teaspoon", "plural": "teaspoons" }, - "spoon": { "singular": "spoon", "plural": "spoons" }, - "cup": { "singular": "cup", "plural": "cups" }, - "jar": { "singular": "jar", "plural": "jars" }, - "sack": { "singular": "sack", "plural": "sacks" }, - "packet": { "singular": "packet", "plural": "packets" }, - "cob": { "singular": "cob", "plural": "cobs" }, - "pod": { "singular": "pod", "plural": "pods" }, - "ear": { "singular": "ear", "plural": "ears" }, - "head": { "singular": "head", "plural": "heads" }, - "fruit": { "singular": "fruit", "plural": "fruits" }, - "bulb": { "singular": "bulb", "plural": "bulbs" }, - "tuber": { "singular": "tuber", "plural": "tubers" }, - "seedHead": { "singular": "seed head", "plural": "seed heads" }, - "bunch": { "singular": "bunch", "plural": "bunches" }, - "plant": { "singular": "plant", "plural": "plants" }, - "pot": { "singular": "pot", "plural": "pots" }, - "tray": { "singular": "tray", "plural": "trays" }, - "seedling": { "singular": "seedling", "plural": "seedlings" }, - "tree": { "singular": "tree", "plural": "trees" }, - "cutting": { "singular": "cutting", "plural": "cuttings" }, - "grams": { "singular": "gram", "plural": "grams" }, - "count": { "singular": "seed", "plural": "seeds" } + "avatar": { + "title": "Your photo or avatar", + "fromPhoto": "Take or choose a photo", + "illustration": "Or pick an illustration", + "remove": "Remove" + }, + "favorites": { + "title": "Favorites", + "empty": "No favorites yet. Save offers you like from the market.", + "save": "Save to favorites", + "remove": "Remove from favorites", + "unavailable": "No longer available" + }, + "seedSaving": { + "title": "Saving its seed", + "subtitle": "What it takes to keep the variety true", + "lifeCycle": "Cycle", + "cycleAnnual": "Annual", + "cycleBiennial": "Biennial — seeds in year 2", + "cyclePerennial": "Perennial", + "pollination": "Pollination", + "pollSelf": "Self-pollinating", + "pollCross": "Crosses with others", + "pollMixed": "Self-pollinates, sometimes crosses", + "byInsect": "by insects", + "byWind": "on the wind", + "isolation": "Keep apart", + "isolationRange": "{min}–{max} m from other varieties", + "isolationSingle": "{min} m from other varieties", + "plants": "Save from several plants", + "plantsValue": "From at least {n} plants", + "processing": "Cleaning the seed", + "procDry": "Dry seed (thresh)", + "procWet": "Wet seed (ferment and rinse)", + "difficulty": "Difficulty", + "diffEasy": "Easy", + "diffMedium": "Medium", + "diffHard": "Hard", + "advisory": "General guidance — adapt it to your climate and variety.", + "sourcePrefix": "Source" + }, + "calendar": { + "title": "This month", + "filterChip": "This month", + "selfNote": "What you've noted in your varieties.", + "nothing": "Nothing noted for {month}." + }, + "app": { + "title": "Tane" + }, + "bootstrap": { + "failed": "Tane couldn't start", + "retry": "Try again" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "type": "Type", + "comingSoon": "Coming soon", + "offline": "You're offline — sharing is paused" + }, + "home": { + "tagline": "Share and grow local seeds", + "openMarket": "Market", + "openMarketSubtitle": "Discover and share seeds nearby", + "yourInventory": "Your inventory", + "yourInventorySubtitle": "Manage your seeds" + }, + "photo": { + "camera": "Take a photo", + "gallery": "Choose from gallery", + "setAsCover": "Set as cover", + "isCover": "Cover photo", + "deleteConfirm": "Delete this photo?" + }, + "menu": { + "tagline": "your seed bank", + "inventory": "Inventory", + "market": "Market", + "profile": "Your profile", + "chat": "Chat", + "wishlist": "Favorites", + "following": "Following", + "plantares": "Plantares", + "sales": "Sales", + "calendar": "Calendar", + "settings": "Settings" + }, + "settings": { + "language": "Language", + "systemLanguage": "System language", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langAst": "Asturianu", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "About", + "aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.", + "aboutOpen": "About Tane" + }, + "backup": { + "title": "Backup & restore", + "autoBackupTitle": "Automatic backups", + "autoBackupLast": "Last copy saved {date} · every {days} days", + "autoBackupNone": "A copy is kept automatically every {days} days", + "exportJson": "Save a backup", + "exportJsonSubtitle": "A complete copy to keep safe, restore later or move to another device", + "importJson": "Restore a backup", + "importJsonSubtitle": "Bring a saved copy back — nothing gets duplicated", + "exportCsv": "Export to a spreadsheet", + "exportCsvSubtitle": "A simple list for Excel or LibreOffice — no photos", + "importCsv": "Import a list", + "importCsvSubtitle": "Add entries from a spreadsheet", + "importConfirmTitle": "Restore a backup?", + "importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.", + "importCsvConfirmTitle": "Import a list?", + "importCsvConfirmBody": "Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.", + "importAction": "Import", + "exportSaved": "Copy saved", + "cancelled": "Cancelled", + "importDone": "Imported: {added} new, {updated} updated", + "importCsvDone": "Added {count} entries", + "importFailed": "This file could not be read as a Tane copy", + "failed": "Something went wrong", + "recoveryTitle": "Your recovery code", + "recoverySubtitle": "Print it and keep it safe — it opens your copies on a new device", + "recoveryIntro": "This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.", + "recoveryCopy": "Copy", + "recoverySave": "Save the sheet", + "recoverySheetTitle": "Tane — your recovery sheet", + "recoveryPromptTitle": "Enter your recovery code", + "recoveryPromptBody": "This copy was saved with another code. Type the code from your recovery sheet to open it.", + "recoveryWrongCode": "That code doesn't open this copy" + }, + "about": { + "title": "About", + "kanji": "種", + "tagline": "A local-first, decentralized app for managing and sharing traditional seeds and seedlings.", + "intro": "Tane (種, \"seed\" in Japanese) helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. Its name comes from tanemaki (種まき), \"to sow / scatter seeds\". The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.", + "heritage": "The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the \"community currency for seed exchange\" (BAH-Semillero, 2009, CC-BY-SA). Tane is the digital Plantare.", + "version": "Version", + "license": "License", + "licenseValue": "AGPL-3.0", + "website": "Website", + "sourceCode": "Source code", + "translate": "Help translate", + "translateSubtitle": "Help bring Tane to your language", + "openSourceLicenses": "Open-source licenses", + "openSourceLicensesSubtitle": "Third-party libraries and their licenses", + "copyright": "© {years} Comunes Association, under AGPLv3" + }, + "intro": { + "skip": "Skip", + "next": "Next", + "start": "Get started", + "menuEntry": "How Tane works", + "slides": { + "welcome": { + "title": "The seed that brought you here", + "body": "Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing." + }, + "inventory": { + "title": "Your seed bank, in your pocket", + "body": "Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start." + }, + "privacy": { + "title": "Yours, and only yours", + "body": "No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared." + }, + "share": { + "title": "Share, the way it's always been done", + "body": "Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person." + }, + "plantare": { + "title": "To sow is to multiply", + "body": "With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons." + } } + }, + "inventory": { + "title": "Inventory", + "searchHint": "Search seeds", + "empty": "No seeds yet. Tap + to add your first.", + "noMatches": "No seeds match your filters.", + "clearFilters": "Clear filters", + "uncategorized": "Uncategorized", + "needsReproductionFilter": "To regrow", + "loadError": "Couldn't open your seed bank. It may just have been busy — try again.", + "retry": "Try again" + }, + "draft": { + "capture": "Capture photos", + "captured": "{n} captured to catalogue", + "triageTitle": "To catalogue", + "triageCount": "{n} to catalogue", + "untitled": "Unnamed", + "nameField": "Name this seed", + "nameHint": "What is it?", + "suggestFromPhoto": "Suggest name from photo", + "discard": "Discard" + }, + "quickAdd": { + "title": "Add a seed", + "labelField": "Name", + "labelRequired": "Give it a name", + "addPhoto": "Add photo", + "quantity": "How much?", + "more": "Add more…", + "save": "Save", + "saveAndAddAnother": "Save & add another", + "addedCount": "{n} added", + "cancel": "Cancel" + }, + "detail": { + "notFound": "This seed is no longer here.", + "lots": "Lots", + "noLots": "No lots yet.", + "names": "Also known as", + "addName": "Add name", + "links": "Links", + "addLink": "Add link", + "linkUrl": "URL", + "linkTitle": "Title (optional)", + "reference": "Learn more", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", + "notes": "Notes", + "addLot": "Add lot", + "editLot": "Edit lot", + "deleteConfirm": "Delete this seed?", + "year": "Year {year}", + "noYear": "Year unknown" + }, + "germination": { + "title": "Germination", + "add": "Add test", + "sampleSize": "Sample size", + "germinated": "Germinated", + "none": "No germination tests yet.", + "result": "{percent}%" + }, + "viability": { + "expiringSoon": "Use or reproduce this season", + "expiringSoonYears": "Use or reproduce this season · keeps ~{years} yr", + "expired": "Past typical viability — reproduce", + "expiredYears": "Past typical viability (~{years} yr) — reproduce" + }, + "editVariety": { + "title": "Edit seed", + "name": "Name", + "category": "Category", + "notes": "Notes", + "species": "Species (from catalog)", + "speciesHint": "Search a species…", + "speciesSuggested": "Suggested from the name", + "organic": "Organic", + "organicHint": "Grown organically (eco)" + }, + "addLot": { + "title": "Add lot", + "year": "Harvest date", + "quantity": "How much?", + "amount": "Amount" + }, + "harvest": { + "pickTitle": "Select month / year", + "anyMonth": "Any month", + "noDate": "Set harvest date", + "monthNames": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ] + }, + "lotType": { + "seed": "Seeds", + "plant": "Plant", + "seedling": "Seedling", + "tree": "Tree / shrub", + "bulb": "Bulb / tuber", + "cutting": "Cutting" + }, + "presentation": { + "title": "Packaging", + "none": "Unspecified", + "pot": "Pot", + "tray": "Tray", + "plug": "Plug", + "bareRoot": "Bare-root", + "rootBall": "Root-ball" + }, + "provenance": { + "section": "Where it's from", + "seedsFrom": "Seeds from", + "seedsFromHint": "Who grew or gave them", + "place": "Place", + "placeHint": "Where they come from (with region)", + "addSeedsFrom": "Seeds from", + "addPlace": "Place" + }, + "abundance": { + "add": "How much I have", + "title": "How much I have", + "none": "Not set", + "plentyToShare": "Plenty to share", + "enoughToShare": "Enough to share a little", + "enoughForMe": "Enough for me", + "runningLow": "Running low" + }, + "share": { + "add": "Do you share it?", + "title": "Do you share it?", + "nudge": "You have plenty — you could share some.", + "price": "Price", + "priceHint": "Leave it empty to agree it later", + "private": "Just for me", + "gift": "To give away", + "exchange": "To swap", + "sell": "For sale", + "filterChip": "I share", + "printCatalog": "Print what I share", + "catalogTitle": "What I share", + "catalogSaved": "Catalog saved", + "cancelled": "Cancelled" + }, + "printLabels": { + "action": "Print labels", + "title": "Print labels", + "selectHint": "Pick the seeds to print labels for", + "selectAll": "Select all", + "selected": "{n} selected", + "none": "Select seeds first", + "format": "Label size", + "formatStickers": "Small stickers", + "formatStickersHint": "Many small labels per page — to peel onto packets", + "formatCards": "Large cards", + "formatCardsHint": "Fewer, bigger labels — for jars and boxes", + "count": "{n} labels", + "save": "Save labels", + "saved": "Labels saved", + "cancelled": "Cancelled" + }, + "cropCalendar": { + "add": "Crop calendar", + "title": "Crop calendar", + "sow": "Sow", + "transplant": "Transplant", + "flowering": "Flowering", + "fruiting": "Fruiting", + "seedHarvest": "Seed harvest", + "editorHint": "Note the months typical for this variety in your area — these are your own notes.", + "unset": "—" + }, + "needsReproduction": { + "label": "To regrow this season", + "hint": "Grow it out before the seed runs out", + "badge": "To regrow" + }, + "preservation": { + "add": "How it's kept", + "title": "How it's kept", + "none": "Unspecified", + "jarWithDesiccant": "Jar with drying agent", + "glassJar": "Glass jar", + "paperEnvelope": "Paper envelope", + "paperBag": "Paper bag", + "plasticBag": "Plastic bag" + }, + "conditionCheck": { + "advanced": "Storage & seed-bank details", + "title": "Storage checks", + "add": "Add check", + "containers": "Jars / containers", + "desiccant": "Drying agent", + "none": "No storage checks yet.", + "summary": "{count} jar(s) · {state}" + }, + "desiccant": { + "none": "None", + "add": "Add some", + "replace": "Replace it", + "dry": "Blue — dry", + "fresh": "Just renewed" + }, + "unit": { + "aFew": "a few", + "some": "some", + "plenty": "plenty", + "pinch": "a pinch", + "handful": { + "singular": "handful", + "plural": "handfuls" + }, + "teaspoon": { + "singular": "teaspoon", + "plural": "teaspoons" + }, + "spoon": { + "singular": "spoon", + "plural": "spoons" + }, + "cup": { + "singular": "cup", + "plural": "cups" + }, + "jar": { + "singular": "jar", + "plural": "jars" + }, + "sack": { + "singular": "sack", + "plural": "sacks" + }, + "packet": { + "singular": "packet", + "plural": "packets" + }, + "cob": { + "singular": "cob", + "plural": "cobs" + }, + "pod": { + "singular": "pod", + "plural": "pods" + }, + "ear": { + "singular": "ear", + "plural": "ears" + }, + "head": { + "singular": "head", + "plural": "heads" + }, + "fruit": { + "singular": "fruit", + "plural": "fruits" + }, + "bulb": { + "singular": "bulb", + "plural": "bulbs" + }, + "tuber": { + "singular": "tuber", + "plural": "tubers" + }, + "seedHead": { + "singular": "seed head", + "plural": "seed heads" + }, + "bunch": { + "singular": "bunch", + "plural": "bunches" + }, + "plant": { + "singular": "plant", + "plural": "plants" + }, + "pot": { + "singular": "pot", + "plural": "pots" + }, + "tray": { + "singular": "tray", + "plural": "trays" + }, + "seedling": { + "singular": "seedling", + "plural": "seedlings" + }, + "tree": { + "singular": "tree", + "plural": "trees" + }, + "cutting": { + "singular": "cutting", + "plural": "cuttings" + }, + "grams": { + "singular": "gram", + "plural": "grams" + }, + "count": { + "singular": "seed", + "plural": "seeds" + } + }, + "market": { + "title": "Seeds near you", + "subtitle": "What others are sharing nearby", + "notSetUp": "Sharing isn't set up yet", + "notSetUpBody": "Turn on sharing to see and give seeds to people near you. It stays rough — your zone, never your exact address.", + "setUp": "Set up sharing", + "cantReach": "Can't reach the community servers right now", + "cantReachBody": "Try again, or check the servers under Advanced setup.", + "retry": "Retry", + "setArea": "Set your area", + "setAreaBody": "Tell the market roughly where you are to see seeds nearby.", + "searching": "Looking around your area…", + "empty": "No seeds shared near you yet", + "searchHint": "Search these seeds", + "noMatches": "No shared seeds match your search", + "near": "Near you", + "contact": "Message", + "mine": "You", + "configTitle": "Sharing setup", + "setupIntro": "Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.", + "areaLabel": "Your area", + "areaHelp": "Kept rough on purpose — your zone, never an exact spot.", + "areaSet": "Your area is set — kept coarse, never your exact spot", + "areaNotSet": "Area not set yet — use your location, or add a code under Advanced", + "advanced": "Advanced", + "areaCodeLabel": "Area code", + "areaCodeHint": "A short code like sp3e9 — not a place name", + "serversLabel": "Community servers", + "serversHelp": "Choose which servers to use. Leave the defaults if unsure.", + "serversAdvanced": "Add another server", + "serverAddress": "Server address", + "serverInvalid": "Enter a valid address (wss://…)", + "save": "Save", + "saved": "Saved", + "wanted": "Wanted", + "shareMine": "Share my seeds", + "sharedCount": "Shared {n} seeds nearby", + "nothingToShare": "Mark some seeds to give, swap or sell first", + "useLocation": "Use my approximate location", + "locationFailed": "Couldn't get your location — check that location is on and the permission is granted", + "queued": "Saved — we'll share these when you're connected", + "shareFailed": "Couldn't reach the community servers — your seeds weren't shared. Try again in a moment.", + "rangeLabel": "How far to look", + "rangeNear": "Very close", + "rangeArea": "Around here", + "rangeRegion": "My region", + "sharedBy": "Shared by", + "noProfile": "This person hasn't shared a profile yet", + "copyId": "Copy code", + "idCopied": "Code copied", + "photo": "Photo" + }, + "profile": { + "title": "Your profile", + "name": "Display name", + "nameHint": "How others see you", + "about": "About", + "aboutHint": "A short line — what you grow, where", + "g1": "Ğ1 address (optional)", + "g1Hint": "So people can pay you in Ğ1 — separate from your key", + "yourId": "Your identity", + "idHelp": "Share this so people can recognise you", + "copy": "Copy", + "copied": "Copied", + "save": "Save", + "saved": "Profile saved", + "identities": "Your identities", + "identitiesHelp": "Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.", + "identityLabel": "Identity {n}", + "current": "In use", + "newIdentity": "New identity", + "switchTitle": "Switch identity?", + "switchBody": "Messages and contacts are kept separate for each identity. You can switch back anytime.", + "switchAction": "Switch" + }, + "chatList": { + "title": "Messages", + "empty": "No conversations yet. Message someone from the market." + }, + "chat": { + "title": "Chat", + "hint": "Write a message…", + "send": "Send", + "empty": "No messages yet — say hello", + "offline": "Set up sharing to send messages", + "payG1": "Pay in Ğ1", + "g1Copied": "Ğ1 address copied — paste it in your wallet", + "today": "Today", + "yesterday": "Yesterday", + "sendError": "Couldn't send — check your connection", + "noLinks": "Links aren't allowed in messages" + }, + "trust": { + "none": "No one vouches for them yet", + "count": "Vouched for by {n}", + "vouch": "I know this person", + "vouched": "You vouch for them", + "circle": "In your circle" + }, + "yourPeople": { + "title": "Your people", + "help": "People you've met and vouch for, and people who vouch for you.", + "youVouchFor": "You vouch for", + "vouchesForYou": "They vouch for you", + "youVouchForEmpty": "You don't vouch for anyone yet. When you meet someone, open your chat with them and tap \"I know this person\".", + "vouchesForYouEmpty": "No one vouches for you yet", + "revoke": "Stop vouching", + "revokeConfirm": "Stop vouching for this person?", + "offline": "You're offline — try again when you're connected" + }, + "ratings": { + "rate": "Rate this person", + "edit": "Edit your rating", + "commentHint": "How did it go? (optional)", + "fromYourCircle": "{n} from people you know", + "retract": "Remove your rating", + "saved": "Rating saved" + }, + "notifications": { + "newMessageFrom": "New message from {name}" + }, + "plantare": { + "title": "Plantares", + "help": "A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It's not a sale.", + "add": "Add a commitment", + "empty": "No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.", + "iReturn": "I'll grow it out & return some", + "owedToMe": "They'll return some to me", + "direction": "Who reproduces & returns", + "counterparty": "With whom?", + "counterpartyHint": "A person or a collective (optional)", + "owed": "What comes back?", + "owedHint": "e.g. a handful next season (optional)", + "note": "Note (optional)", + "save": "Save", + "markReturned": "Mark returned", + "markForgiven": "Let it go", + "reopen": "Reopen", + "delete": "Remove", + "statusReturned": "Returned", + "statusForgiven": "Settled", + "openSection": "Open", + "settledSection": "Done", + "removeConfirm": "Remove this commitment?", + "returnBy": "Return by {date}", + "overdue": "overdue", + "dueByLabel": "Return by (optional)", + "dueByHint": "A gentle reminder, never enforced", + "pickDate": "Pick a date", + "clearDate": "Clear date", + "sectionTitle": "Commitments", + "propose": "Propose a signed Plantaré", + "proposeHelp": "Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.", + "proposeTo": "With {name}", + "sent": "Proposal sent — waiting for them to sign", + "seedLabel": "Which seed?", + "seedHint": "The variety this promise is about", + "returnKindLabel": "What comes back?", + "returnSimilar": "A similar amount of seed", + "returnSimilarNote": "open-pollinated · non-GMO · organically grown", + "returnWork": "Some hours of work", + "returnOther": "Something else", + "workHoursLabel": "How many hours?", + "proposalsSection": "Waiting for your reply", + "incomingFrom": "{name} proposes a Plantaré", + "accept": "Accept & sign", + "declineAction": "Decline", + "declineReasonHint": "Reason (optional)", + "acceptedToast": "Signed — you both hold it now", + "declinedToast": "Declined", + "badgeAwaiting": "Awaiting signature", + "badgeSigned": "Signed by both", + "badgeDeclined": "Declined", + "offline": "You're offline — it'll be sent when you reconnect" + }, + "handover": { + "title": "Seeds changed hands", + "help": "A gift, a swap or a sale — note it down, with a return promise or without.", + "iGave": "I gave seeds", + "iReceived": "I received seeds", + "whichLot": "Which batch?", + "howMuch": "How much?", + "allOfIt": "All of it", + "partOfIt": "A part", + "paymentChip": "Money changed hands", + "promiseGave": "They'll return me seed", + "promiseReceived": "I'll return seed" + }, + "sale": { + "title": "Sales", + "help": "Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.", + "add": "Record a sale", + "empty": "No sales yet. Note here what you sell or buy.", + "iSold": "I sold", + "iBought": "I bought", + "direction": "Sold or bought?", + "counterparty": "With whom?", + "counterpartyHint": "A person or a collective (optional)", + "amount": "Amount", + "currency": "Currency", + "currencyHint": "€, Ğ1, hours… (optional)", + "hours": "hours", + "note": "Note (optional)", + "save": "Save", + "delete": "Remove", + "removeConfirm": "Remove this sale?" + }, + "legal": { + "title": "Privacy & rules", + "subtitle": "Your privacy, the market rules, and sharing seeds legally", + "privacyTitle": "Your privacy", + "privacyBody": "Tane works without an account, and everything you record stays on your device, encrypted. There are no ads, no trackers, and no Tane server.\n\nNothing is shared unless you choose to: when you publish an offer or your profile, or send a message, it travels through community-run servers. Offers only ever carry a rough zone — never your address.\n\nWhat you publish is public, and copies may remain even after you remove it — so share with care.", + "rulesTitle": "Rules of the road", + "rulesBody": "Tane is a tool, not a shop: exchanges are agreed directly between people, and nobody takes a cut. That also means you are responsible for what you offer and send.\n\nIn the market: be honest about your seeds, only offer what you may share, treat people well, and don't spam. You can block anyone, and report offers or people that break the rules — reports are acted on in the community servers.", + "seedsTitle": "About sharing seeds and seedlings", + "seedsBody": "Gifting and swapping seeds between amateurs is broadly recognized in most countries. Selling can be different: in many places, selling seed of varieties that aren't officially registered is restricted — check your local rules before asking a price.\n\nSending seeds to another country is often restricted too, and commercially protected varieties must not be propagated without permission. When in doubt, keep it local and keep it a gift.\n\nThe same spirit applies to seedlings and young plants, but live plants can carry stricter transport and plant-health rules than seeds — moving them across regions or borders may need extra checks.", + "readFull": "Read the full documents online" + }, + "marketGate": { + "title": "Before you join the market", + "intro": "The market is a shared space between neighbours. By continuing, you agree to a few simple rules:", + "ruleHonest": "Be honest about the seeds you offer", + "ruleLegal": "Only share what you're allowed to share where you live", + "ruleRespect": "Treat people well — no spam, no abuse", + "publicNote": "What you publish here is public, and copies may remain even if you remove it later.", + "viewLegal": "Privacy & rules", + "accept": "I agree", + "decline": "Not now" + }, + "report": { + "offer": "Report this offer", + "person": "Report this person", + "title": "Report", + "prompt": "What's wrong?", + "reasonSpam": "Spam or a scam", + "reasonAbuse": "Abusive or disrespectful", + "reasonIllegal": "Seeds that shouldn't be offered", + "reasonOther": "Something else", + "detailsHint": "Add details (optional)", + "send": "Send report", + "sentHidden": "Report sent — you won't see this anymore", + "failed": "Couldn't send the report — check your connection", + "alsoBlock": "Also block this person" + }, + "block": { + "action": "Block this person", + "confirmTitle": "Block this person?", + "confirmBody": "You won't see their offers or messages anymore. You can unblock them later under Blocked people in the sharing setup.", + "confirm": "Block", + "blockedToast": "Blocked — their offers and messages are hidden", + "manageTitle": "Blocked people", + "manageEmpty": "You haven't blocked anyone", + "unblock": "Unblock" + } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 63a37d7..de9f4ff 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -1,333 +1,755 @@ { - "app": { - "title": "Tanemaki" - }, - "common": { - "save": "Guardar", - "cancel": "Cancelar", - "delete": "Eliminar", - "edit": "Editar", - "type": "Tipo", - "comingSoon": "Pronto" - }, - "home": { - "tagline": "Comparte y cultiva semillas locales", - "openMarket": "Mercado abierto", - "openMarketSubtitle": "Explora e intercambia", - "yourInventory": "Tu inventario", - "yourInventorySubtitle": "Gestiona tus semillas" - }, - "photo": { - "camera": "Hacer una foto", - "gallery": "Elegir de la galería", - "setAsCover": "Poner de portada", - "isCover": "Foto de portada", - "deleteConfirm": "¿Borrar esta foto?" - }, - "menu": { - "tagline": "tu banco de semillas", - "inventory": "Inventario", - "market": "Mercado", - "profile": "Tu perfil", - "chat": "Chat", - "wishlist": "Lista de deseos", - "following": "Siguiendo", - "settings": "Ajustes" - }, - "settings": { - "language": "Idioma", - "systemLanguage": "Idioma del sistema", - "langEs": "Español", - "langEn": "English", - "langPt": "Português", - "about": "Acerca de", - "aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.", - "aboutOpen": "Acerca de Tanemaki" - }, - "backup": { - "title": "Copia de seguridad", - "exportJson": "Guardar una copia de seguridad", - "exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo", - "importJson": "Restaurar una copia", - "importJsonSubtitle": "Recupera una copia guardada — no se duplica nada", - "exportCsv": "Exportar a una hoja de cálculo", - "exportCsvSubtitle": "Una lista sencilla para Excel o LibreOffice — sin fotos", - "importCsv": "Importar una lista", - "importCsvSubtitle": "Añade entradas desde una hoja de cálculo", - "importConfirmTitle": "¿Restaurar una copia?", - "importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.", - "importCsvConfirmTitle": "¿Importar una lista?", - "importCsvConfirmBody": "Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.", - "importAction": "Importar", - "exportSaved": "Copia guardada", - "cancelled": "Cancelado", - "importDone": "Importado: {added} nuevas, {updated} actualizadas", - "importCsvDone": "Añadidas {count} entradas", - "importFailed": "Este fichero no se pudo leer como una copia de Tanemaki", - "failed": "Algo ha salido mal", - "recoveryTitle": "Tu código de recuperación", - "recoverySubtitle": "Imprímelo y guárdalo bien: abre tus copias en otro dispositivo", - "recoveryIntro": "Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.", - "recoveryCopy": "Copiar", - "recoverySave": "Guardar la hoja", - "recoverySheetTitle": "Tanemaki — tu hoja de recuperación", - "recoveryPromptTitle": "Escribe tu código de recuperación", - "recoveryPromptBody": "Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.", - "recoveryWrongCode": "Ese código no abre esta copia" - }, - "about": { - "title": "Acerca de", - "kanji": "種まき", - "tagline": "Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.", - "intro": "Tanemaki (種まき, «sembrar / esparcir semillas» en japonés; nombre corto Tane, 種 «semilla») ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.", - "heritage": "El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tanemaki es el Plantare digital.", - "version": "Versión", - "license": "Licencia", - "licenseValue": "AGPL-3.0", - "website": "Sitio web", - "openSourceLicenses": "Licencias de código abierto", - "openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias", - "copyright": "© {years} Asociación Comunes, bajo AGPLv3" - }, - "intro": { - "skip": "Saltar", - "next": "Siguiente", - "start": "Empezar", - "menuEntry": "Cómo funciona Tanemaki", - "slides": { - "welcome": { - "title": "La semilla que te trajo hasta aquí", - "body": "Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio." - }, - "inventory": { - "title": "Tu banco de semillas, en el bolsillo", - "body": "Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar." - }, - "privacy": { - "title": "Tuyo y solo tuyo", - "body": "Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas." - }, - "share": { - "title": "Compartir, como siempre se hizo", - "body": "Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona." - }, - "plantare": { - "title": "Sembrar es multiplicar", - "body": "Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común." - } - } - }, - "inventory": { - "title": "Inventario", - "searchHint": "Buscar semillas", - "empty": "Aún no hay semillas. Toca + para añadir la primera.", - "noMatches": "Ninguna semilla coincide con los filtros.", - "clearFilters": "Quitar filtros", - "uncategorized": "Sin categoría", - "needsReproductionFilter": "Por reproducir" - }, - "draft": { - "capture": "Capturar fotos", - "captured": "{n} capturadas por catalogar", - "triageTitle": "Por catalogar", - "triageCount": "{n} por catalogar", - "untitled": "Sin nombre", - "nameField": "Nombra esta semilla", - "nameHint": "¿Qué es?", - "suggestFromPhoto": "Sugerir nombre de la foto", - "discard": "Descartar" - }, - "quickAdd": { - "title": "Añadir una semilla", - "labelField": "Nombre", - "labelRequired": "Ponle un nombre", - "addPhoto": "Añadir foto", - "quantity": "¿Cuánta?", - "more": "Añadir más…", - "save": "Guardar", - "saveAndAddAnother": "Guardar y añadir otra", - "addedCount": "{n} añadidas", - "cancel": "Cancelar" - }, - "detail": { - "notFound": "Esta semilla ya no está aquí.", - "lots": "Lotes", - "noLots": "Aún no hay lotes.", - "names": "También conocida como", - "addName": "Añadir nombre", - "links": "Enlaces", - "addLink": "Añadir enlace", - "linkUrl": "URL", - "linkTitle": "Título (opcional)", - "reference": "Saber más", - "refGbif": "GBIF", - "refWikipedia": "Wikipedia", - "refWikispecies": "Wikispecies", - "notes": "Notas", - "addLot": "Añadir lote", - "editLot": "Editar lote", - "deleteConfirm": "¿Eliminar esta semilla?", - "year": "Año {year}", - "noYear": "Año desconocido" - }, - "germination": { - "title": "Germinación", - "add": "Añadir prueba", - "sampleSize": "Muestra", - "germinated": "Germinadas", - "none": "Aún no hay pruebas de germinación.", - "result": "{percent}%" - }, - "viability": { - "expiringSoon": "Úsala o multiplícala esta temporada", - "expiringSoonYears": "Úsala o multiplícala esta temporada · dura ~{years} años", - "expired": "Supera su viabilidad típica — a multiplicar", - "expiredYears": "Supera su viabilidad típica (~{years} años) — a multiplicar" - }, - "editVariety": { - "title": "Editar semilla", - "name": "Nombre", - "category": "Categoría", - "notes": "Notas", - "species": "Especie (del catálogo)", - "speciesHint": "Buscar una especie…", - "speciesSuggested": "Sugerida por el nombre", - "organic": "Ecológica", - "organicHint": "Cultivada de forma ecológica (eco)" - }, - "addLot": { - "title": "Añadir lote", - "year": "Fecha de cosecha", - "quantity": "¿Cuánta?", - "amount": "Cantidad" - }, - "harvest": { - "pickTitle": "Seleccionar mes / año", - "anyMonth": "Cualquier mes", - "noDate": "Indicar fecha de cosecha", - "monthNames": [ - "enero", "febrero", "marzo", "abril", "mayo", "junio", - "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre" - ] - }, - "lotType": { - "seed": "Semillas", - "plant": "Planta", - "seedling": "Plantón", - "tree": "Árbol / arbusto", - "bulb": "Bulbo / tubérculo", - "cutting": "Esqueje" - }, - "presentation": { - "title": "Envase", - "none": "Sin especificar", - "pot": "Maceta", - "tray": "Bandeja", - "plug": "Alvéolo", - "bareRoot": "Raíz desnuda", - "rootBall": "Cepellón" - }, - "provenance": { - "section": "De dónde viene", - "seedsFrom": "Semillas de", - "seedsFromHint": "Quién las cultivó o las dio", - "place": "Lugar", - "placeHint": "De dónde vienen (con provincia)", - "addSeedsFrom": "Semillas de", - "addPlace": "Lugar" - }, - "abundance": { - "add": "Cuánta tengo", - "title": "Cuánta tengo", - "none": "Sin indicar", - "plentyToShare": "De sobra para compartir", - "enoughToShare": "Bastante, para compartir con moderación", - "enoughForMe": "Suficiente para mí", - "runningLow": "Queda poca" - }, - "share": { - "add": "¿La compartes?", - "title": "¿La compartes?", - "nudge": "Tienes de sobra: podrías compartir un poco.", - "private": "Solo para mí", - "gift": "Para regalar", - "exchange": "Para intercambiar", - "sell": "En venta", - "filterChip": "Comparto", - "printCatalog": "Imprimir lo que comparto", - "catalogTitle": "Lo que comparto", - "catalogSaved": "Catálogo guardado", - "cancelled": "Cancelado" - }, - "cropCalendar": { - "add": "Calendario de cultivo", - "title": "Calendario de cultivo", - "sow": "Siembra", - "transplant": "Trasplante", - "flowering": "Floración", - "fruiting": "Fructificación", - "seedHarvest": "Cosecha de semilla", - "unset": "—" - }, - "needsReproduction": { - "label": "Reproducir esta temporada", - "hint": "Cultívala antes de que se acabe la semilla", - "badge": "Por reproducir" - }, - "preservation": { - "add": "Cómo se conserva", - "title": "Cómo se conserva", - "none": "Sin especificar", - "jarWithDesiccant": "Bote con desecante", - "glassJar": "Bote de cristal", - "paperEnvelope": "Sobre de papel", - "paperBag": "Bolsa de papel", - "plasticBag": "Bolsa de plástico" - }, - "conditionCheck": { - "advanced": "Conservación y detalles del banco", - "title": "Revisiones de conservación", - "add": "Añadir revisión", - "containers": "Botes / recipientes", - "desiccant": "Desecante", - "none": "Aún no hay revisiones.", - "summary": "{count} bote(s) · {state}" - }, - "desiccant": { - "none": "No tiene", - "add": "Se pone", - "replace": "Se cambia", - "dry": "Azul — seco", - "fresh": "Recién puesto" - }, - "unit": { - "aFew": "unas pocas", - "some": "algunas", - "plenty": "muchas", - "pinch": "una pizca", - "handful": { "singular": "puñado", "plural": "puñados" }, - "teaspoon": { "singular": "cucharadita", "plural": "cucharaditas" }, - "spoon": { "singular": "cuchara", "plural": "cucharas" }, - "cup": { "singular": "taza", "plural": "tazas" }, - "jar": { "singular": "bote", "plural": "botes" }, - "sack": { "singular": "saco", "plural": "sacos" }, - "packet": { "singular": "sobre", "plural": "sobres" }, - "cob": { "singular": "mazorca", "plural": "mazorcas" }, - "pod": { "singular": "vaina", "plural": "vainas" }, - "ear": { "singular": "espiga", "plural": "espigas" }, - "head": { "singular": "cabezuela", "plural": "cabezuelas" }, - "fruit": { "singular": "fruto", "plural": "frutos" }, - "bulb": { "singular": "bulbo", "plural": "bulbos" }, - "tuber": { "singular": "tubérculo", "plural": "tubérculos" }, - "seedHead": { "singular": "cabeza de semillas", "plural": "cabezas de semillas" }, - "bunch": { "singular": "manojo", "plural": "manojos" }, - "plant": { "singular": "planta", "plural": "plantas" }, - "pot": { "singular": "maceta", "plural": "macetas" }, - "tray": { "singular": "bandeja", "plural": "bandejas" }, - "seedling": { "singular": "plántula", "plural": "plántulas" }, - "tree": { "singular": "árbol", "plural": "árboles" }, - "cutting": { "singular": "esqueje", "plural": "esquejes" }, - "grams": { "singular": "gramo", "plural": "gramos" }, - "count": { "singular": "semilla", "plural": "semillas" } + "avatar": { + "title": "Tu foto o avatar", + "fromPhoto": "Hacer o elegir una foto", + "illustration": "O elige un dibujo", + "remove": "Quitar" + }, + "favorites": { + "title": "Favoritos", + "empty": "Aún no tienes favoritos. Guarda ofertas que te gusten del mercado.", + "save": "Guardar en favoritos", + "remove": "Quitar de favoritos", + "unavailable": "Ya no está disponible" + }, + "seedSaving": { + "title": "Conservar su semilla", + "subtitle": "Lo que hace falta para mantener la variedad fiel", + "lifeCycle": "Ciclo", + "cycleAnnual": "Anual", + "cycleBiennial": "Bienal — da semilla el 2.º año", + "cyclePerennial": "Perenne", + "pollination": "Polinización", + "pollSelf": "Se autopoliniza", + "pollCross": "Se cruza con otras", + "pollMixed": "Se autopoliniza, pero se cruza a veces", + "byInsect": "por insectos", + "byWind": "por el viento", + "isolation": "Sepárala", + "isolationRange": "{min}–{max} m de otras variedades", + "isolationSingle": "{min} m de otras variedades", + "plants": "Guarda de varias plantas", + "plantsValue": "De al menos {n} plantas", + "processing": "Cómo limpiarla", + "procDry": "Semilla seca (trillar)", + "procWet": "Semilla húmeda (fermentar y lavar)", + "difficulty": "Dificultad", + "diffEasy": "Fácil", + "diffMedium": "Media", + "diffHard": "Difícil", + "advisory": "Orientativo — adáptalo a tu clima y variedad.", + "sourcePrefix": "Fuente" + }, + "calendar": { + "title": "Este mes", + "filterChip": "Este mes", + "selfNote": "Lo que has anotado en tus variedades.", + "nothing": "Nada anotado para {month}." + }, + "app": { + "title": "Tane" + }, + "bootstrap": { + "failed": "Tane no pudo arrancar", + "retry": "Reintentar" + }, + "common": { + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "type": "Tipo", + "comingSoon": "Pronto", + "offline": "Sin conexión — el compartir está en pausa" + }, + "home": { + "tagline": "Comparte y cultiva semillas locales", + "openMarket": "Mercado", + "openMarketSubtitle": "Descubre y comparte semillas cerca", + "yourInventory": "Tu inventario", + "yourInventorySubtitle": "Gestiona tus semillas" + }, + "photo": { + "camera": "Hacer una foto", + "gallery": "Elegir de la galería", + "setAsCover": "Poner de portada", + "isCover": "Foto de portada", + "deleteConfirm": "¿Borrar esta foto?" + }, + "menu": { + "tagline": "tu banco de semillas", + "inventory": "Inventario", + "market": "Mercado", + "profile": "Tu perfil", + "chat": "Chat", + "wishlist": "Favoritos", + "following": "Siguiendo", + "plantares": "Plantares", + "sales": "Ventas", + "calendar": "Calendario", + "settings": "Ajustes" + }, + "settings": { + "language": "Idioma", + "systemLanguage": "Idioma del sistema", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "Acerca de", + "aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.", + "aboutOpen": "Acerca de Tane" + }, + "backup": { + "title": "Copia de seguridad", + "autoBackupTitle": "Copias automáticas", + "autoBackupLast": "Última copia el {date} · cada {days} días", + "autoBackupNone": "Se guarda una copia automáticamente cada {days} días", + "exportJson": "Guardar una copia de seguridad", + "exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo", + "importJson": "Restaurar una copia", + "importJsonSubtitle": "Recupera una copia guardada — no se duplica nada", + "exportCsv": "Exportar a una hoja de cálculo", + "exportCsvSubtitle": "Una lista sencilla para Excel o LibreOffice — sin fotos", + "importCsv": "Importar una lista", + "importCsvSubtitle": "Añade entradas desde una hoja de cálculo", + "importConfirmTitle": "¿Restaurar una copia?", + "importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.", + "importCsvConfirmTitle": "¿Importar una lista?", + "importCsvConfirmBody": "Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.", + "importAction": "Importar", + "exportSaved": "Copia guardada", + "cancelled": "Cancelado", + "importDone": "Importado: {added} nuevas, {updated} actualizadas", + "importCsvDone": "Añadidas {count} entradas", + "importFailed": "Este fichero no se pudo leer como una copia de Tane", + "failed": "Algo ha salido mal", + "recoveryTitle": "Tu código de recuperación", + "recoverySubtitle": "Imprímelo y guárdalo bien: abre tus copias en otro dispositivo", + "recoveryIntro": "Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.", + "recoveryCopy": "Copiar", + "recoverySave": "Guardar la hoja", + "recoverySheetTitle": "Tane — tu hoja de recuperación", + "recoveryPromptTitle": "Escribe tu código de recuperación", + "recoveryPromptBody": "Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.", + "recoveryWrongCode": "Ese código no abre esta copia" + }, + "about": { + "title": "Acerca de", + "kanji": "種", + "tagline": "Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.", + "intro": "Tane (種, «semilla» en japonés) ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. Su nombre viene de tanemaki (種まき), «sembrar / esparcir semillas». El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.", + "heritage": "El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tane es el Plantare digital.", + "version": "Versión", + "license": "Licencia", + "licenseValue": "AGPL-3.0", + "website": "Sitio web", + "sourceCode": "Código fuente", + "translate": "Ayuda a traducir", + "translateSubtitle": "Ayuda a traer Tane a tu idioma", + "openSourceLicenses": "Licencias de código abierto", + "openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias", + "copyright": "© {years} Asociación Comunes, bajo AGPLv3" + }, + "intro": { + "skip": "Saltar", + "next": "Siguiente", + "start": "Empezar", + "menuEntry": "Cómo funciona Tane", + "slides": { + "welcome": { + "title": "La semilla que te trajo hasta aquí", + "body": "Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio." + }, + "inventory": { + "title": "Tu banco de semillas, en el bolsillo", + "body": "Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar." + }, + "privacy": { + "title": "Tuyo y solo tuyo", + "body": "Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas." + }, + "share": { + "title": "Compartir, como siempre se hizo", + "body": "Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona." + }, + "plantare": { + "title": "Sembrar es multiplicar", + "body": "Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común." + } } + }, + "inventory": { + "title": "Inventario", + "searchHint": "Buscar semillas", + "empty": "Aún no hay semillas. Toca + para añadir la primera.", + "noMatches": "Ninguna semilla coincide con los filtros.", + "clearFilters": "Quitar filtros", + "uncategorized": "Sin categoría", + "needsReproductionFilter": "Por reproducir", + "loadError": "No se pudo abrir tu banco de semillas. Quizá estaba ocupado: inténtalo de nuevo.", + "retry": "Reintentar" + }, + "draft": { + "capture": "Capturar fotos", + "captured": "{n} capturadas por catalogar", + "triageTitle": "Por catalogar", + "triageCount": "{n} por catalogar", + "untitled": "Sin nombre", + "nameField": "Nombra esta semilla", + "nameHint": "¿Qué es?", + "suggestFromPhoto": "Sugerir nombre de la foto", + "discard": "Descartar" + }, + "quickAdd": { + "title": "Añadir una semilla", + "labelField": "Nombre", + "labelRequired": "Ponle un nombre", + "addPhoto": "Añadir foto", + "quantity": "¿Cuánta?", + "more": "Añadir más…", + "save": "Guardar", + "saveAndAddAnother": "Guardar y añadir otra", + "addedCount": "{n} añadidas", + "cancel": "Cancelar" + }, + "detail": { + "notFound": "Esta semilla ya no está aquí.", + "lots": "Lotes", + "noLots": "Aún no hay lotes.", + "names": "También conocida como", + "addName": "Añadir nombre", + "links": "Enlaces", + "addLink": "Añadir enlace", + "linkUrl": "URL", + "linkTitle": "Título (opcional)", + "reference": "Saber más", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", + "notes": "Notas", + "addLot": "Añadir lote", + "editLot": "Editar lote", + "deleteConfirm": "¿Eliminar esta semilla?", + "year": "Año {year}", + "noYear": "Año desconocido" + }, + "germination": { + "title": "Germinación", + "add": "Añadir prueba", + "sampleSize": "Muestra", + "germinated": "Germinadas", + "none": "Aún no hay pruebas de germinación.", + "result": "{percent}%" + }, + "viability": { + "expiringSoon": "Úsala o multiplícala esta temporada", + "expiringSoonYears": "Úsala o multiplícala esta temporada · dura ~{years} años", + "expired": "Supera su viabilidad típica — a multiplicar", + "expiredYears": "Supera su viabilidad típica (~{years} años) — a multiplicar" + }, + "editVariety": { + "title": "Editar semilla", + "name": "Nombre", + "category": "Categoría", + "notes": "Notas", + "species": "Especie (del catálogo)", + "speciesHint": "Buscar una especie…", + "speciesSuggested": "Sugerida por el nombre", + "organic": "Ecológica", + "organicHint": "Cultivada de forma ecológica (eco)" + }, + "addLot": { + "title": "Añadir lote", + "year": "Fecha de cosecha", + "quantity": "¿Cuánta?", + "amount": "Cantidad" + }, + "harvest": { + "pickTitle": "Seleccionar mes / año", + "anyMonth": "Cualquier mes", + "noDate": "Indicar fecha de cosecha", + "monthNames": [ + "enero", + "febrero", + "marzo", + "abril", + "mayo", + "junio", + "julio", + "agosto", + "septiembre", + "octubre", + "noviembre", + "diciembre" + ] + }, + "lotType": { + "seed": "Semillas", + "plant": "Planta", + "seedling": "Plantón", + "tree": "Árbol / arbusto", + "bulb": "Bulbo / tubérculo", + "cutting": "Esqueje" + }, + "presentation": { + "title": "Envase", + "none": "Sin especificar", + "pot": "Maceta", + "tray": "Bandeja", + "plug": "Alvéolo", + "bareRoot": "Raíz desnuda", + "rootBall": "Cepellón" + }, + "provenance": { + "section": "De dónde viene", + "seedsFrom": "Semillas de", + "seedsFromHint": "Quién las cultivó o las dio", + "place": "Lugar", + "placeHint": "De dónde vienen (con provincia)", + "addSeedsFrom": "Semillas de", + "addPlace": "Lugar" + }, + "abundance": { + "add": "Cuánta tengo", + "title": "Cuánta tengo", + "none": "Sin indicar", + "plentyToShare": "De sobra para compartir", + "enoughToShare": "Bastante, para compartir con moderación", + "enoughForMe": "Suficiente para mí", + "runningLow": "Queda poca" + }, + "share": { + "add": "¿La compartes?", + "title": "¿La compartes?", + "nudge": "Tienes de sobra: podrías compartir un poco.", + "price": "Precio", + "priceHint": "Déjalo vacío para acordarlo luego", + "private": "Solo para mí", + "gift": "Para regalar", + "exchange": "Para intercambiar", + "sell": "En venta", + "filterChip": "Comparto", + "printCatalog": "Imprimir lo que comparto", + "catalogTitle": "Lo que comparto", + "catalogSaved": "Catálogo guardado", + "cancelled": "Cancelado" + }, + "printLabels": { + "action": "Imprimir etiquetas", + "title": "Imprimir etiquetas", + "selectHint": "Elige las semillas para imprimir sus etiquetas", + "selectAll": "Seleccionar todo", + "selected": "{n} seleccionadas", + "none": "Selecciona semillas primero", + "format": "Tamaño de etiqueta", + "formatStickers": "Pegatinas pequeñas", + "formatStickersHint": "Muchas etiquetas pequeñas por hoja — para pegar en sobres", + "formatCards": "Tarjetas grandes", + "formatCardsHint": "Menos etiquetas, más grandes — para botes y cajas", + "count": "{n} etiquetas", + "save": "Guardar etiquetas", + "saved": "Etiquetas guardadas", + "cancelled": "Cancelado" + }, + "cropCalendar": { + "add": "Calendario de cultivo", + "title": "Calendario de cultivo", + "sow": "Siembra", + "transplant": "Trasplante", + "flowering": "Floración", + "fruiting": "Fructificación", + "seedHarvest": "Cosecha de semilla", + "editorHint": "Anota tú los meses típicos de esta variedad en tu zona — son tus propias notas.", + "unset": "—" + }, + "needsReproduction": { + "label": "Reproducir esta temporada", + "hint": "Cultívala antes de que se acabe la semilla", + "badge": "Por reproducir" + }, + "preservation": { + "add": "Cómo se conserva", + "title": "Cómo se conserva", + "none": "Sin especificar", + "jarWithDesiccant": "Bote con desecante", + "glassJar": "Bote de cristal", + "paperEnvelope": "Sobre de papel", + "paperBag": "Bolsa de papel", + "plasticBag": "Bolsa de plástico" + }, + "conditionCheck": { + "advanced": "Conservación y detalles del banco", + "title": "Revisiones de conservación", + "add": "Añadir revisión", + "containers": "Botes / recipientes", + "desiccant": "Desecante", + "none": "Aún no hay revisiones.", + "summary": "{count} bote(s) · {state}" + }, + "desiccant": { + "none": "No tiene", + "add": "Se pone", + "replace": "Se cambia", + "dry": "Azul — seco", + "fresh": "Recién puesto" + }, + "unit": { + "aFew": "unas pocas", + "some": "algunas", + "plenty": "muchas", + "pinch": "una pizca", + "handful": { + "singular": "puñado", + "plural": "puñados" + }, + "teaspoon": { + "singular": "cucharadita", + "plural": "cucharaditas" + }, + "spoon": { + "singular": "cuchara", + "plural": "cucharas" + }, + "cup": { + "singular": "taza", + "plural": "tazas" + }, + "jar": { + "singular": "bote", + "plural": "botes" + }, + "sack": { + "singular": "saco", + "plural": "sacos" + }, + "packet": { + "singular": "sobre", + "plural": "sobres" + }, + "cob": { + "singular": "mazorca", + "plural": "mazorcas" + }, + "pod": { + "singular": "vaina", + "plural": "vainas" + }, + "ear": { + "singular": "espiga", + "plural": "espigas" + }, + "head": { + "singular": "cabezuela", + "plural": "cabezuelas" + }, + "fruit": { + "singular": "fruto", + "plural": "frutos" + }, + "bulb": { + "singular": "bulbo", + "plural": "bulbos" + }, + "tuber": { + "singular": "tubérculo", + "plural": "tubérculos" + }, + "seedHead": { + "singular": "cabeza de semillas", + "plural": "cabezas de semillas" + }, + "bunch": { + "singular": "manojo", + "plural": "manojos" + }, + "plant": { + "singular": "planta", + "plural": "plantas" + }, + "pot": { + "singular": "maceta", + "plural": "macetas" + }, + "tray": { + "singular": "bandeja", + "plural": "bandejas" + }, + "seedling": { + "singular": "plántula", + "plural": "plántulas" + }, + "tree": { + "singular": "árbol", + "plural": "árboles" + }, + "cutting": { + "singular": "esqueje", + "plural": "esquejes" + }, + "grams": { + "singular": "gramo", + "plural": "gramos" + }, + "count": { + "singular": "semilla", + "plural": "semillas" + } + }, + "market": { + "title": "Semillas cerca de ti", + "subtitle": "Lo que otras personas comparten cerca", + "notSetUp": "Aún no has configurado el compartir", + "notSetUpBody": "Activa el compartir para ver y dar semillas a gente cercana. Se mantiene aproximado — tu zona, nunca tu dirección exacta.", + "setUp": "Configurar el compartir", + "cantReach": "No se puede conectar con los servidores ahora mismo", + "cantReachBody": "Inténtalo de nuevo, o revisa los servidores en la configuración avanzada.", + "retry": "Reintentar", + "setArea": "Indica tu zona", + "setAreaBody": "Dile al mercado tu zona aproximada para ver semillas cerca.", + "searching": "Buscando por tu zona…", + "empty": "Aún no hay semillas compartidas cerca de ti", + "searchHint": "Buscar entre estas semillas", + "noMatches": "Ninguna semilla compartida coincide con tu búsqueda", + "near": "Cerca de ti", + "contact": "Mensaje", + "mine": "Tú", + "configTitle": "Configuración de compartir", + "setupIntro": "Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.", + "areaLabel": "Tu zona", + "areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.", + "areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto", + "areaNotSet": "Zona sin definir — usa tu ubicación, o añade un código en Avanzado", + "advanced": "Avanzado", + "areaCodeLabel": "Código de zona", + "areaCodeHint": "Un código corto como sp3e9 — no un nombre de lugar", + "serversLabel": "Servidores de la comunidad", + "serversHelp": "Elige qué servidores usar. Déjalo así si no sabes.", + "serversAdvanced": "Añadir otro servidor", + "serverAddress": "Dirección del servidor", + "serverInvalid": "Introduce una dirección válida (wss://…)", + "save": "Guardar", + "saved": "Guardado", + "wanted": "Busco", + "shareMine": "Compartir mis semillas", + "sharedCount": "Compartidas {n} semillas", + "nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender", + "useLocation": "Usar mi ubicación aproximada", + "locationFailed": "No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido", + "queued": "Guardado — las compartiremos cuando tengas conexión", + "shareFailed": "No se pudo conectar con los servidores — tus semillas no se compartieron. Inténtalo de nuevo en un momento.", + "rangeLabel": "Hasta dónde buscar", + "rangeNear": "Muy cerca", + "rangeArea": "Por mi zona", + "rangeRegion": "Mi región", + "sharedBy": "Compartido por", + "noProfile": "Esta persona aún no ha compartido su perfil", + "copyId": "Copiar código", + "idCopied": "Código copiado", + "photo": "Foto" + }, + "profile": { + "title": "Tu perfil", + "name": "Nombre", + "nameHint": "Cómo te ven los demás", + "about": "Sobre ti", + "aboutHint": "Una línea — qué cultivas, dónde", + "g1": "Dirección Ğ1 (opcional)", + "g1Hint": "Para que te paguen en Ğ1 — aparte de tu clave", + "yourId": "Tu identidad", + "idHelp": "Compártela para que te reconozcan", + "copy": "Copiar", + "copied": "Copiado", + "save": "Guardar", + "saved": "Perfil guardado", + "identities": "Tus identidades", + "identitiesHelp": "Ten identidades separadas — cada una con sus mensajes y contactos. Todas salen de tu única copia de seguridad, así que cambiar no añade nada que recordar.", + "identityLabel": "Identidad {n}", + "current": "En uso", + "newIdentity": "Nueva identidad", + "switchTitle": "¿Cambiar de identidad?", + "switchBody": "Los mensajes y contactos se guardan por separado para cada identidad. Puedes volver cuando quieras.", + "switchAction": "Cambiar" + }, + "chatList": { + "title": "Mensajes", + "empty": "Aún no hay conversaciones. Escribe a alguien desde el mercado." + }, + "chat": { + "title": "Chat", + "hint": "Escribe un mensaje…", + "send": "Enviar", + "empty": "Aún no hay mensajes — saluda", + "offline": "Configura el compartir para enviar mensajes", + "payG1": "Pagar en Ğ1", + "g1Copied": "Dirección Ğ1 copiada — pégala en tu cartera", + "today": "Hoy", + "yesterday": "Ayer", + "sendError": "No se pudo enviar — revisa tu conexión", + "noLinks": "No se permiten enlaces en los mensajes" + }, + "trust": { + "none": "Nadie los avala aún", + "count": "Avalada por {n}", + "vouch": "Conozco a esta persona", + "vouched": "Avalas a esta persona", + "circle": "En tu círculo" + }, + "yourPeople": { + "title": "Tu gente", + "help": "Personas que conoces y avalas, y personas que te avalan.", + "youVouchFor": "Tú avalas a", + "vouchesForYou": "Te avalan", + "youVouchForEmpty": "Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca \"Conozco a esta persona\".", + "vouchesForYouEmpty": "Nadie te avala todavía", + "revoke": "Dejar de avalar", + "revokeConfirm": "¿Dejar de avalar a esta persona?", + "offline": "Sin conexión — inténtalo cuando estés en línea" + }, + "ratings": { + "rate": "Valorar a esta persona", + "edit": "Editar tu valoración", + "commentHint": "¿Qué tal fue? (opcional)", + "fromYourCircle": "{n} de gente que conoces", + "retract": "Quitar tu valoración", + "saved": "Valoración guardada" + }, + "notifications": { + "newMessageFrom": "Nuevo mensaje de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.", + "add": "Añadir compromiso", + "empty": "Aún no hay compromisos. Cuando compartas o recibas semilla con el compromiso de reproducirla y devolver algo, anótalo aquí.", + "iReturn": "La reproduzco y devuelvo yo", + "owedToMe": "Me la devuelven a mí", + "direction": "Quién reproduce y devuelve", + "counterparty": "¿Con quién?", + "counterpartyHint": "Una persona o un colectivo (opcional)", + "owed": "¿Qué se devuelve?", + "owedHint": "p. ej. un puñado la próxima temporada (opcional)", + "note": "Nota (opcional)", + "save": "Guardar", + "markReturned": "Marcar devuelto", + "markForgiven": "Dar por saldado", + "reopen": "Reabrir", + "delete": "Quitar", + "statusReturned": "Devuelto", + "statusForgiven": "Saldado", + "openSection": "Pendientes", + "settledSection": "Hechos", + "removeConfirm": "¿Quitar este compromiso?", + "returnBy": "Devolver antes del {date}", + "overdue": "vencido", + "dueByLabel": "Devolver antes de (opcional)", + "dueByHint": "Un recordatorio suave, nunca obligatorio", + "pickDate": "Elegir fecha", + "clearDate": "Quitar fecha", + "sectionTitle": "Compromisos", + "propose": "Proponer un Plantaré firmado", + "proposeHelp": "Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.", + "proposeTo": "Con {name}", + "sent": "Propuesta enviada — esperando su firma", + "seedLabel": "¿Qué semilla?", + "seedHint": "La variedad a la que se refiere la promesa", + "returnKindLabel": "¿Qué se devuelve?", + "returnSimilar": "Una cantidad similar de semilla", + "returnSimilarNote": "polinización abierta · no transgénica · cultivada en ecológico", + "returnWork": "Unas horas de trabajo", + "returnOther": "Otra cosa", + "workHoursLabel": "¿Cuántas horas?", + "proposalsSection": "Esperando tu respuesta", + "incomingFrom": "{name} te propone un Plantaré", + "accept": "Aceptar y firmar", + "declineAction": "Rechazar", + "declineReasonHint": "Motivo (opcional)", + "acceptedToast": "Firmado — ahora lo guardáis los dos", + "declinedToast": "Rechazado", + "badgeAwaiting": "Falta firma", + "badgeSigned": "Firmado por ambos", + "badgeDeclined": "Rechazado", + "offline": "Estás sin conexión — se enviará al reconectar" + }, + "handover": { + "title": "Di o recibí semillas", + "help": "Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.", + "iGave": "Di semillas", + "iReceived": "Recibí semillas", + "whichLot": "¿De qué lote?", + "howMuch": "¿Cuánto?", + "allOfIt": "Todo", + "partOfIt": "Una parte", + "paymentChip": "Hubo dinero por medio", + "promiseGave": "Me devolverán semilla", + "promiseReceived": "Devolveré semilla" + }, + "sale": { + "title": "Ventas", + "help": "Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.", + "add": "Registrar venta", + "empty": "Aún no hay ventas. Anota aquí lo que vendes o compras.", + "iSold": "Vendí", + "iBought": "Compré", + "direction": "¿Vendes o compras?", + "counterparty": "¿Con quién?", + "counterpartyHint": "Una persona o un colectivo (opcional)", + "amount": "Importe", + "currency": "Moneda", + "currencyHint": "€, Ğ1, horas… (opcional)", + "hours": "horas", + "note": "Nota (opcional)", + "save": "Guardar", + "delete": "Quitar", + "removeConfirm": "¿Quitar esta venta?" + }, + "legal": { + "title": "Privacidad y normas", + "subtitle": "Tu privacidad, las normas del mercado y la legalidad de las semillas", + "privacyTitle": "Tu privacidad", + "privacyBody": "Tane funciona sin cuenta, y todo lo que registras se queda en tu dispositivo, cifrado. No hay publicidad, ni rastreadores, ni servidor de Tane.\n\nNo se comparte nada salvo que tú quieras: cuando publicas una oferta o tu perfil, o envías un mensaje, viaja por servidores comunitarios. Las ofertas solo llevan una zona aproximada — nunca tu dirección.\n\nLo que publicas es público, y pueden quedar copias incluso después de retirarlo — así que comparte con cuidado.", + "rulesTitle": "Las reglas del juego", + "rulesBody": "Tane es una herramienta, no una tienda: los intercambios se acuerdan directamente entre personas y nadie se lleva comisión. Eso también significa que tú respondes de lo que ofreces y envías.\n\nEn el mercado: sé honesto con tus semillas, ofrece solo lo que puedas compartir, trata bien a la gente y no hagas spam. Puedes bloquear a cualquiera y denunciar ofertas o personas que incumplan las normas — las denuncias se atienden en los servidores comunitarios.", + "seedsTitle": "Sobre compartir semillas y plantones", + "seedsBody": "Regalar e intercambiar semillas entre aficionados está ampliamente reconocido en la mayoría de países. Vender puede ser distinto: en muchos sitios, vender semilla de variedades no registradas oficialmente está restringido — consulta tu normativa antes de pedir un precio.\n\nEnviar semillas a otro país también suele estar restringido, y las variedades protegidas comercialmente no pueden propagarse sin permiso. Ante la duda, mejor local y mejor regalo.\n\nLo mismo vale para plantones y plantas jóvenes, pero la planta viva puede tener reglas de transporte y fitosanitarias más estrictas que la semilla: moverla entre regiones o países puede requerir controles adicionales.", + "readFull": "Leer los documentos completos en la web" + }, + "marketGate": { + "title": "Antes de entrar al mercado", + "intro": "El mercado es un espacio compartido entre vecinas y vecinos. Al continuar, aceptas unas pocas normas sencillas:", + "ruleHonest": "Sé honesto con las semillas que ofreces", + "ruleLegal": "Comparte solo lo que puedas compartir donde vives", + "ruleRespect": "Trata bien a la gente — sin spam ni abusos", + "publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.", + "viewLegal": "Privacidad y normas", + "accept": "Acepto", + "decline": "Ahora no" + }, + "report": { + "offer": "Denunciar esta oferta", + "person": "Denunciar a esta persona", + "title": "Denunciar", + "prompt": "¿Qué ocurre?", + "reasonSpam": "Spam o un engaño", + "reasonAbuse": "Abusivo o irrespetuoso", + "reasonIllegal": "Semillas que no deberían ofrecerse", + "reasonOther": "Otra cosa", + "detailsHint": "Añade detalles (opcional)", + "send": "Enviar denuncia", + "sentHidden": "Denuncia enviada — ya no verás esto", + "failed": "No se pudo enviar la denuncia — revisa tu conexión", + "alsoBlock": "Bloquear también a esta persona" + }, + "block": { + "action": "Bloquear a esta persona", + "confirmTitle": "¿Bloquear a esta persona?", + "confirmBody": "Dejarás de ver sus ofertas y mensajes. Puedes desbloquearla más adelante en Personas bloqueadas, en la configuración de compartir.", + "confirm": "Bloquear", + "blockedToast": "Persona bloqueada — sus ofertas y mensajes quedan ocultos", + "manageTitle": "Personas bloqueadas", + "manageEmpty": "No has bloqueado a nadie", + "unblock": "Desbloquear" + } } diff --git a/apps/app_seeds/lib/i18n/fr.i18n.json b/apps/app_seeds/lib/i18n/fr.i18n.json new file mode 100644 index 0000000..a90758a --- /dev/null +++ b/apps/app_seeds/lib/i18n/fr.i18n.json @@ -0,0 +1,726 @@ +{ + "avatar": { + "title": "Votre photo ou avatar", + "fromPhoto": "Prendre ou choisir une photo", + "illustration": "Ou choisir une illustration", + "remove": "Supprimer" + }, + "favorites": { + "title": "Favoris", + "empty": "Pas encore de favoris. Enregistrez les offres que vous aimez du marché.", + "save": "Ajouter aux favoris", + "remove": "Retirer des favoris", + "unavailable": "Plus disponible" + }, + "seedSaving": { + "title": "Conserver sa semence", + "subtitle": "Ce qu'il faut pour garder la variété fidèle", + "lifeCycle": "Cycle", + "cycleAnnual": "Annuelle", + "cycleBiennial": "Bisannuelle — graines la 2e année", + "cyclePerennial": "Vivace", + "pollination": "Pollinisation", + "pollSelf": "Autoféconde", + "pollCross": "Se croise avec d'autres", + "pollMixed": "S'autoféconde, parfois se croise", + "byInsect": "par les insectes", + "byWind": "par le vent", + "isolation": "À isoler", + "isolationRange": "{min}–{max} m des autres variétés", + "isolationSingle": "{min} m des autres variétés", + "plants": "Préserver plusieurs plantes", + "plantsValue": "D'au moins {n} plantes", + "processing": "Nettoyer la semence", + "procDry": "Semence sèche (dépiquage)", + "procWet": "Semence humide (fermentation et rinçage)", + "difficulty": "Difficulté", + "diffEasy": "Facile", + "diffMedium": "Moyen", + "diffHard": "Difficile", + "advisory": "Guide général — adaptez-le à votre climat et à votre variété.", + "sourcePrefix": "Source" + }, + "calendar": { + "title": "Ce mois", + "filterChip": "Ce mois", + "selfNote": "Ce que vous avez noté dans vos variétés.", + "nothing": "Rien noté pour {month}." + }, + "app": { + "title": "Tane" + }, + "bootstrap": { + "failed": "Tane n'a pas pu démarrer", + "retry": "Réessayer" + }, + "common": { + "save": "Enregistrer", + "cancel": "Annuler", + "delete": "Supprimer", + "edit": "Modifier", + "type": "Type", + "comingSoon": "À venir", + "offline": "Vous êtes hors ligne — le partage est en pause" + }, + "home": { + "tagline": "Partagez et cultivez des graines locales", + "openMarket": "Marché", + "openMarketSubtitle": "Découvrez et partagez des graines à proximité", + "yourInventory": "Votre inventaire", + "yourInventorySubtitle": "Gérez vos graines" + }, + "photo": { + "camera": "Prendre une photo", + "gallery": "Choisir de la galerie", + "setAsCover": "Mettre en couverture", + "isCover": "Photo de couverture", + "deleteConfirm": "Supprimer cette photo ?" + }, + "menu": { + "tagline": "votre grainothèque", + "inventory": "Inventaire", + "market": "Marché", + "profile": "Votre profil", + "chat": "Messages", + "wishlist": "Favoris", + "following": "Abonnements", + "plantares": "Plantares", + "sales": "Ventes", + "calendar": "Calendrier", + "settings": "Paramètres" + }, + "settings": { + "language": "Langue", + "systemLanguage": "Langue du système", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langAst": "Asturianu", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "À propos", + "aboutText": "Inventaire local-first et chiffré pour les semences traditionnelles. AGPL-3.0.", + "aboutOpen": "À propos de Tane" + }, + "backup": { + "title": "Sauvegarde & restauration", + "autoBackupTitle": "Sauvegardes automatiques", + "autoBackupLast": "Dernière copie {date} · tous les {days} jours", + "autoBackupNone": "Une copie est conservée automatiquement tous les {days} jours", + "exportJson": "Enregistrer une sauvegarde", + "exportJsonSubtitle": "Une copie complète à conserver précieusement, restaurer plus tard ou transférer sur un autre appareil", + "importJson": "Restaurer une sauvegarde", + "importJsonSubtitle": "Récupérez une copie enregistrée — rien ne sera dupliqué", + "exportCsv": "Exporter vers un tableur", + "exportCsvSubtitle": "Une liste simple pour Excel ou LibreOffice — sans photos", + "importCsv": "Importer une liste", + "importCsvSubtitle": "Ajoutez des entrées depuis un tableur", + "importConfirmTitle": "Restaurer une sauvegarde ?", + "importConfirmBody": "Les entrées fusionnent avec votre inventaire ; si une entrée existe des deux côtés, c'est la version la plus récente qui gagne. Rien ne sera dupliqué.", + "importCsvConfirmTitle": "Importer une liste ?", + "importCsvConfirmBody": "Chaque ligne est ajoutée comme une nouvelle entrée. Cela ne fusionne ni ne remplace, donc importer le même fichier deux fois l'ajoute deux fois.", + "importAction": "Importer", + "exportSaved": "Copie enregistrée", + "cancelled": "Annulé", + "importDone": "Importé : {added} nouvelles, {updated} actualisées", + "importCsvDone": "Ajoutées {count} entrées", + "importFailed": "Ce fichier n'a pas pu être lu comme une sauvegarde Tane", + "failed": "Quelque chose s'est mal passé", + "recoveryTitle": "Votre code de récupération", + "recoverySubtitle": "Imprimez-le et gardez-le en sécurité — il ouvre vos sauvegardes sur un nouvel appareil", + "recoveryIntro": "Ce code ouvre vos sauvegardes et retrouve votre grainothèque sur n'importe quel appareil. Conservez deux copies imprimées en lieux sûrs, comme votre meilleure graine. Quiconque l'obtient peut lire vos sauvegardes, alors ne le partagez avec personne.", + "recoveryCopy": "Copier", + "recoverySave": "Enregistrer la feuille", + "recoverySheetTitle": "Tane — votre feuille de récupération", + "recoveryPromptTitle": "Entrez votre code de récupération", + "recoveryPromptBody": "Cette sauvegarde a été enregistrée avec un autre code. Entrez le code de votre feuille de récupération pour l'ouvrir.", + "recoveryWrongCode": "Ce code n'ouvre pas cette sauvegarde" + }, + "about": { + "title": "À propos", + "kanji": "種", + "tagline": "Une app local-first et décentralisée pour gérer et partager des semences et plantules traditionnelles.", + "intro": "Tane (種, « graine » en japonais) aide les gens et les collectifs à gérer un inventaire bienveillant de leur grainothèque, décider ce qu'ils offrent et le partager localement — sans intermédiaire central qui pourrait contrôler, censurer ou être poursuivi pour cela. Son nom vient de tanemaki (種まき), « semer / disperser des graines ». L'objectif est à la fois pratique et politique : soutenir les variétés de semences traditionnelles et résister au monopole des semenciers.", + "heritage": "Le nom honore les anciennes traditions d'entraide japonaises autour du riz — yui (travail communautaire partagé) et tanomoshi (caisses de secours mutuel) — qui ont inspiré le document Plantare, la « monnaie communautaire pour l'échange de semences » (BAH-Semillero, 2009, CC-BY-SA). Tane est le Plantare numérique.", + "version": "Version", + "license": "Licence", + "licenseValue": "AGPL-3.0", + "website": "Site web", + "sourceCode": "Code source", + "translate": "Aider à traduire", + "translateSubtitle": "Aidez à traduire Tane dans votre langue", + "openSourceLicenses": "Licences open source", + "openSourceLicensesSubtitle": "Bibliothèques tiers et leurs licences", + "copyright": "© {years} Association Comunes, sous AGPLv3" + }, + "intro": { + "skip": "Passer", + "next": "Suivant", + "start": "Commencer", + "menuEntry": "Comment fonctionne Tane", + "slides": { + "welcome": { + "title": "La graine qui vous a menés ici", + "body": "Chaque semence traditionnelle est une lettre écrite par des milliers de générations, passée de main en main. Nous sommes ce que nous sommes grâce à ce partage." + }, + "inventory": { + "title": "Votre grainothèque, dans votre poche", + "body": "Notez ce que vous avez, de quelle année, combien et d'où ça vient — avec le nom que vous utilisez. Une photo et un nom suffisent pour commencer." + }, + "privacy": { + "title": "Vôtre, et uniquement vôtre", + "body": "Pas de compte, pas d'internet, pas de trackers. Vos données vivent chiffrées sur votre appareil, et seul ce que vous choisissez est partagé." + }, + "share": { + "title": "Partager, comme cela s'est toujours fait", + "body": "Offrez ce que vous avez en excédent — cadeau, échange ou vente — et laissez quelqu'un à proximité le trouver. Seule la distance approximative est affichée, jamais votre adresse ; vous fermez le marché en personne." + }, + "plantare": { + "title": "Semer c'est multiplier", + "body": "Avec un Plantare, celui qui reçoit la graine promet d'en renvoyer plus tard. Et comme renvoyer signifie la cultiver, chaque prêt multiplie le bien commun." + } + } + }, + "inventory": { + "title": "Inventaire", + "searchHint": "Rechercher des graines", + "empty": "Pas encore de graines. Appuyez sur + pour ajouter votre première.", + "noMatches": "Aucune graine ne correspond à vos filtres.", + "clearFilters": "Effacer les filtres", + "uncategorized": "Non catégorisé", + "needsReproductionFilter": "À multiplier", + "loadError": "Impossible d'ouvrir votre grainothèque. Elle était peut-être occupée — réessayez.", + "retry": "Réessayer" + }, + "draft": { + "capture": "Capturer des photos", + "captured": "{n} capturée(s) à cataloguer", + "triageTitle": "À cataloguer", + "triageCount": "{n} à cataloguer", + "untitled": "Sans titre", + "nameField": "Nommez cette graine", + "nameHint": "Qu'est-ce que c'est ?", + "suggestFromPhoto": "Suggérer un nom depuis la photo", + "discard": "Abandonner" + }, + "quickAdd": { + "title": "Ajouter une graine", + "labelField": "Nom", + "labelRequired": "Donnez-lui un nom", + "addPhoto": "Ajouter une photo", + "quantity": "Combien ?", + "more": "Ajouter plus…", + "save": "Enregistrer", + "saveAndAddAnother": "Enregistrer et ajouter une autre", + "addedCount": "{n} ajoutée(s)", + "cancel": "Annuler" + }, + "detail": { + "notFound": "Cette graine n'est plus ici.", + "lots": "Lots", + "noLots": "Pas encore de lots.", + "names": "Aussi connue sous le nom de", + "addName": "Ajouter un nom", + "links": "Liens", + "addLink": "Ajouter un lien", + "linkUrl": "URL", + "linkTitle": "Titre (optionnel)", + "reference": "En savoir plus", + "refGbif": "GBIF", + "refWikipedia": "Wikipedia", + "refWikispecies": "Wikispecies", + "notes": "Notes", + "addLot": "Ajouter un lot", + "editLot": "Modifier le lot", + "deleteConfirm": "Supprimer cette graine ?", + "year": "Année {year}", + "noYear": "Année inconnue" + }, + "germination": { + "title": "Germination", + "add": "Ajouter un test", + "sampleSize": "Échantillon", + "germinated": "Germinées", + "none": "Pas encore de tests de germination.", + "result": "{percent}%" + }, + "viability": { + "expiringSoon": "Utilisez-la ou multipliez-la cette saison", + "expiringSoonYears": "Utilisez-la ou multipliez-la cette saison · se garde ~{years} an(s)", + "expired": "Au-delà de la viabilité typique — à multiplier", + "expiredYears": "Au-delà de la viabilité typique (~{years} an(s)) — à multiplier" + }, + "editVariety": { + "title": "Modifier la graine", + "name": "Nom", + "category": "Catégorie", + "notes": "Notes", + "species": "Espèce (du catalogue)", + "speciesHint": "Rechercher une espèce…", + "speciesSuggested": "Suggérée par le nom", + "organic": "Biologique", + "organicHint": "Cultivée de manière biologique (bio)" + }, + "addLot": { + "title": "Ajouter un lot", + "year": "Date de récolte", + "quantity": "Combien ?", + "amount": "Quantité" + }, + "harvest": { + "pickTitle": "Sélectionnez le mois / l'année", + "anyMonth": "N'importe quel mois", + "noDate": "Définir la date de récolte", + "monthNames": [ + "janvier", + "février", + "mars", + "avril", + "mai", + "juin", + "juillet", + "août", + "septembre", + "octobre", + "novembre", + "décembre" + ] + }, + "lotType": { + "seed": "Graines", + "plant": "Plante", + "seedling": "Plantule", + "tree": "Arbre / arbuste", + "bulb": "Bulbe / tubercule", + "cutting": "Bouture" + }, + "presentation": { + "title": "Emballage", + "none": "Non spécifié", + "pot": "Pot", + "tray": "Plateau", + "plug": "Alvéole", + "bareRoot": "Racines nues", + "rootBall": "Motte" + }, + "provenance": { + "section": "D'où elle vient", + "seedsFrom": "Graines de", + "seedsFromHint": "Qui les a cultivées ou données", + "place": "Lieu", + "placeHint": "D'où elles viennent (avec région)", + "addSeedsFrom": "Graines de", + "addPlace": "Lieu" + }, + "abundance": { + "add": "Combien j'en ai", + "title": "Combien j'en ai", + "none": "Non défini", + "plentyToShare": "De quoi partager généreusement", + "enoughToShare": "Assez pour partager un peu", + "enoughForMe": "Assez pour moi", + "runningLow": "Ça commence à manquer" + }, + "share": { + "add": "La partagez-vous ?", + "title": "La partagez-vous ?", + "nudge": "Vous en avez beaucoup — vous pourriez en partager.", + "price": "Prix", + "priceHint": "Laissez vide pour en discuter plus tard", + "private": "Juste pour moi", + "gift": "À donner", + "exchange": "À échanger", + "sell": "À vendre", + "filterChip": "Je partage", + "printCatalog": "Imprimer ce que je partage", + "catalogTitle": "Ce que je partage", + "catalogSaved": "Catalogue enregistré", + "cancelled": "Annulé" + }, + "printLabels": { + "action": "Imprimer les étiquettes", + "title": "Imprimer les étiquettes", + "selectHint": "Choisissez les graines pour lesquelles imprimer les étiquettes", + "selectAll": "Tout sélectionner", + "selected": "{n} sélectionnée(s)", + "none": "Sélectionnez d'abord les graines", + "format": "Taille d'étiquette", + "formatStickers": "Petits autocollants", + "formatStickersHint": "Beaucoup de petites étiquettes par page — à coller sur les sachets", + "formatCards": "Grandes cartes", + "formatCardsHint": "Moins d'étiquettes, plus grandes — pour les pots et boîtes", + "count": "{n} étiquette(s)", + "save": "Enregistrer les étiquettes", + "saved": "Étiquettes enregistrées", + "cancelled": "Annulé" + }, + "cropCalendar": { + "add": "Calendrier de culture", + "title": "Calendrier de culture", + "sow": "Semis", + "transplant": "Repiquage", + "flowering": "Floraison", + "fruiting": "Fructification", + "seedHarvest": "Récolte de semences", + "editorHint": "Notez les mois typiques pour cette variété dans votre région — ce sont vos propres notes.", + "unset": "—" + }, + "needsReproduction": { + "label": "À multiplier cette saison", + "hint": "Cultivez-la avant que la semence ne manque", + "badge": "À multiplier" + }, + "preservation": { + "add": "Comment c'est conservé", + "title": "Comment c'est conservé", + "none": "Non spécifié", + "jarWithDesiccant": "Pot avec dessiccant", + "glassJar": "Pot en verre", + "paperEnvelope": "Enveloppe en papier", + "paperBag": "Sac en papier", + "plasticBag": "Sac en plastique" + }, + "conditionCheck": { + "advanced": "Conservation et détails de la grainothèque", + "title": "Contrôles de conservation", + "add": "Ajouter un contrôle", + "containers": "Pots / récipients", + "desiccant": "Dessiccant", + "none": "Pas encore de contrôles.", + "summary": "{count} pot(s) · {state}" + }, + "desiccant": { + "none": "Aucun", + "add": "En ajouter", + "replace": "À changer", + "dry": "Bleu — sec", + "fresh": "Fraîchement mis" + }, + "unit": { + "aFew": "quelques", + "some": "quelques", + "plenty": "beaucoup", + "pinch": "une pincée", + "handful": { + "singular": "poignée", + "plural": "poignées" + }, + "teaspoon": { + "singular": "cuillère à café", + "plural": "cuillères à café" + }, + "spoon": { + "singular": "cuillère", + "plural": "cuillères" + }, + "cup": { + "singular": "tasse", + "plural": "tasses" + }, + "jar": { + "singular": "pot", + "plural": "pots" + }, + "sack": { + "singular": "sac", + "plural": "sacs" + }, + "packet": { + "singular": "sachet", + "plural": "sachets" + }, + "cob": { + "singular": "épi", + "plural": "épis" + }, + "pod": { + "singular": "gousse", + "plural": "gousses" + }, + "ear": { + "singular": "épi", + "plural": "épis" + }, + "head": { + "singular": "capitule", + "plural": "capitules" + }, + "fruit": { + "singular": "fruit", + "plural": "fruits" + }, + "bulb": { + "singular": "bulbe", + "plural": "bulbes" + }, + "tuber": { + "singular": "tubercule", + "plural": "tubercules" + }, + "seedHead": { + "singular": "tête de graines", + "plural": "têtes de graines" + }, + "bunch": { + "singular": "botte", + "plural": "bottes" + }, + "plant": { + "singular": "plante", + "plural": "plantes" + }, + "pot": { + "singular": "pot", + "plural": "pots" + }, + "tray": { + "singular": "plateau", + "plural": "plateaux" + }, + "seedling": { + "singular": "plantule", + "plural": "plantules" + }, + "tree": { + "singular": "arbre", + "plural": "arbres" + }, + "cutting": { + "singular": "bouture", + "plural": "boutures" + }, + "grams": { + "singular": "gramme", + "plural": "grammes" + }, + "count": { + "singular": "graine", + "plural": "graines" + } + }, + "market": { + "title": "Graines près de vous", + "subtitle": "Ce que les autres partagent à proximité", + "notSetUp": "Le partage n'est pas encore configuré", + "notSetUpBody": "Activez le partage pour voir et donner des graines aux gens à proximité. C'est volontairement approximatif — votre zone, jamais votre adresse exacte.", + "setUp": "Configurer le partage", + "cantReach": "Impossible de se connecter aux serveurs communautaires en ce moment", + "cantReachBody": "Réessayez, ou vérifiez les serveurs sous Configuration avancée.", + "retry": "Réessayer", + "setArea": "Définir votre zone", + "setAreaBody": "Dites au marché approximativement où vous êtes pour voir les graines à proximité.", + "searching": "Recherche autour de votre zone…", + "empty": "Aucune graine n'est encore partagée près de vous", + "searchHint": "Rechercher parmi ces graines", + "noMatches": "Aucune graine partagée ne correspond à votre recherche", + "near": "Près de vous", + "contact": "Message", + "mine": "Vous", + "configTitle": "Configuration du partage", + "setupIntro": "Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.", + "areaLabel": "Votre zone", + "areaHelp": "Gardée approximative volontairement — votre zone, jamais un point exact.", + "areaSet": "Votre zone est définie — approximative, jamais votre point exact", + "areaNotSet": "Zone non définie — utilisez votre position, ou ajoutez un code sous Avancé", + "advanced": "Avancé", + "areaCodeLabel": "Code de zone", + "areaCodeHint": "Un code court comme sp3e9 — pas un nom de lieu", + "serversLabel": "Serveurs communautaires", + "serversHelp": "Choisissez les serveurs à utiliser. Laissez les valeurs par défaut si vous n'êtes pas sûr.", + "serversAdvanced": "Ajouter un autre serveur", + "serverAddress": "Adresse du serveur", + "serverInvalid": "Entrez une adresse valide (wss://…)", + "save": "Enregistrer", + "saved": "Enregistré", + "wanted": "Recherché", + "shareMine": "Partager mes graines", + "sharedCount": "{n} graines partagées", + "nothingToShare": "Marquez d'abord certaines graines à donner, échanger ou vendre", + "useLocation": "Utiliser ma position approximative", + "locationFailed": "Impossible d'obtenir votre position — vérifiez que la localisation est activée et la permission accordée", + "queued": "Enregistré — nous partagerons cela quand vous serez connecté", + "shareFailed": "Impossible de se connecter aux serveurs communautaires — vos graines n'ont pas été partagées. Réessayez dans un instant.", + "rangeLabel": "Jusqu'où chercher", + "rangeNear": "Très proche", + "rangeArea": "Autour d'ici", + "rangeRegion": "Ma région", + "sharedBy": "Partagé par", + "noProfile": "Cette personne n'a pas encore partagé son profil", + "copyId": "Copier le code", + "idCopied": "Code copié", + "photo": "Photo" + }, + "profile": { + "title": "Votre profil", + "name": "Nom d'affichage", + "nameHint": "Comment les autres vous voient", + "about": "À propos", + "aboutHint": "Une courte ligne — ce que vous cultivez, où", + "g1": "Adresse Ğ1 (optionnel)", + "g1Hint": "Pour que les gens puissent vous payer en Ğ1 — séparé de votre clé", + "yourId": "Votre identité", + "idHelp": "Partagez-la pour que les gens vous reconnaissent", + "copy": "Copier", + "copied": "Copié", + "save": "Enregistrer", + "saved": "Profil enregistré", + "identities": "Vos identités", + "identitiesHelp": "Gardez des identités séparées — chacune avec ses propres messages et contacts. Elles proviennent toutes de votre unique sauvegarde, donc changer n'ajoute rien à retenir.", + "identityLabel": "Identité {n}", + "current": "En cours d'utilisation", + "newIdentity": "Nouvelle identité", + "switchTitle": "Changer d'identité ?", + "switchBody": "Les messages et contacts sont conservés séparément pour chaque identité. Vous pouvez revenir en arrière à tout moment.", + "switchAction": "Changer" + }, + "chatList": { + "title": "Messages", + "empty": "Pas encore de conversations. Envoyez un message à quelqu'un depuis le marché." + }, + "chat": { + "title": "Messages", + "hint": "Écrivez un message…", + "send": "Envoyer", + "empty": "Pas encore de messages — dites bonjour", + "offline": "Configurez le partage pour envoyer des messages", + "payG1": "Payer en Ğ1", + "g1Copied": "Adresse Ğ1 copiée — collez-la dans votre portefeuille", + "today": "Aujourd'hui", + "yesterday": "Hier", + "sendError": "Impossible d'envoyer — vérifiez votre connexion", + "noLinks": "Les liens ne sont pas autorisés dans les messages" + }, + "trust": { + "none": "Personne ne les cautionne encore", + "count": "Cautionnée par {n}", + "vouch": "Je connais cette personne", + "vouched": "Vous la cautionnez", + "circle": "Dans votre cercle" + }, + "yourPeople": { + "title": "Vos contacts", + "help": "Les gens que vous connaissez et cautionnez, et les gens qui vous cautionnent.", + "youVouchFor": "Vous cautionnez", + "vouchesForYou": "Ils vous cautionnent", + "youVouchForEmpty": "Vous ne cautionnez personne pour l'instant. Quand vous rencontrez quelqu'un, ouvrez votre conversation avec lui et appuyez sur « Je connais cette personne ».", + "vouchesForYouEmpty": "Personne ne vous cautionne pour l'instant", + "revoke": "Arrêter de cautionner", + "revokeConfirm": "Arrêter de cautionner cette personne ?", + "offline": "Vous êtes hors ligne — réessayez quand vous êtes connecté" + }, + "ratings": { + "rate": "Évaluer cette personne", + "edit": "Modifier votre évaluation", + "commentHint": "Comment ça s'est passé ? (optionnel)", + "fromYourCircle": "{n} de gens que vous connaissez", + "retract": "Retirer votre évaluation", + "saved": "Évaluation enregistrée" + }, + "notifications": { + "newMessageFrom": "Nouveau message de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Un Plantare est un engagement à multiplier une semence et en rendre une partie — comment une variété continue son voyage de main en main. Ce n'est pas une vente.", + "add": "Ajouter un engagement", + "empty": "Pas encore d'engagements. Quand vous partagez ou recevez une graine avec la promesse de la cultiver et d'en renvoyer une partie, notez-le ici.", + "iReturn": "Je la multiplie et la rends", + "owedToMe": "Ils m'en renvoient", + "direction": "Qui multiplie et rend", + "counterparty": "Avec qui ?", + "counterpartyHint": "Une personne ou un collectif (optionnel)", + "owed": "Qu'est-ce qui revient ?", + "owedHint": "p. ex. une poignée la saison prochaine (optionnel)", + "note": "Note (optionnel)", + "save": "Enregistrer", + "markReturned": "Marquer comme rendu", + "markForgiven": "Clore l'engagement", + "reopen": "Rouvrir", + "delete": "Retirer", + "statusReturned": "Rendu", + "statusForgiven": "Clôturé", + "openSection": "En attente", + "settledSection": "Fermés", + "removeConfirm": "Retirer cet engagement ?" + }, + "handover": { + "title": "Graines échangées", + "help": "Un cadeau, un échange ou une vente — notez-le, avec une promesse de retour ou pas.", + "iGave": "J'ai donné des graines", + "iReceived": "J'ai reçu des graines", + "whichLot": "Quel lot ?", + "howMuch": "Combien ?", + "allOfIt": "Tout", + "partOfIt": "Une partie", + "paymentChip": "Il y a eu un échange d'argent", + "promiseGave": "Ils me renverront de la graine", + "promiseReceived": "Je rendrai de la graine" + }, + "sale": { + "title": "Ventes", + "help": "Enregistrez les graines que vous avez vendues ou achetées — argent, Ğ1, ou n'importe quelle monnaie. Un modèle distinct d'un cadeau ou d'un Plantare. Aucune commission n'est jamais prélevée sur les graines.", + "add": "Enregistrer une vente", + "empty": "Pas encore de ventes. Notez ici ce que vous vendez ou achetez.", + "iSold": "J'ai vendu", + "iBought": "J'ai acheté", + "direction": "Vendu ou acheté ?", + "counterparty": "Avec qui ?", + "counterpartyHint": "Une personne ou un collectif (optionnel)", + "amount": "Montant", + "currency": "Monnaie", + "currencyHint": "€, Ğ1, heures… (optionnel)", + "hours": "heures", + "note": "Note (optionnel)", + "save": "Enregistrer", + "delete": "Retirer", + "removeConfirm": "Retirer cette vente ?" + }, + "legal": { + "title": "Confidentialité & règles", + "subtitle": "Votre confidentialité, les règles du marché et le partage légal de semences", + "privacyTitle": "Votre confidentialité", + "privacyBody": "Tane fonctionne sans compte, et tout ce que vous enregistrez reste sur votre appareil, chiffré. Il n'y a pas de publicités, pas de trackers, et pas de serveur Tane.\n\nRien n'est partagé sauf si vous le choisissez : quand vous publiez une offre ou votre profil, ou envoyez un message, cela passe par des serveurs gérés par la communauté. Les offres ne portent qu'une zone approximative — jamais votre adresse.\n\nCe que vous publiez est public, et des copies peuvent rester même après l'avoir retiré — alors partagez avec prudence.", + "rulesTitle": "Les règles du jeu", + "rulesBody": "Tane est un outil, pas un magasin : les échanges sont convenus directement entre les gens, et personne ne prend de commission. Cela signifie aussi que vous êtes responsable de ce que vous offrez et envoyez.\n\nSur le marché : soyez honnête avec vos graines, n'offrez que ce que vous pouvez partager, traitez les gens bien et ne faites pas de spam. Vous pouvez bloquer n'importe qui et signaler les offres ou les gens qui enfreignent les règles — les signalements sont traités sur les serveurs communautaires.", + "seedsTitle": "À propos du partage de semences et de plants", + "seedsBody": "Donner et échanger des graines entre amateurs est largement reconnu dans la plupart des pays. La vente peut être différente : dans de nombreux endroits, vendre des graines de variétés non officiellement enregistrées est restreint — vérifiez votre législation locale avant de demander un prix.\n\nEnvoyer des graines dans un autre pays est souvent restreint aussi, et les variétés protégées commercialement ne peuvent pas être reproduites sans permission. En cas de doute, gardez-le local et faites-en un cadeau.\n\nCela vaut aussi pour les plants et jeunes plantes, mais une plante vivante peut être soumise à des règles de transport et phytosanitaires plus strictes que les graines : la déplacer entre régions ou pays peut nécessiter des contrôles supplémentaires.", + "readFull": "Lire les documents complets en ligne" + }, + "marketGate": { + "title": "Avant de rejoindre le marché", + "intro": "Le marché est un espace partagé entre voisins. En continuant, vous acceptez quelques règles simples :", + "ruleHonest": "Soyez honnête avec les graines que vous offrez", + "ruleLegal": "Partagez uniquement ce que vous êtes autorisé à partager où vous vivez", + "ruleRespect": "Traitez les gens bien — pas de spam, pas d'abus", + "publicNote": "Ce que vous publiez ici est public, et des copies peuvent rester même si vous le retirez plus tard.", + "viewLegal": "Confidentialité & règles", + "accept": "J'accepte", + "decline": "Pas maintenant" + }, + "report": { + "offer": "Signaler cette offre", + "person": "Signaler cette personne", + "title": "Signaler", + "prompt": "Qu'est-ce qui ne va pas ?", + "reasonSpam": "Spam ou escroquerie", + "reasonAbuse": "Abusif ou irrespectueux", + "reasonIllegal": "Graines qui ne devraient pas être offertes", + "reasonOther": "Autre chose", + "detailsHint": "Ajouter des détails (optionnel)", + "send": "Envoyer le signalement", + "sentHidden": "Signalement envoyé — vous ne verrez plus cela", + "failed": "Impossible d'envoyer le signalement — vérifiez votre connexion", + "alsoBlock": "Bloquer aussi cette personne" + }, + "block": { + "action": "Bloquer cette personne", + "confirmTitle": "Bloquer cette personne ?", + "confirmBody": "Vous ne verrez plus ses offres ou messages. Vous pouvez la débloquer plus tard sous Personnes bloquées dans la configuration du partage.", + "confirm": "Bloquer", + "blockedToast": "Personne bloquée — ses offres et messages sont cachés", + "manageTitle": "Personnes bloquées", + "manageEmpty": "Vous n'avez bloqué personne", + "unblock": "Débloquer" + } +} diff --git a/apps/app_seeds/lib/i18n/ja.i18n.json b/apps/app_seeds/lib/i18n/ja.i18n.json new file mode 100644 index 0000000..b84bc5f --- /dev/null +++ b/apps/app_seeds/lib/i18n/ja.i18n.json @@ -0,0 +1,46 @@ +{ + "app": { + "title": "Tane" + }, + "common": { + "save": "保存", + "cancel": "キャンセル", + "delete": "削除", + "edit": "編集", + "type": "種類", + "comingSoon": "近日公開", + "offline": "オフラインです — 共有を一時停止しています" + }, + "menu": { + "tagline": "あなたの種子バンク", + "inventory": "在庫", + "market": "マーケット", + "profile": "プロフィール", + "chat": "チャット", + "wishlist": "お気に入り", + "following": "フォロー中", + "calendar": "カレンダー", + "settings": "設定" + }, + "settings": { + "language": "言語", + "systemLanguage": "システムの言語", + "langEs": "Español", + "langEn": "English", + "langPt": "Português", + "langAst": "Asturianu", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", + "about": "このアプリについて", + "aboutText": "在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。", + "aboutOpen": "Tane について" + }, + "home": { + "tagline": "地域の種を分かち合い、育てよう", + "openMarket": "マーケット", + "openMarketSubtitle": "近くの種を見つけて分かち合う", + "yourInventory": "あなたの在庫", + "yourInventorySubtitle": "種を管理する" + } +} diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 2c0bee9..5d248ee 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -1,6 +1,57 @@ { + "avatar": { + "title": "A tua foto ou avatar", + "fromPhoto": "Tirar ou escolher uma foto", + "illustration": "Ou escolhe um desenho", + "remove": "Remover" + }, + "favorites": { + "title": "Favoritos", + "empty": "Ainda não tens favoritos. Guarda ofertas que gostes do mercado.", + "save": "Guardar nos favoritos", + "remove": "Remover dos favoritos", + "unavailable": "Já não está disponível" + }, + "seedSaving": { + "title": "Guardar a sua semente", + "subtitle": "O que é preciso para manter a variedade fiel", + "lifeCycle": "Ciclo", + "cycleAnnual": "Anual", + "cycleBiennial": "Bienal — dá semente no 2.º ano", + "cyclePerennial": "Perene", + "pollination": "Polinização", + "pollSelf": "Autopoliniza-se", + "pollCross": "Cruza-se com outras", + "pollMixed": "Autopoliniza-se, mas às vezes cruza", + "byInsect": "por insetos", + "byWind": "pelo vento", + "isolation": "Separe-a", + "isolationRange": "{min}–{max} m de outras variedades", + "isolationSingle": "{min} m de outras variedades", + "plants": "Guarde de várias plantas", + "plantsValue": "De pelo menos {n} plantas", + "processing": "Como limpá-la", + "procDry": "Semente seca (debulhar)", + "procWet": "Semente húmida (fermentar e lavar)", + "difficulty": "Dificuldade", + "diffEasy": "Fácil", + "diffMedium": "Média", + "diffHard": "Difícil", + "advisory": "Orientativo — adapte-o ao seu clima e variedade.", + "sourcePrefix": "Fonte" + }, + "calendar": { + "title": "Este mês", + "filterChip": "Este mês", + "selfNote": "O que anotaste nas tuas variedades.", + "nothing": "Nada anotado para {month}." + }, "app": { - "title": "Tanemaki" + "title": "Tane" + }, + "bootstrap": { + "failed": "O Tane não conseguiu iniciar", + "retry": "Tentar de novo" }, "common": { "save": "Guardar", @@ -8,12 +59,13 @@ "delete": "Eliminar", "edit": "Editar", "type": "Tipo", - "comingSoon": "Em breve" + "comingSoon": "Em breve", + "offline": "Sem ligação — a partilha está em pausa" }, "home": { "tagline": "Partilha e cultiva sementes locais", - "openMarket": "Mercado aberto", - "openMarketSubtitle": "Descobre e troca", + "openMarket": "Mercado", + "openMarketSubtitle": "Descobre e partilha sementes por perto", "yourInventory": "O teu inventário", "yourInventorySubtitle": "Gere as tuas sementes" }, @@ -30,8 +82,11 @@ "market": "Mercado", "profile": "O teu perfil", "chat": "Conversas", - "wishlist": "Lista de desejos", + "wishlist": "Favoritos", "following": "A seguir", + "plantares": "Plantares", + "sales": "Vendas", + "calendar": "Calendário", "settings": "Definições" }, "settings": { @@ -40,12 +95,19 @@ "langEs": "Español", "langEn": "English", "langPt": "Português", + "langAst": "Asturianu", + "langFr": "Français", + "langDe": "Deutsch", + "langJa": "日本語", "about": "Acerca de", "aboutText": "Inventário local e cifrado para sementes tradicionais. AGPL-3.0.", - "aboutOpen": "Acerca do Tanemaki" + "aboutOpen": "Acerca do Tane" }, "backup": { "title": "Cópia de segurança", + "autoBackupTitle": "Cópias automáticas", + "autoBackupLast": "Última cópia em {date} · a cada {days} dias", + "autoBackupNone": "Uma cópia é guardada automaticamente a cada {days} dias", "exportJson": "Guardar uma cópia de segurança", "exportJsonSubtitle": "Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho", "importJson": "Restaurar uma cópia", @@ -63,28 +125,31 @@ "cancelled": "Cancelado", "importDone": "Importado: {added} novas, {updated} atualizadas", "importCsvDone": "{count} entradas acrescentadas", - "importFailed": "Este ficheiro não pôde ser lido como uma cópia do Tanemaki", + "importFailed": "Este ficheiro não pôde ser lido como uma cópia do Tane", "failed": "Algo correu mal", "recoveryTitle": "O teu código de recuperação", "recoverySubtitle": "Imprime-o e guarda-o bem: abre as tuas cópias noutro aparelho", "recoveryIntro": "Este código abre as tuas cópias guardadas e recupera o teu banco em qualquer aparelho. Guarda duas cópias em papel em sítios seguros, como a tua melhor semente. Quem o tiver pode ler as tuas cópias, por isso não o partilhes com ninguém.", "recoveryCopy": "Copiar", "recoverySave": "Guardar a folha", - "recoverySheetTitle": "Tanemaki — a tua folha de recuperação", + "recoverySheetTitle": "Tane — a tua folha de recuperação", "recoveryPromptTitle": "Escreve o teu código de recuperação", "recoveryPromptBody": "Esta cópia foi guardada com outro código. Escreve o código da tua folha de recuperação para a abrir.", "recoveryWrongCode": "Esse código não abre esta cópia" }, "about": { "title": "Acerca de", - "kanji": "種まき", + "kanji": "種", "tagline": "Uma aplicação local e descentralizada para gerir e partilhar sementes e plântulas tradicionais.", - "intro": "O Tanemaki (種まき, \"semear / espalhar sementes\" em japonês; nome curto Tane, 種 \"semente\") ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e partilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.", - "heritage": "O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário partilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a \"moeda comunitária de troca de sementes\" (BAH-Semillero, 2009, CC-BY-SA). O Tanemaki é o Plantare digital.", + "intro": "O Tane (種, \"semente\" em japonês) ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e partilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O seu nome vem de tanemaki (種まき), \"semear / espalhar sementes\". O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.", + "heritage": "O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário partilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a \"moeda comunitária de troca de sementes\" (BAH-Semillero, 2009, CC-BY-SA). O Tane é o Plantare digital.", "version": "Versão", "license": "Licença", "licenseValue": "AGPL-3.0", "website": "Sítio web", + "sourceCode": "Código-fonte", + "translate": "Ajuda a traduzir", + "translateSubtitle": "Ajuda a trazer o Tane para o teu idioma", "openSourceLicenses": "Licenças de código aberto", "openSourceLicensesSubtitle": "Bibliotecas de terceiros e as suas licenças", "copyright": "© {years} Associação Comunes, sob AGPLv3" @@ -93,7 +158,7 @@ "skip": "Saltar", "next": "Seguinte", "start": "Começar", - "menuEntry": "Como funciona o Tanemaki", + "menuEntry": "Como funciona o Tane", "slides": { "welcome": { "title": "A semente que te trouxe até aqui", @@ -124,7 +189,9 @@ "noMatches": "Nenhuma semente corresponde aos teus filtros.", "clearFilters": "Limpar filtros", "uncategorized": "Sem categoria", - "needsReproductionFilter": "Para reproduzir" + "needsReproductionFilter": "Para reproduzir", + "loadError": "Não foi possível abrir o teu banco de sementes. Talvez estivesse ocupado — tenta de novo.", + "retry": "Tentar de novo" }, "draft": { "capture": "Capturar fotos", @@ -202,8 +269,18 @@ "anyMonth": "Qualquer mês", "noDate": "Definir data de colheita", "monthNames": [ - "janeiro", "fevereiro", "março", "abril", "maio", "junho", - "julho", "agosto", "setembro", "outubro", "novembro", "dezembro" + "janeiro", + "fevereiro", + "março", + "abril", + "maio", + "junho", + "julho", + "agosto", + "setembro", + "outubro", + "novembro", + "dezembro" ] }, "lotType": { @@ -245,6 +322,8 @@ "add": "Partilhas esta?", "title": "Partilhas esta?", "nudge": "Tens de sobra: podias partilhar um pouco.", + "price": "Preço", + "priceHint": "Deixa vazio para combinar depois", "private": "Só para mim", "gift": "Para dar", "exchange": "Para trocar", @@ -255,6 +334,23 @@ "catalogSaved": "Catálogo guardado", "cancelled": "Cancelado" }, + "printLabels": { + "action": "Imprimir etiquetas", + "title": "Imprimir etiquetas", + "selectHint": "Escolhe as sementes para imprimir as etiquetas", + "selectAll": "Selecionar tudo", + "selected": "{n} selecionadas", + "none": "Seleciona sementes primeiro", + "format": "Tamanho da etiqueta", + "formatStickers": "Autocolantes pequenos", + "formatStickersHint": "Muitas etiquetas pequenas por folha — para colar em envelopes", + "formatCards": "Cartões grandes", + "formatCardsHint": "Menos etiquetas, maiores — para frascos e caixas", + "count": "{n} etiquetas", + "save": "Guardar etiquetas", + "saved": "Etiquetas guardadas", + "cancelled": "Cancelado" + }, "cropCalendar": { "add": "Calendário de cultivo", "title": "Calendário de cultivo", @@ -263,6 +359,7 @@ "flowering": "Floração", "fruiting": "Frutificação", "seedHarvest": "Colheita de semente", + "editorHint": "Anota tu os meses típicos desta variedade na tua zona — são as tuas notas.", "unset": "—" }, "needsReproduction": { @@ -301,29 +398,332 @@ "some": "bastantes", "plenty": "muitas", "pinch": "uma pitada", - "handful": { "singular": "mão-cheia", "plural": "mãos-cheias" }, - "teaspoon": { "singular": "colher de chá", "plural": "colheres de chá" }, - "spoon": { "singular": "colher", "plural": "colheres" }, - "cup": { "singular": "chávena", "plural": "chávenas" }, - "jar": { "singular": "frasco", "plural": "frascos" }, - "sack": { "singular": "saco", "plural": "sacos" }, - "packet": { "singular": "pacote", "plural": "pacotes" }, - "cob": { "singular": "maçaroca", "plural": "maçarocas" }, - "pod": { "singular": "vagem", "plural": "vagens" }, - "ear": { "singular": "espiga", "plural": "espigas" }, - "head": { "singular": "cabeça", "plural": "cabeças" }, - "fruit": { "singular": "fruto", "plural": "frutos" }, - "bulb": { "singular": "bolbo", "plural": "bolbos" }, - "tuber": { "singular": "tubérculo", "plural": "tubérculos" }, - "seedHead": { "singular": "capítulo", "plural": "capítulos" }, - "bunch": { "singular": "molho", "plural": "molhos" }, - "plant": { "singular": "planta", "plural": "plantas" }, - "pot": { "singular": "vaso", "plural": "vasos" }, - "tray": { "singular": "tabuleiro", "plural": "tabuleiros" }, - "seedling": { "singular": "plântula", "plural": "plântulas" }, - "tree": { "singular": "árvore", "plural": "árvores" }, - "cutting": { "singular": "estaca", "plural": "estacas" }, - "grams": { "singular": "grama", "plural": "gramas" }, - "count": { "singular": "semente", "plural": "sementes" } + "handful": { + "singular": "mão-cheia", + "plural": "mãos-cheias" + }, + "teaspoon": { + "singular": "colher de chá", + "plural": "colheres de chá" + }, + "spoon": { + "singular": "colher", + "plural": "colheres" + }, + "cup": { + "singular": "chávena", + "plural": "chávenas" + }, + "jar": { + "singular": "frasco", + "plural": "frascos" + }, + "sack": { + "singular": "saco", + "plural": "sacos" + }, + "packet": { + "singular": "pacote", + "plural": "pacotes" + }, + "cob": { + "singular": "maçaroca", + "plural": "maçarocas" + }, + "pod": { + "singular": "vagem", + "plural": "vagens" + }, + "ear": { + "singular": "espiga", + "plural": "espigas" + }, + "head": { + "singular": "cabeça", + "plural": "cabeças" + }, + "fruit": { + "singular": "fruto", + "plural": "frutos" + }, + "bulb": { + "singular": "bolbo", + "plural": "bolbos" + }, + "tuber": { + "singular": "tubérculo", + "plural": "tubérculos" + }, + "seedHead": { + "singular": "capítulo", + "plural": "capítulos" + }, + "bunch": { + "singular": "molho", + "plural": "molhos" + }, + "plant": { + "singular": "planta", + "plural": "plantas" + }, + "pot": { + "singular": "vaso", + "plural": "vasos" + }, + "tray": { + "singular": "tabuleiro", + "plural": "tabuleiros" + }, + "seedling": { + "singular": "plântula", + "plural": "plântulas" + }, + "tree": { + "singular": "árvore", + "plural": "árvores" + }, + "cutting": { + "singular": "estaca", + "plural": "estacas" + }, + "grams": { + "singular": "grama", + "plural": "gramas" + }, + "count": { + "singular": "semente", + "plural": "sementes" + } + }, + "market": { + "title": "Sementes perto de ti", + "subtitle": "O que outras pessoas partilham por perto", + "notSetUp": "Ainda não configuraste a partilha", + "notSetUpBody": "Ativa a partilha para ver e dar sementes a pessoas por perto. Mantém-se aproximado — a tua zona, nunca a tua morada exata.", + "setUp": "Configurar a partilha", + "cantReach": "Não é possível ligar aos servidores neste momento", + "cantReachBody": "Tenta de novo, ou verifica os servidores na configuração avançada.", + "retry": "Tentar de novo", + "setArea": "Indica a tua zona", + "setAreaBody": "Diz ao mercado a tua zona aproximada para ver sementes por perto.", + "searching": "A procurar pela tua zona…", + "empty": "Ainda não há sementes partilhadas perto de ti", + "searchHint": "Procurar nestas sementes", + "noMatches": "Nenhuma semente partilhada corresponde à procura", + "near": "Perto de ti", + "contact": "Mensagem", + "mine": "Tu", + "configTitle": "Configuração de partilha", + "setupIntro": "Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.", + "areaLabel": "A tua zona", + "areaHelp": "Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.", + "areaSet": "A tua zona está definida — aproximada, nunca o teu ponto exato", + "areaNotSet": "Zona por definir — usa a tua localização, ou adiciona um código em Avançado", + "advanced": "Avançado", + "areaCodeLabel": "Código de zona", + "areaCodeHint": "Um código curto como sp3e9 — não um nome de lugar", + "serversLabel": "Servidores da comunidade", + "serversHelp": "Escolhe que servidores usar. Deixa assim se não souberes.", + "serversAdvanced": "Adicionar outro servidor", + "serverAddress": "Endereço do servidor", + "serverInvalid": "Introduz um endereço válido (wss://…)", + "save": "Guardar", + "saved": "Guardado", + "wanted": "Procuro", + "shareMine": "Partilhar as minhas sementes", + "sharedCount": "Partilhadas {n} sementes", + "nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender", + "useLocation": "Usar a minha localização aproximada", + "locationFailed": "Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida", + "queued": "Guardado — vamos partilhá-las quando tiveres ligação", + "shareFailed": "Não foi possível contactar os servidores — as tuas sementes não foram partilhadas. Tenta de novo daqui a pouco.", + "rangeLabel": "Até onde procurar", + "rangeNear": "Muito perto", + "rangeArea": "Pela minha zona", + "rangeRegion": "A minha região", + "sharedBy": "Partilhado por", + "noProfile": "Esta pessoa ainda não partilhou o seu perfil", + "copyId": "Copiar código", + "idCopied": "Código copiado", + "photo": "Foto" + }, + "profile": { + "title": "O teu perfil", + "name": "Nome", + "nameHint": "Como os outros te veem", + "about": "Sobre ti", + "aboutHint": "Uma linha — o que cultivas, onde", + "g1": "Endereço Ğ1 (opcional)", + "g1Hint": "Para que te paguem em Ğ1 — separado da tua chave", + "yourId": "A tua identidade", + "idHelp": "Partilha-a para que te reconheçam", + "copy": "Copiar", + "copied": "Copiado", + "save": "Guardar", + "saved": "Perfil guardado", + "identities": "As tuas identidades", + "identitiesHelp": "Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da tua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.", + "identityLabel": "Identidade {n}", + "current": "Em uso", + "newIdentity": "Nova identidade", + "switchTitle": "Mudar de identidade?", + "switchBody": "As mensagens e contactos são guardados separadamente para cada identidade. Podes voltar quando quiseres.", + "switchAction": "Mudar" + }, + "chatList": { + "title": "Mensagens", + "empty": "Ainda não há conversas. Escreve a alguém a partir do mercado." + }, + "chat": { + "title": "Conversa", + "hint": "Escreve uma mensagem…", + "send": "Enviar", + "empty": "Ainda não há mensagens — diz olá", + "offline": "Configura a partilha para enviar mensagens", + "payG1": "Pagar em Ğ1", + "g1Copied": "Endereço Ğ1 copiado — cola-o na tua carteira", + "today": "Hoje", + "yesterday": "Ontem", + "sendError": "Não foi possível enviar — verifica a tua ligação", + "noLinks": "Não são permitidos links nas mensagens" + }, + "trust": { + "none": "Ainda ninguém os avaliza", + "count": "Avalizada por {n}", + "vouch": "Conheço esta pessoa", + "vouched": "Avalizas esta pessoa", + "circle": "No teu círculo" + }, + "yourPeople": { + "title": "A tua gente", + "help": "Pessoas que conheces e avalizas, e pessoas que te avalizam.", + "youVouchFor": "Tu avalizas", + "vouchesForYou": "Avalizam-te", + "youVouchForEmpty": "Ainda não avalizas ninguém. Quando conheceres alguém, abre a vossa conversa e toca em \"Conheço esta pessoa\".", + "vouchesForYouEmpty": "Ainda ninguém te avaliza", + "revoke": "Deixar de avalizar", + "revokeConfirm": "Deixar de avalizar esta pessoa?", + "offline": "Sem ligação — tenta novamente quando estiveres em linha" + }, + "ratings": { + "rate": "Avaliar esta pessoa", + "edit": "Editar a tua avaliação", + "commentHint": "Como correu? (opcional)", + "fromYourCircle": "{n} de pessoas que conheces", + "retract": "Remover a tua avaliação", + "saved": "Avaliação guardada" + }, + "notifications": { + "newMessageFrom": "Nova mensagem de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.", + "add": "Adicionar compromisso", + "empty": "Ainda não há compromissos. Quando partilhares ou receberes semente com o compromisso de a reproduzir e devolver algo, anota-o aqui.", + "iReturn": "Reproduzo e devolvo eu", + "owedToMe": "Devolvem-me a mim", + "direction": "Quem reproduz e devolve", + "counterparty": "Com quem?", + "counterpartyHint": "Uma pessoa ou um coletivo (opcional)", + "owed": "O que é devolvido?", + "owedHint": "p. ex. um punhado na próxima temporada (opcional)", + "note": "Nota (opcional)", + "save": "Guardar", + "markReturned": "Marcar devolvido", + "markForgiven": "Dar por saldado", + "reopen": "Reabrir", + "delete": "Remover", + "statusReturned": "Devolvido", + "statusForgiven": "Saldado", + "openSection": "Pendentes", + "settledSection": "Feitos", + "removeConfirm": "Remover este compromisso?", + "returnBy": "Devolver até {date}", + "overdue": "vencido", + "dueByLabel": "Devolver até (opcional)", + "dueByHint": "Um lembrete gentil, nunca imposto", + "pickDate": "Escolher data", + "clearDate": "Limpar data", + "sectionTitle": "Compromissos" + }, + "handover": { + "title": "Dei ou recebi sementes", + "help": "Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.", + "iGave": "Dei sementes", + "iReceived": "Recebi sementes", + "whichLot": "De que lote?", + "howMuch": "Quanto?", + "allOfIt": "Tudo", + "partOfIt": "Uma parte", + "paymentChip": "Houve dinheiro pelo meio", + "promiseGave": "Vão devolver-me semente", + "promiseReceived": "Vou devolver semente" + }, + "sale": { + "title": "Vendas", + "help": "Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.", + "add": "Registar venda", + "empty": "Ainda não há vendas. Anota aqui o que vendes ou compras.", + "iSold": "Vendi", + "iBought": "Comprei", + "direction": "Vendes ou compras?", + "counterparty": "Com quem?", + "counterpartyHint": "Uma pessoa ou um coletivo (opcional)", + "amount": "Montante", + "currency": "Moeda", + "currencyHint": "€, Ğ1, horas… (opcional)", + "hours": "horas", + "note": "Nota (opcional)", + "save": "Guardar", + "delete": "Remover", + "removeConfirm": "Remover esta venda?" + }, + "legal": { + "title": "Privacidade e regras", + "subtitle": "A tua privacidade, as regras do mercado e a legalidade das sementes", + "privacyTitle": "A tua privacidade", + "privacyBody": "O Tane funciona sem conta, e tudo o que registas fica no teu dispositivo, cifrado. Não há publicidade, nem rastreadores, nem servidor do Tane.\n\nNada é partilhado a não ser que tu queiras: quando publicas uma oferta ou o teu perfil, ou envias uma mensagem, viaja por servidores comunitários. As ofertas levam apenas uma zona aproximada — nunca a tua morada.\n\nO que publicas é público, e podem ficar cópias mesmo depois de o retirares — por isso partilha com cuidado.", + "rulesTitle": "As regras do jogo", + "rulesBody": "O Tane é uma ferramenta, não uma loja: as trocas são acordadas diretamente entre pessoas e ninguém fica com comissão. Isso também significa que és responsável pelo que ofereces e envias.\n\nNo mercado: sê honesto com as tuas sementes, oferece apenas o que podes partilhar, trata bem as pessoas e não faças spam. Podes bloquear qualquer pessoa e denunciar ofertas ou pessoas que quebrem as regras — as denúncias são tratadas nos servidores comunitários.", + "seedsTitle": "Sobre partilhar sementes e mudas", + "seedsBody": "Oferecer e trocar sementes entre amadores é amplamente reconhecido na maioria dos países. Vender pode ser diferente: em muitos lugares, vender sementes de variedades não registadas oficialmente é restrito — consulta as regras locais antes de pedir um preço.\n\nEnviar sementes para outro país também costuma ser restrito, e as variedades protegidas comercialmente não podem ser propagadas sem autorização. Na dúvida, melhor local e melhor oferta.\n\nO mesmo se aplica a mudas e plantas jovens, mas a planta viva pode ter regras de transporte e fitossanitárias mais rígidas do que a semente: movê-la entre regiões ou países pode exigir controlos adicionais.", + "readFull": "Ler os documentos completos na web" + }, + "marketGate": { + "title": "Antes de entrares no mercado", + "intro": "O mercado é um espaço partilhado entre vizinhas e vizinhos. Ao continuar, aceitas umas poucas regras simples:", + "ruleHonest": "Sê honesto com as sementes que ofereces", + "ruleLegal": "Partilha apenas o que podes partilhar onde vives", + "ruleRespect": "Trata bem as pessoas — sem spam nem abusos", + "publicNote": "O que publicares aqui é público, e podem ficar cópias mesmo que o retires depois.", + "viewLegal": "Privacidade e regras", + "accept": "Aceito", + "decline": "Agora não" + }, + "report": { + "offer": "Denunciar esta oferta", + "person": "Denunciar esta pessoa", + "title": "Denunciar", + "prompt": "O que se passa?", + "reasonSpam": "Spam ou uma burla", + "reasonAbuse": "Abusivo ou desrespeitoso", + "reasonIllegal": "Sementes que não deviam ser oferecidas", + "reasonOther": "Outra coisa", + "detailsHint": "Acrescenta detalhes (opcional)", + "send": "Enviar denúncia", + "sentHidden": "Denúncia enviada — já não voltas a ver isto", + "failed": "Não foi possível enviar a denúncia — verifica a tua ligação", + "alsoBlock": "Bloquear também esta pessoa" + }, + "block": { + "action": "Bloquear esta pessoa", + "confirmTitle": "Bloquear esta pessoa?", + "confirmBody": "Deixas de ver as suas ofertas e mensagens. Podes desbloqueá-la mais tarde em Pessoas bloqueadas, na configuração de partilha.", + "confirm": "Bloquear", + "blockedToast": "Pessoa bloqueada — as suas ofertas e mensagens ficam ocultas", + "manageTitle": "Pessoas bloqueadas", + "manageEmpty": "Não bloqueaste ninguém", + "unblock": "Desbloquear" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index bbd7a3c..a0f7c8e 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -3,10 +3,10 @@ /// Source: lib/i18n /// To regenerate, run: `dart run slang` /// -/// Locales: 3 -/// Strings: 875 (291 per locale) +/// Locales: 7 +/// Strings: 3472 (496 per locale) /// -/// Built on 2026-07-10 at 00:35 UTC +/// Built on 2026-07-15 at 17:09 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import @@ -18,7 +18,11 @@ import 'package:slang/generated.dart'; import 'package:slang_flutter/slang_flutter.dart'; export 'package:slang_flutter/slang_flutter.dart'; +import 'strings_ast.g.dart' as l_ast; +import 'strings_de.g.dart' as l_de; import 'strings_es.g.dart' as l_es; +import 'strings_fr.g.dart' as l_fr; +import 'strings_ja.g.dart' as l_ja; import 'strings_pt.g.dart' as l_pt; part 'strings_en.g.dart'; @@ -30,7 +34,11 @@ part 'strings_en.g.dart'; /// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check enum AppLocale with BaseAppLocale { en(languageCode: 'en'), + ast(languageCode: 'ast'), + de(languageCode: 'de'), es(languageCode: 'es'), + fr(languageCode: 'fr'), + ja(languageCode: 'ja'), pt(languageCode: 'pt'); const AppLocale({ @@ -69,12 +77,36 @@ enum AppLocale with BaseAppLocale { cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, ); + case AppLocale.ast: + return l_ast.TranslationsAst( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + case AppLocale.de: + return l_de.TranslationsDe( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); case AppLocale.es: return l_es.TranslationsEs( overrides: overrides, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, ); + case AppLocale.fr: + return l_fr.TranslationsFr( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + case AppLocale.ja: + return l_ja.TranslationsJa( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); case AppLocale.pt: return l_pt.TranslationsPt( overrides: overrides, diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart new file mode 100644 index 0000000..e53dba8 --- /dev/null +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -0,0 +1,1991 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import +// dart format off + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'strings.g.dart'; + +// Path: +class TranslationsAst extends Translations with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + TranslationsAst({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = meta ?? TranslationMetadata( + locale: AppLocale.ast, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); + + late final TranslationsAst _root = this; // ignore: unused_field + + @override + TranslationsAst $copyWith({TranslationMetadata? meta}) => TranslationsAst(meta: meta ?? this.$meta); + + // Translations + @override late final _Translations$avatar$ast avatar = _Translations$avatar$ast._(_root); + @override late final _Translations$favorites$ast favorites = _Translations$favorites$ast._(_root); + @override late final _Translations$seedSaving$ast seedSaving = _Translations$seedSaving$ast._(_root); + @override late final _Translations$calendar$ast calendar = _Translations$calendar$ast._(_root); + @override late final _Translations$app$ast app = _Translations$app$ast._(_root); + @override late final _Translations$bootstrap$ast bootstrap = _Translations$bootstrap$ast._(_root); + @override late final _Translations$common$ast common = _Translations$common$ast._(_root); + @override late final _Translations$home$ast home = _Translations$home$ast._(_root); + @override late final _Translations$photo$ast photo = _Translations$photo$ast._(_root); + @override late final _Translations$menu$ast menu = _Translations$menu$ast._(_root); + @override late final _Translations$settings$ast settings = _Translations$settings$ast._(_root); + @override late final _Translations$backup$ast backup = _Translations$backup$ast._(_root); + @override late final _Translations$about$ast about = _Translations$about$ast._(_root); + @override late final _Translations$intro$ast intro = _Translations$intro$ast._(_root); + @override late final _Translations$inventory$ast inventory = _Translations$inventory$ast._(_root); + @override late final _Translations$draft$ast draft = _Translations$draft$ast._(_root); + @override late final _Translations$quickAdd$ast quickAdd = _Translations$quickAdd$ast._(_root); + @override late final _Translations$detail$ast detail = _Translations$detail$ast._(_root); + @override late final _Translations$germination$ast germination = _Translations$germination$ast._(_root); + @override late final _Translations$viability$ast viability = _Translations$viability$ast._(_root); + @override late final _Translations$editVariety$ast editVariety = _Translations$editVariety$ast._(_root); + @override late final _Translations$addLot$ast addLot = _Translations$addLot$ast._(_root); + @override late final _Translations$harvest$ast harvest = _Translations$harvest$ast._(_root); + @override late final _Translations$lotType$ast lotType = _Translations$lotType$ast._(_root); + @override late final _Translations$presentation$ast presentation = _Translations$presentation$ast._(_root); + @override late final _Translations$provenance$ast provenance = _Translations$provenance$ast._(_root); + @override late final _Translations$abundance$ast abundance = _Translations$abundance$ast._(_root); + @override late final _Translations$share$ast share = _Translations$share$ast._(_root); + @override late final _Translations$printLabels$ast printLabels = _Translations$printLabels$ast._(_root); + @override late final _Translations$cropCalendar$ast cropCalendar = _Translations$cropCalendar$ast._(_root); + @override late final _Translations$needsReproduction$ast needsReproduction = _Translations$needsReproduction$ast._(_root); + @override late final _Translations$preservation$ast preservation = _Translations$preservation$ast._(_root); + @override late final _Translations$conditionCheck$ast conditionCheck = _Translations$conditionCheck$ast._(_root); + @override late final _Translations$desiccant$ast desiccant = _Translations$desiccant$ast._(_root); + @override late final _Translations$unit$ast unit = _Translations$unit$ast._(_root); + @override late final _Translations$market$ast market = _Translations$market$ast._(_root); + @override late final _Translations$profile$ast profile = _Translations$profile$ast._(_root); + @override late final _Translations$chatList$ast chatList = _Translations$chatList$ast._(_root); + @override late final _Translations$chat$ast chat = _Translations$chat$ast._(_root); + @override late final _Translations$trust$ast trust = _Translations$trust$ast._(_root); + @override late final _Translations$yourPeople$ast yourPeople = _Translations$yourPeople$ast._(_root); + @override late final _Translations$ratings$ast ratings = _Translations$ratings$ast._(_root); + @override late final _Translations$notifications$ast notifications = _Translations$notifications$ast._(_root); + @override late final _Translations$plantare$ast plantare = _Translations$plantare$ast._(_root); + @override late final _Translations$handover$ast handover = _Translations$handover$ast._(_root); + @override late final _Translations$sale$ast sale = _Translations$sale$ast._(_root); + @override late final _Translations$legal$ast legal = _Translations$legal$ast._(_root); + @override late final _Translations$marketGate$ast marketGate = _Translations$marketGate$ast._(_root); + @override late final _Translations$report$ast report = _Translations$report$ast._(_root); + @override late final _Translations$block$ast block = _Translations$block$ast._(_root); +} + +// Path: avatar +class _Translations$avatar$ast extends Translations$avatar$en { + _Translations$avatar$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'La to semeya o avatar'; + @override String get fromPhoto => 'Facer o escoyer una semeya'; + @override String get illustration => 'O escueyi un dibuxu'; + @override String get remove => 'Quitar'; +} + +// Path: favorites +class _Translations$favorites$ast extends Translations$favorites$en { + _Translations$favorites$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoritos'; + @override String get empty => 'Entá nun tienes favoritos. Guarda ofertes que te presten del mercáu.'; + @override String get save => 'Guardar en favoritos'; + @override String get remove => 'Quitar de favoritos'; + @override String get unavailable => 'Yá nun ta disponible'; +} + +// Path: seedSaving +class _Translations$seedSaving$ast extends Translations$seedSaving$en { + _Translations$seedSaving$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Guardar la so semiente'; + @override String get subtitle => 'Lo que fai falta pa caltener la variedá fiel'; + @override String get lifeCycle => 'Ciclu'; + @override String get cycleAnnual => 'Añal'; + @override String get cycleBiennial => 'Bienal — da semiente el 2.u añu'; + @override String get cyclePerennial => 'Perenne'; + @override String get pollination => 'Polinización'; + @override String get pollSelf => 'Autopolinízase'; + @override String get pollCross => 'Crúciase con otres'; + @override String get pollMixed => 'Autopolinízase, pero crúciase dacuando'; + @override String get byInsect => 'por inseutos'; + @override String get byWind => 'col vientu'; + @override String get isolation => 'Xébrala'; + @override String isolationRange({required Object min, required Object max}) => '${min}–${max} m d\'otres variedaes'; + @override String isolationSingle({required Object min}) => '${min} m d\'otres variedaes'; + @override String get plants => 'Guarda de delles plantes'; + @override String plantsValue({required Object n}) => 'D\'a lo menos ${n} plantes'; + @override String get processing => 'Cómo llimpiala'; + @override String get procDry => 'Semiente seca (mayar)'; + @override String get procWet => 'Semiente húmeda (fermentar y llavar)'; + @override String get difficulty => 'Dificultá'; + @override String get diffEasy => 'Fácil'; + @override String get diffMedium => 'Media'; + @override String get diffHard => 'Difícil'; + @override String get advisory => 'Orientativu — afaílu al to clima y variedá.'; + @override String get sourcePrefix => 'Fonte'; +} + +// Path: calendar +class _Translations$calendar$ast extends Translations$calendar$en { + _Translations$calendar$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Esti mes'; + @override String get filterChip => 'Esti mes'; + @override String get selfNote => 'Lo qu\'anotasti nes tos variedaes.'; + @override String nothing({required Object month}) => 'Nada anotao pa ${month}.'; +} + +// Path: app +class _Translations$app$ast extends Translations$app$en { + _Translations$app$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Tane'; +} + +// Path: bootstrap +class _Translations$bootstrap$ast extends Translations$bootstrap$en { + _Translations$bootstrap$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get failed => 'Tane nun pudo aniciar'; + @override String get retry => 'Reintentar'; +} + +// Path: common +class _Translations$common$ast extends Translations$common$en { + _Translations$common$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get save => 'Guardar'; + @override String get cancel => 'Encaboxar'; + @override String get delete => 'Desaniciar'; + @override String get edit => 'Editar'; + @override String get type => 'Triba'; + @override String get comingSoon => 'Aína'; +} + +// Path: home +class _Translations$home$ast extends Translations$home$en { + _Translations$home$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get tagline => 'Comparte y cultiva simiente llocal'; + @override String get openMarket => 'Mercáu'; + @override String get openMarketSubtitle => 'Descubri y comparte simiente cerca'; + @override String get yourInventory => 'El to inventariu'; + @override String get yourInventorySubtitle => 'Remana la to simiente'; +} + +// Path: photo +class _Translations$photo$ast extends Translations$photo$en { + _Translations$photo$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get camera => 'Facer una semeya'; + @override String get gallery => 'Escoyer de la galería'; + @override String get setAsCover => 'Poner de portada'; + @override String get isCover => 'Semeya de portada'; + @override String get deleteConfirm => '¿Desaniciar esta semeya?'; +} + +// Path: menu +class _Translations$menu$ast extends Translations$menu$en { + _Translations$menu$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get tagline => 'el to bancu de simiente'; + @override String get inventory => 'Inventariu'; + @override String get market => 'Mercáu'; + @override String get profile => 'El to perfil'; + @override String get chat => 'Charra'; + @override String get wishlist => 'Favoritos'; + @override String get following => 'Siguiendo'; + @override String get plantares => 'Plantares'; + @override String get sales => 'Ventes'; + @override String get calendar => 'Calendariu'; + @override String get settings => 'Axustes'; +} + +// Path: settings +class _Translations$settings$ast extends Translations$settings$en { + _Translations$settings$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get language => 'Llingua'; + @override String get systemLanguage => 'Llingua del sistema'; + @override String get langEs => 'Español'; + @override String get langEn => 'English'; + @override String get langPt => 'Português'; + @override String get langAst => 'Asturianu'; + @override String get langFr => 'Français'; + @override String get langDe => 'Deutsch'; + @override String get langJa => '日本語'; + @override String get about => 'Tocante a'; + @override String get aboutText => 'Inventariu llocal y cifráu pa simiente tradicional. AGPL-3.0.'; + @override String get aboutOpen => 'Tocante a Tane'; +} + +// Path: backup +class _Translations$backup$ast extends Translations$backup$en { + _Translations$backup$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Copia de seguranza'; + @override String get autoBackupTitle => 'Copies automátiques'; + @override String autoBackupLast({required Object date, required Object days}) => 'Cabera copia\'l ${date} · cada ${days} díes'; + @override String autoBackupNone({required Object days}) => 'Guárdase una copia automáticamente cada ${days} díes'; + @override String get exportJson => 'Guardar una copia de seguranza'; + @override String get exportJsonSubtitle => 'Una copia completa pa guardar a salvo, restaurar dempués o pasar a otru preséu'; + @override String get importJson => 'Restaurar una copia'; + @override String get importJsonSubtitle => 'Recupera una copia guardada — nun se dobla nada'; + @override String get exportCsv => 'Esportar a una fueya de cálculu'; + @override String get exportCsvSubtitle => 'Una llista cenciella pa Excel o LibreOffice — ensin semeyes'; + @override String get importCsv => 'Importar una llista'; + @override String get importCsvSubtitle => 'Amiesta entraes dende una fueya de cálculu'; + @override String get importConfirmTitle => '¿Restaurar una copia?'; + @override String get importConfirmBody => 'Les entraes fusiónense col to inventariu; si una entrada esiste en dambos llaos, gana la versión más nueva. Nun se dobla nada.'; + @override String get importCsvConfirmTitle => '¿Importar una llista?'; + @override String get importCsvConfirmBody => 'Cada filera amiéstase como una entrada nueva. Nun fusiona nin troca, asina qu\'importar el mesmu ficheru dos veces amiéstalu dos veces.'; + @override String get importAction => 'Importar'; + @override String get exportSaved => 'Copia guardada'; + @override String get cancelled => 'Encaboxáu'; + @override String importDone({required Object added, required Object updated}) => 'Importáu: ${added} nueves, ${updated} anovaes'; + @override String importCsvDone({required Object count}) => 'Amestaes ${count} entraes'; + @override String get importFailed => 'Esti ficheru nun se pudo lleer como una copia de Tane'; + @override String get failed => 'Daqué salió mal'; + @override String get recoveryTitle => 'El to códigu de recuperación'; + @override String get recoverySubtitle => 'Imprémelu y guárdalu bien: abre les tos copies n\'otru preséu'; + @override String get recoveryIntro => 'Esti códigu abre les tos copies guardaes y recupera\'l to bancu en cualquier preséu. Guarda dos copies en papel en sitios seguros, como la to meyor simiente. Quien lu tenga pue lleer les tos copies, asina que nun lu compartas con naide.'; + @override String get recoveryCopy => 'Copiar'; + @override String get recoverySave => 'Guardar la fueya'; + @override String get recoverySheetTitle => 'Tane — la to fueya de recuperación'; + @override String get recoveryPromptTitle => 'Escribi\'l to códigu de recuperación'; + @override String get recoveryPromptBody => 'Esta copia guardóse con otru códigu. Escribi\'l códigu de la to fueya de recuperación p\'abrilla.'; + @override String get recoveryWrongCode => 'Esi códigu nun abre esta copia'; +} + +// Path: about +class _Translations$about$ast extends Translations$about$en { + _Translations$about$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Tocante a'; + @override String get kanji => '種'; + @override String get tagline => 'Una app local-first y descentralizada pa remanar y compartir simiente y plantones tradicionales.'; + @override String get intro => 'Tane (種, «grana» en xaponés) ayuda a persones y coleutivos a llevar un inventariu amable del so bancu de simiente, decidir qué ufierten y compartilo llocalmente — ensin un intermediariu central que pueda controlalo, censuralo o ser multáu por ello. El so nome vien de tanemaki (種まき), «semar / esparder simiente». L\'oxetivu ye a la vez práuticu y políticu: sofitar les variedaes tradicionales de simiente y plantar cara al monopoliu simienteru.'; + @override String get heritage => 'El nome honra les vieyes tradiciones xaponeses d\'ayuda mutua alredor del arroz — yui (trabayu comunitariu compartíu) y tanomoshi (fondos de reciprocidá) — qu\'inspiraron el papel Plantare, la «moneda comunitaria pal intercambiu de simiente» (BAH-Semilleru, 2009, CC-BY-SA). Tane ye\'l Plantare dixital.'; + @override String get version => 'Versión'; + @override String get license => 'Llicencia'; + @override String get licenseValue => 'AGPL-3.0'; + @override String get website => 'Sitiu web'; + @override String get sourceCode => 'Códigu fonte'; + @override String get translate => 'Ayuda a traducir'; + @override String get translateSubtitle => 'Ayuda a traer Tane a la to llingua'; + @override String get openSourceLicenses => 'Llicencies de códigu abiertu'; + @override String get openSourceLicensesSubtitle => 'Biblioteques de terceros y les sos llicencies'; + @override String copyright({required Object years}) => '© ${years} Asociación Comunes, baxo AGPLv3'; +} + +// Path: intro +class _Translations$intro$ast extends Translations$intro$en { + _Translations$intro$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get skip => 'Saltar'; + @override String get next => 'Siguiente'; + @override String get start => 'Entamar'; + @override String get menuEntry => 'Cómo furrula Tane'; + @override late final _Translations$intro$slides$ast slides = _Translations$intro$slides$ast._(_root); +} + +// Path: inventory +class _Translations$inventory$ast extends Translations$inventory$en { + _Translations$inventory$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Inventariu'; + @override String get searchHint => 'Guetar simiente'; + @override String get empty => 'Entá nun hai simiente. Toca + p\'amestar la primera.'; + @override String get noMatches => 'Nenguna simiente concasa colos filtros.'; + @override String get clearFilters => 'Quitar filtros'; + @override String get uncategorized => 'Ensin categoría'; + @override String get needsReproductionFilter => 'Por reproducir'; + @override String get loadError => 'Nun se pudo abrir el to bancu de granes. Seique taba ocupáu: prueba otra vuelta.'; + @override String get retry => 'Volver probar'; +} + +// Path: draft +class _Translations$draft$ast extends Translations$draft$en { + _Translations$draft$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get capture => 'Capturar semeyes'; + @override String captured({required Object n}) => '${n} capturaes por catalogar'; + @override String get triageTitle => 'Por catalogar'; + @override String triageCount({required Object n}) => '${n} por catalogar'; + @override String get untitled => 'Ensin nome'; + @override String get nameField => 'Ponle nome a esta simiente'; + @override String get nameHint => '¿Qué ye?'; + @override String get suggestFromPhoto => 'Suxerir nome de la semeya'; + @override String get discard => 'Descartar'; +} + +// Path: quickAdd +class _Translations$quickAdd$ast extends Translations$quickAdd$en { + _Translations$quickAdd$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Amestar una simiente'; + @override String get labelField => 'Nome'; + @override String get labelRequired => 'Ponle un nome'; + @override String get addPhoto => 'Amestar semeya'; + @override String get quantity => '¿Cuánta?'; + @override String get more => 'Amestar más…'; + @override String get save => 'Guardar'; + @override String get saveAndAddAnother => 'Guardar y amestar otra'; + @override String addedCount({required Object n}) => '${n} amestaes'; + @override String get cancel => 'Encaboxar'; +} + +// Path: detail +class _Translations$detail$ast extends Translations$detail$en { + _Translations$detail$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get notFound => 'Esta simiente yá nun ta equí.'; + @override String get lots => 'Llotes'; + @override String get noLots => 'Entá nun hai llotes.'; + @override String get names => 'Conocida tamién como'; + @override String get addName => 'Amestar nome'; + @override String get links => 'Enllaces'; + @override String get addLink => 'Amestar enllaz'; + @override String get linkUrl => 'URL'; + @override String get linkTitle => 'Títulu (opcional)'; + @override String get reference => 'Saber más'; + @override String get refGbif => 'GBIF'; + @override String get refWikipedia => 'Wikipedia'; + @override String get refWikispecies => 'Wikispecies'; + @override String get notes => 'Notes'; + @override String get addLot => 'Amestar llote'; + @override String get editLot => 'Editar llote'; + @override String get deleteConfirm => '¿Desaniciar esta simiente?'; + @override String year({required Object year}) => 'Añu ${year}'; + @override String get noYear => 'Añu desconocíu'; +} + +// Path: germination +class _Translations$germination$ast extends Translations$germination$en { + _Translations$germination$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Germinación'; + @override String get add => 'Amestar prueba'; + @override String get sampleSize => 'Amuesa'; + @override String get germinated => 'Germinaes'; + @override String get none => 'Entá nun hai pruebes de germinación.'; + @override String result({required Object percent}) => '${percent}%'; +} + +// Path: viability +class _Translations$viability$ast extends Translations$viability$en { + _Translations$viability$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get expiringSoon => 'Úsala o multiplícala esta temporada'; + @override String expiringSoonYears({required Object years}) => 'Úsala o multiplícala esta temporada · dura ~${years} años'; + @override String get expired => 'Pasa la so viabilidá típica — a multiplicar'; + @override String expiredYears({required Object years}) => 'Pasa la so viabilidá típica (~${years} años) — a multiplicar'; +} + +// Path: editVariety +class _Translations$editVariety$ast extends Translations$editVariety$en { + _Translations$editVariety$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Editar simiente'; + @override String get name => 'Nome'; + @override String get category => 'Categoría'; + @override String get notes => 'Notes'; + @override String get species => 'Especie (del catálogu)'; + @override String get speciesHint => 'Guetar una especie…'; + @override String get speciesSuggested => 'Suxerida pol nome'; + @override String get organic => 'Ecolóxica'; + @override String get organicHint => 'Cultivada de mou ecolóxicu (eco)'; +} + +// Path: addLot +class _Translations$addLot$ast extends Translations$addLot$en { + _Translations$addLot$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Amestar llote'; + @override String get year => 'Data de coyecha'; + @override String get quantity => '¿Cuánta?'; + @override String get amount => 'Cantidá'; +} + +// Path: harvest +class _Translations$harvest$ast extends Translations$harvest$en { + _Translations$harvest$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get pickTitle => 'Escoyer mes / añu'; + @override String get anyMonth => 'Cualquier mes'; + @override String get noDate => 'Indicar data de coyecha'; + @override List get monthNames => [ + 'xineru', + 'febreru', + 'marzu', + 'abril', + 'mayu', + 'xunu', + 'xunetu', + 'agostu', + 'setiembre', + 'ochobre', + 'payares', + 'avientu', + ]; +} + +// Path: lotType +class _Translations$lotType$ast extends Translations$lotType$en { + _Translations$lotType$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get seed => 'Simiente'; + @override String get plant => 'Planta'; + @override String get seedling => 'Planton'; + @override String get tree => 'Árbol / matu'; + @override String get bulb => 'Bulbu / tubérculu'; + @override String get cutting => 'Esqueje'; +} + +// Path: presentation +class _Translations$presentation$ast extends Translations$presentation$en { + _Translations$presentation$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Envase'; + @override String get none => 'Ensin especificar'; + @override String get pot => 'Tiestu'; + @override String get tray => 'Bandexa'; + @override String get plug => 'Alvéolu'; + @override String get bareRoot => 'Raíz esnuda'; + @override String get rootBall => 'Cepellón'; +} + +// Path: provenance +class _Translations$provenance$ast extends Translations$provenance$en { + _Translations$provenance$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get section => 'D\'ónde vien'; + @override String get seedsFrom => 'Simiente de'; + @override String get seedsFromHint => 'Quién la cultivó o la dio'; + @override String get place => 'Llugar'; + @override String get placeHint => 'D\'ónde vien (con provincia)'; + @override String get addSeedsFrom => 'Simiente de'; + @override String get addPlace => 'Llugar'; +} + +// Path: abundance +class _Translations$abundance$ast extends Translations$abundance$en { + _Translations$abundance$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get add => 'Cuánta tengo'; + @override String get title => 'Cuánta tengo'; + @override String get none => 'Ensin indicar'; + @override String get plentyToShare => 'A esgaya pa compartir'; + @override String get enoughToShare => 'Abondo, pa compartir con mesura'; + @override String get enoughForMe => 'Abondo pa min'; + @override String get runningLow => 'Queda poca'; +} + +// Path: share +class _Translations$share$ast extends Translations$share$en { + _Translations$share$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get add => '¿Compártesla?'; + @override String get title => '¿Compártesla?'; + @override String get nudge => 'Tienes a esgaya: podríes compartir un poco.'; + @override String get price => 'Preciu'; + @override String get priceHint => 'Déxalu baleru p\'alcordalu depués'; + @override String get private => 'Namás pa min'; + @override String get gift => 'Pa regalar'; + @override String get exchange => 'Pa trocar'; + @override String get sell => 'En venta'; + @override String get filterChip => 'Comparto'; + @override String get printCatalog => 'Imprentar lo que comparto'; + @override String get catalogTitle => 'Lo que comparto'; + @override String get catalogSaved => 'Catálogu guardáu'; + @override String get cancelled => 'Encaboxáu'; +} + +// Path: printLabels +class _Translations$printLabels$ast extends Translations$printLabels$en { + _Translations$printLabels$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get action => 'Imprentar etiquetes'; + @override String get title => 'Imprentar etiquetes'; + @override String get selectHint => 'Escueyi les semientes pa imprentar les sos etiquetes'; + @override String get selectAll => 'Seleicionar too'; + @override String selected({required Object n}) => '${n} seleicionaes'; + @override String get none => 'Seleiciona semientes primero'; + @override String get format => 'Tamañu d\'etiqueta'; + @override String get formatStickers => 'Pegatines pequeñes'; + @override String get formatStickersHint => 'Munches etiquetes pequeñes per fueya — pa pegar en sobres'; + @override String get formatCards => 'Tarxetes grandes'; + @override String get formatCardsHint => 'Menos etiquetes, más grandes — pa botes y caxes'; + @override String count({required Object n}) => '${n} etiquetes'; + @override String get save => 'Guardar etiquetes'; + @override String get saved => 'Etiquetes guardaes'; + @override String get cancelled => 'Encaboxáu'; +} + +// Path: cropCalendar +class _Translations$cropCalendar$ast extends Translations$cropCalendar$en { + _Translations$cropCalendar$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get add => 'Calendariu de cultivu'; + @override String get title => 'Calendariu de cultivu'; + @override String get sow => 'Sema'; + @override String get transplant => 'Tresplante'; + @override String get flowering => 'Floriamientu'; + @override String get fruiting => 'Fructificación'; + @override String get seedHarvest => 'Coyecha de simiente'; + @override String get editorHint => 'Anota tú los meses típicos d\'esta variedá na to zona — son notes tuyes.'; + @override String get unset => '—'; +} + +// Path: needsReproduction +class _Translations$needsReproduction$ast extends Translations$needsReproduction$en { + _Translations$needsReproduction$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get label => 'Reproducir esta temporada'; + @override String get hint => 'Cultívala enantes de que s\'acabe la simiente'; + @override String get badge => 'Por reproducir'; +} + +// Path: preservation +class _Translations$preservation$ast extends Translations$preservation$en { + _Translations$preservation$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get add => 'Cómo se caltién'; + @override String get title => 'Cómo se caltién'; + @override String get none => 'Ensin especificar'; + @override String get jarWithDesiccant => 'Bote con desecante'; + @override String get glassJar => 'Bote de cristal'; + @override String get paperEnvelope => 'Sobre de papel'; + @override String get paperBag => 'Bolsa de papel'; + @override String get plasticBag => 'Bolsa de plásticu'; +} + +// Path: conditionCheck +class _Translations$conditionCheck$ast extends Translations$conditionCheck$en { + _Translations$conditionCheck$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get advanced => 'Caltenimientu y detalles del bancu'; + @override String get title => 'Revisiones de caltenimientu'; + @override String get add => 'Amestar revisión'; + @override String get containers => 'Botes / recipientes'; + @override String get desiccant => 'Desecante'; + @override String get none => 'Entá nun hai revisiones.'; + @override String summary({required Object count, required Object state}) => '${count} bote(s) · ${state}'; +} + +// Path: desiccant +class _Translations$desiccant$ast extends Translations$desiccant$en { + _Translations$desiccant$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get none => 'Nun tien'; + @override String get add => 'Pónse'; + @override String get replace => 'Cámbiase'; + @override String get dry => 'Azul — secu'; + @override String get fresh => 'Recién puestu'; +} + +// Path: unit +class _Translations$unit$ast extends Translations$unit$en { + _Translations$unit$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get aFew => 'delles poques'; + @override String get some => 'dalgunes'; + @override String get plenty => 'munches'; + @override String get pinch => 'una migaya'; + @override late final _Translations$unit$handful$ast handful = _Translations$unit$handful$ast._(_root); + @override late final _Translations$unit$teaspoon$ast teaspoon = _Translations$unit$teaspoon$ast._(_root); + @override late final _Translations$unit$spoon$ast spoon = _Translations$unit$spoon$ast._(_root); + @override late final _Translations$unit$cup$ast cup = _Translations$unit$cup$ast._(_root); + @override late final _Translations$unit$jar$ast jar = _Translations$unit$jar$ast._(_root); + @override late final _Translations$unit$sack$ast sack = _Translations$unit$sack$ast._(_root); + @override late final _Translations$unit$packet$ast packet = _Translations$unit$packet$ast._(_root); + @override late final _Translations$unit$cob$ast cob = _Translations$unit$cob$ast._(_root); + @override late final _Translations$unit$pod$ast pod = _Translations$unit$pod$ast._(_root); + @override late final _Translations$unit$ear$ast ear = _Translations$unit$ear$ast._(_root); + @override late final _Translations$unit$head$ast head = _Translations$unit$head$ast._(_root); + @override late final _Translations$unit$fruit$ast fruit = _Translations$unit$fruit$ast._(_root); + @override late final _Translations$unit$bulb$ast bulb = _Translations$unit$bulb$ast._(_root); + @override late final _Translations$unit$tuber$ast tuber = _Translations$unit$tuber$ast._(_root); + @override late final _Translations$unit$seedHead$ast seedHead = _Translations$unit$seedHead$ast._(_root); + @override late final _Translations$unit$bunch$ast bunch = _Translations$unit$bunch$ast._(_root); + @override late final _Translations$unit$plant$ast plant = _Translations$unit$plant$ast._(_root); + @override late final _Translations$unit$pot$ast pot = _Translations$unit$pot$ast._(_root); + @override late final _Translations$unit$tray$ast tray = _Translations$unit$tray$ast._(_root); + @override late final _Translations$unit$seedling$ast seedling = _Translations$unit$seedling$ast._(_root); + @override late final _Translations$unit$tree$ast tree = _Translations$unit$tree$ast._(_root); + @override late final _Translations$unit$cutting$ast cutting = _Translations$unit$cutting$ast._(_root); + @override late final _Translations$unit$grams$ast grams = _Translations$unit$grams$ast._(_root); + @override late final _Translations$unit$count$ast count = _Translations$unit$count$ast._(_root); +} + +// Path: market +class _Translations$market$ast extends Translations$market$en { + _Translations$market$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Simiente cerca de ti'; + @override String get subtitle => 'Lo qu\'otres persones comparten cerca'; + @override String get notSetUp => 'Entá nun configurasti\'l compartir'; + @override String get notSetUpBody => 'Activa\'l compartir pa ver y dar simiente a xente cercano. Caltiénse averao — la to zona, enxamás la to direición esacta.'; + @override String get setUp => 'Configurar el compartir'; + @override String get cantReach => 'Nun se pue coneutar colos servidores agora mesmo'; + @override String get cantReachBody => 'Inténtalo otra vez, o revisa los servidores na configuración avanzada.'; + @override String get retry => 'Retentar'; + @override String get setArea => 'Indica la to zona'; + @override String get setAreaBody => 'Dile al mercáu la to zona averada pa ver simiente cerca.'; + @override String get searching => 'Guetando pela to zona…'; + @override String get empty => 'Entá nun hai simiente compartida cerca de ti'; + @override String get near => 'Cerca de ti'; + @override String get contact => 'Mensaxe'; + @override String get mine => 'Tu'; + @override String get configTitle => 'Configuración de compartir'; + @override String get setupIntro => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu\'otres persones atopen lo qu\'ufiertes, ensin nenguna empresa en mediu.'; + @override String get areaLabel => 'La to zona'; + @override String get areaHelp => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.'; + @override String get areaSet => 'La to zona ta puesta — averada, enxamás el to puntu esactu'; + @override String get areaNotSet => 'Zona ensin definir — usa\'l to allugamientu, o amiesta un códigu n\'Avanzáu'; + @override String get advanced => 'Avanzáu'; + @override String get areaCodeLabel => 'Códigu de zona'; + @override String get areaCodeHint => 'Un códigu curtiu como sp3e9 — non un nome de llugar'; + @override String get serversLabel => 'Servidores de la comunidá'; + @override String get serversHelp => 'Escueyi qué servidores usar. Déxalo asina si nun sabes.'; + @override String get serversAdvanced => 'Amestar otru servidor'; + @override String get serverAddress => 'Direición del servidor'; + @override String get serverInvalid => 'Introduz una direición válida (wss://…)'; + @override String get save => 'Guardar'; + @override String get saved => 'Guardáu'; + @override String get wanted => 'Gueto'; + @override String get shareMine => 'Compartir la mio simiente'; + @override String sharedCount({required Object n}) => 'Compartíes ${n} simientes'; + @override String get nothingToShare => 'Marca primero dalguna simiente pa regalar, trocar o vender'; + @override String get useLocation => 'Usar el mio allugamientu averáu'; + @override String get locationFailed => 'Nun se pudo consiguir el to allugamientu — comprueba que l\'allugamientu ta activáu y el permisu concedíu'; + @override String get queued => 'Guardáu — compartirémosles cuando tengas conexón'; + @override String get shareFailed => 'Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.'; + @override String get rangeLabel => 'Hasta ónde guetar'; + @override String get rangeNear => 'Bien cerca'; + @override String get rangeArea => 'Pela mio zona'; + @override String get rangeRegion => 'La mio rexón'; + @override String get sharedBy => 'Compartíu por'; + @override String get noProfile => 'Esta persona entá nun compartió\'l so perfil'; + @override String get copyId => 'Copiar códigu'; + @override String get idCopied => 'Códigu copiáu'; + @override String get photo => 'Semeya'; +} + +// Path: profile +class _Translations$profile$ast extends Translations$profile$en { + _Translations$profile$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'El to perfil'; + @override String get name => 'Nome'; + @override String get nameHint => 'Cómo te ven los demás'; + @override String get about => 'Sobre ti'; + @override String get aboutHint => 'Una llinia — qué cultives, ónde'; + @override String get g1 => 'Direición Ğ1 (opcional)'; + @override String get g1Hint => 'Pa que te paguen en Ğ1 — aparte de la to clave'; + @override String get yourId => 'La to identidá'; + @override String get idHelp => 'Compártela pa que te reconozan'; + @override String get copy => 'Copiar'; + @override String get copied => 'Copiao'; + @override String get save => 'Guardar'; + @override String get saved => 'Perfil guardáu'; + @override String get identities => 'Les tos identidaes'; + @override String get identitiesHelp => 'Ten identidaes separaes — cada una coles sos mensaxes y contactos. Toes salen de la to única copia de seguridá, asina que camudar nun amiesta nada que recordar.'; + @override String identityLabel({required Object n}) => 'Identidá ${n}'; + @override String get current => 'N\'usu'; + @override String get newIdentity => 'Identidá nueva'; + @override String get switchTitle => '¿Camudar d\'identidá?'; + @override String get switchBody => 'Los mensaxes y contactos guárdense per separao pa cada identidá. Pues volver cuando quieras.'; + @override String get switchAction => 'Camudar'; +} + +// Path: chatList +class _Translations$chatList$ast extends Translations$chatList$en { + _Translations$chatList$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Mensaxes'; + @override String get empty => 'Entá nun hai charres. Escríbi-y a alguien dende\'l mercáu.'; +} + +// Path: chat +class _Translations$chat$ast extends Translations$chat$en { + _Translations$chat$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Charra'; + @override String get hint => 'Escribi un mensaxe…'; + @override String get send => 'Unviar'; + @override String get empty => 'Entá nun hai mensaxes — saluda'; + @override String get offline => 'Configura\'l compartir pa unviar mensaxes'; + @override String get payG1 => 'Pagar en Ğ1'; + @override String get g1Copied => 'Direición Ğ1 copiada — apégala na to cartera'; + @override String get today => 'Güei'; + @override String get yesterday => 'Ayeri'; + @override String get sendError => 'Nun se pudo unviar — comprueba la conexón'; + @override String get noLinks => 'Nun se permiten enllaces nos mensaxes'; +} + +// Path: trust +class _Translations$trust$ast extends Translations$trust$en { + _Translations$trust$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get none => 'Naide los avala entá'; + @override String count({required Object n}) => 'Avalada por ${n}'; + @override String get vouch => 'Conozo a esta persona'; + @override String get vouched => 'Avales a esta persona'; + @override String get circle => 'Nel to círculu'; +} + +// Path: yourPeople +class _Translations$yourPeople$ast extends Translations$yourPeople$en { + _Translations$yourPeople$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'La to xente'; + @override String get help => 'Persones que conoces y avales, y persones que t\'avalen.'; + @override String get youVouchFor => 'Tu avales a'; + @override String get vouchesForYou => 'Aválente'; + @override String get youVouchForEmpty => 'Entá nun avales a naide. Cuando conozas a daquién, abri la vuestra conversación y calca "Conozo a esta persona".'; + @override String get vouchesForYouEmpty => 'Naide t\'avala entá'; + @override String get revoke => 'Dexar d\'avalar'; + @override String get revokeConfirm => '¿Dexar d\'avalar a esta persona?'; + @override String get offline => 'Ensin conexón — vuelvi tentalo cuando teas en llinia'; +} + +// Path: ratings +class _Translations$ratings$ast extends Translations$ratings$en { + _Translations$ratings$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get rate => 'Valorar a esta persona'; + @override String get edit => 'Editar la to valoración'; + @override String get commentHint => '¿Qué tal foi? (opcional)'; + @override String fromYourCircle({required Object n}) => '${n} de xente que conoces'; + @override String get retract => 'Quitar la to valoración'; + @override String get saved => 'Valoración guardada'; +} + +// Path: notifications +class _Translations$notifications$ast extends Translations$notifications$en { + _Translations$notifications$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Mensaxe nuevu de ${name}'; +} + +// Path: plantare +class _Translations$plantare$ast extends Translations$plantare$en { + _Translations$plantare$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Un Plantare ye\'l compromisu de reproducir una semilla y devolver una parte — asina una variedá sigue viaxando de mano en mano. Nun ye una venta.'; + @override String get add => 'Amestar compromisu'; + @override String get empty => 'Entá nun hai compromisos. Cuando compartas o recibas semilla col compromisu de reproducila y devolver daqué, anótalo equí.'; + @override String get iReturn => 'Reprodúzola y devuélvola yo'; + @override String get owedToMe => 'Devuélvenmela a min'; + @override String get direction => 'Quién reproduz y devuelve'; + @override String get counterparty => '¿Con quién?'; + @override String get counterpartyHint => 'Una persona o un coleutivu (opcional)'; + @override String get owed => '¿Qué se devuelve?'; + @override String get owedHint => 'p. ex. un puñáu la próxima temporada (opcional)'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get markReturned => 'Marcar devueltu'; + @override String get markForgiven => 'Dar por saldáu'; + @override String get reopen => 'Reabrir'; + @override String get delete => 'Quitar'; + @override String get statusReturned => 'Devueltu'; + @override String get statusForgiven => 'Saldáu'; + @override String get openSection => 'Pendientes'; + @override String get settledSection => 'Fechos'; + @override String get removeConfirm => '¿Quitar esti compromisu?'; + @override String returnBy({required Object date}) => 'Devolver enantes del ${date}'; + @override String get overdue => 'vencíu'; + @override String get dueByLabel => 'Devolver enantes de (opcional)'; + @override String get dueByHint => 'Un recordatoriu suave, nunca obligatoriu'; + @override String get pickDate => 'Escoyer fecha'; + @override String get clearDate => 'Quitar fecha'; + @override String get sectionTitle => 'Compromisos'; +} + +// Path: handover +class _Translations$handover$ast extends Translations$handover$en { + _Translations$handover$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Di o recibí semientes'; + @override String get help => 'Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.'; + @override String get iGave => 'Di semientes'; + @override String get iReceived => 'Recibí semientes'; + @override String get whichLot => '¿De qué llote?'; + @override String get howMuch => '¿Cuánto?'; + @override String get allOfIt => 'Too'; + @override String get partOfIt => 'Una parte'; + @override String get paymentChip => 'Hubo perres pel mediu'; + @override String get promiseGave => 'Van devolveme semiente'; + @override String get promiseReceived => 'Voi devolver semiente'; +} + +// Path: sale +class _Translations$sale$ast extends Translations$sale$en { + _Translations$sale$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Ventes'; + @override String get help => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.'; + @override String get add => 'Rexistrar venta'; + @override String get empty => 'Entá nun hai ventes. Anota equí lo que vendes o merques.'; + @override String get iSold => 'Vendí'; + @override String get iBought => 'Mercué'; + @override String get direction => '¿Vendes o merques?'; + @override String get counterparty => '¿Con quién?'; + @override String get counterpartyHint => 'Una persona o un coleutivu (opcional)'; + @override String get amount => 'Importe'; + @override String get currency => 'Moneda'; + @override String get currencyHint => '€, Ğ1, hores… (opcional)'; + @override String get hours => 'hores'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get delete => 'Quitar'; + @override String get removeConfirm => '¿Quitar esta venta?'; +} + +// Path: legal +class _Translations$legal$ast extends Translations$legal$en { + _Translations$legal$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Privacidá y normes'; + @override String get subtitle => 'La to privacidá, les normes del mercáu y la llegalidá de la simiente'; + @override String get privacyTitle => 'La to privacidá'; + @override String get privacyBody => 'Tane funciona ensin cuenta, y tolo que rexistres queda nel to preséu, cifrao. Nun hai publicidá, nin rastreadores, nin sirvidor de Tane.\n\nNun se comparte nada sacantes que tu quieras: cuando espublices una ofierta o\'l to perfil, o unvíes un mensaxe, viaxa per sirvidores comuñales. Les ofiertes namái lleven una zona averada — enxamás la to direición.\n\nLo qu\'espubliques ye público, y puen quedar copies mesmo dempués de retiralo — asina que comparte con procuru.'; + @override String get rulesTitle => 'Les regles del xuegu'; + @override String get rulesBody => 'Tane ye una ferramienta, non una tienda: los intercambeos alcuérdense direutamente ente persones y naide lleva comisión. Eso tamién quier dicir que tu respuendes de lo qu\'ufiertes y unvíes.\n\nNel mercáu: sé honestu cola to simiente, ufierta namái lo que puedas compartir, trata bien a la xente y nun faigas spam. Puedes bloquiar a cualquiera y denunciar ofiertes o persones qu\'incumplan les normes — les denuncies atiéndense nos sirvidores comuñales.'; + @override String get seedsTitle => 'Tocante a compartir simiente y plantones'; + @override String get seedsBody => 'Regalar ya intercambiar simiente ente aficionaos ta reconocío ampliamente na mayoría de países. Vender pue ser distinto: en munchos sitios, vender simiente de variedaes non rexistraes oficialmente ta restrinxío — consulta la to normativa enantes de pidir un preciu.\n\nUnviar simiente a otru país tamién suel tar restrinxío, y les variedaes protexíes comercialmente nun puen propagase ensin permisu. Ante la dulda, meyor local y meyor regalu.\n\nLo mesmo val pa plantones y plantes moces, pero la planta viva pue tener regles de tresporte y fitosanitaries más estrictes que la simiente: movela ente rexones o países pue requerir controles adicionales.'; + @override String get readFull => 'Lleer los documentos completos na web'; +} + +// Path: marketGate +class _Translations$marketGate$ast extends Translations$marketGate$en { + _Translations$marketGate$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Enantes d\'entrar al mercáu'; + @override String get intro => 'El mercáu ye un espaciu compartíu ente vecines y vecinos. Al siguir, aceptes unes poques normes cencielles:'; + @override String get ruleHonest => 'Sé honestu cola simiente qu\'ufiertes'; + @override String get ruleLegal => 'Comparte namái lo que puedas compartir onde vives'; + @override String get ruleRespect => 'Trata bien a la xente — ensin spam nin abusos'; + @override String get publicNote => 'Lo qu\'espubliques equí ye público, y puen quedar copies anque lo retires dempués.'; + @override String get viewLegal => 'Privacidá y normes'; + @override String get accept => 'Acepto'; + @override String get decline => 'Agora non'; +} + +// Path: report +class _Translations$report$ast extends Translations$report$en { + _Translations$report$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get offer => 'Denunciar esta ofierta'; + @override String get person => 'Denunciar a esta persona'; + @override String get title => 'Denunciar'; + @override String get prompt => '¿Qué pasa?'; + @override String get reasonSpam => 'Spam o un engañu'; + @override String get reasonAbuse => 'Abusivo o irrespetuoso'; + @override String get reasonIllegal => 'Simiente que nun tendría d\'ufiertase'; + @override String get reasonOther => 'Otra cosa'; + @override String get detailsHint => 'Amiesta detalles (opcional)'; + @override String get send => 'Unviar denuncia'; + @override String get sentHidden => 'Denuncia unviada — yá nun vas ver esto'; + @override String get failed => 'Nun se pudo unviar la denuncia — revisa la conexón'; + @override String get alsoBlock => 'Bloquiar tamién a esta persona'; +} + +// Path: block +class _Translations$block$ast extends Translations$block$en { + _Translations$block$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get action => 'Bloquiar a esta persona'; + @override String get confirmTitle => '¿Bloquiar a esta persona?'; + @override String get confirmBody => 'Vas dexar de ver les sos ofiertes y mensaxes. Puedes desbloquiala más alantre en Persones bloquiaes, na configuración de compartir.'; + @override String get confirm => 'Bloquiar'; + @override String get blockedToast => 'Persona bloquiada — les sos ofiertes y mensaxes queden anubríos'; + @override String get manageTitle => 'Persones bloquiaes'; + @override String get manageEmpty => 'Nun bloquiesti a naide'; + @override String get unblock => 'Desbloquiar'; +} + +// Path: intro.slides +class _Translations$intro$slides$ast extends Translations$intro$slides$en { + _Translations$intro$slides$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override late final _Translations$intro$slides$welcome$ast welcome = _Translations$intro$slides$welcome$ast._(_root); + @override late final _Translations$intro$slides$inventory$ast inventory = _Translations$intro$slides$inventory$ast._(_root); + @override late final _Translations$intro$slides$privacy$ast privacy = _Translations$intro$slides$privacy$ast._(_root); + @override late final _Translations$intro$slides$share$ast share = _Translations$intro$slides$share$ast._(_root); + @override late final _Translations$intro$slides$plantare$ast plantare = _Translations$intro$slides$plantare$ast._(_root); +} + +// Path: unit.handful +class _Translations$unit$handful$ast extends Translations$unit$handful$en { + _Translations$unit$handful$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'puñáu'; + @override String get plural => 'puñaos'; +} + +// Path: unit.teaspoon +class _Translations$unit$teaspoon$ast extends Translations$unit$teaspoon$en { + _Translations$unit$teaspoon$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'cuyaradina'; + @override String get plural => 'cuyaradines'; +} + +// Path: unit.spoon +class _Translations$unit$spoon$ast extends Translations$unit$spoon$en { + _Translations$unit$spoon$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'cuyar'; + @override String get plural => 'cuyares'; +} + +// Path: unit.cup +class _Translations$unit$cup$ast extends Translations$unit$cup$en { + _Translations$unit$cup$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'taza'; + @override String get plural => 'tazes'; +} + +// Path: unit.jar +class _Translations$unit$jar$ast extends Translations$unit$jar$en { + _Translations$unit$jar$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'bote'; + @override String get plural => 'botes'; +} + +// Path: unit.sack +class _Translations$unit$sack$ast extends Translations$unit$sack$en { + _Translations$unit$sack$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'sacu'; + @override String get plural => 'sacos'; +} + +// Path: unit.packet +class _Translations$unit$packet$ast extends Translations$unit$packet$en { + _Translations$unit$packet$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'sobre'; + @override String get plural => 'sobres'; +} + +// Path: unit.cob +class _Translations$unit$cob$ast extends Translations$unit$cob$en { + _Translations$unit$cob$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'panoya'; + @override String get plural => 'panoyes'; +} + +// Path: unit.pod +class _Translations$unit$pod$ast extends Translations$unit$pod$en { + _Translations$unit$pod$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'vaina'; + @override String get plural => 'vaines'; +} + +// Path: unit.ear +class _Translations$unit$ear$ast extends Translations$unit$ear$en { + _Translations$unit$ear$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'espiga'; + @override String get plural => 'espigues'; +} + +// Path: unit.head +class _Translations$unit$head$ast extends Translations$unit$head$en { + _Translations$unit$head$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'cabezuela'; + @override String get plural => 'cabezueles'; +} + +// Path: unit.fruit +class _Translations$unit$fruit$ast extends Translations$unit$fruit$en { + _Translations$unit$fruit$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'frutu'; + @override String get plural => 'frutos'; +} + +// Path: unit.bulb +class _Translations$unit$bulb$ast extends Translations$unit$bulb$en { + _Translations$unit$bulb$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'bulbu'; + @override String get plural => 'bulbos'; +} + +// Path: unit.tuber +class _Translations$unit$tuber$ast extends Translations$unit$tuber$en { + _Translations$unit$tuber$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'tubérculu'; + @override String get plural => 'tubérculos'; +} + +// Path: unit.seedHead +class _Translations$unit$seedHead$ast extends Translations$unit$seedHead$en { + _Translations$unit$seedHead$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'cabeza de simiente'; + @override String get plural => 'cabeces de simiente'; +} + +// Path: unit.bunch +class _Translations$unit$bunch$ast extends Translations$unit$bunch$en { + _Translations$unit$bunch$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'manoyu'; + @override String get plural => 'manoyos'; +} + +// Path: unit.plant +class _Translations$unit$plant$ast extends Translations$unit$plant$en { + _Translations$unit$plant$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'planta'; + @override String get plural => 'plantes'; +} + +// Path: unit.pot +class _Translations$unit$pot$ast extends Translations$unit$pot$en { + _Translations$unit$pot$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'tiestu'; + @override String get plural => 'tiestos'; +} + +// Path: unit.tray +class _Translations$unit$tray$ast extends Translations$unit$tray$en { + _Translations$unit$tray$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'bandexa'; + @override String get plural => 'bandexes'; +} + +// Path: unit.seedling +class _Translations$unit$seedling$ast extends Translations$unit$seedling$en { + _Translations$unit$seedling$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'plántula'; + @override String get plural => 'plántules'; +} + +// Path: unit.tree +class _Translations$unit$tree$ast extends Translations$unit$tree$en { + _Translations$unit$tree$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'árbol'; + @override String get plural => 'árboles'; +} + +// Path: unit.cutting +class _Translations$unit$cutting$ast extends Translations$unit$cutting$en { + _Translations$unit$cutting$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'esqueje'; + @override String get plural => 'esquejes'; +} + +// Path: unit.grams +class _Translations$unit$grams$ast extends Translations$unit$grams$en { + _Translations$unit$grams$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'gramu'; + @override String get plural => 'gramos'; +} + +// Path: unit.count +class _Translations$unit$count$ast extends Translations$unit$count$en { + _Translations$unit$count$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get singular => 'grana'; + @override String get plural => 'granes'; +} + +// Path: intro.slides.welcome +class _Translations$intro$slides$welcome$ast extends Translations$intro$slides$welcome$en { + _Translations$intro$slides$welcome$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'La grana que te traxo hasta equí'; + @override String get body => 'Cada simiente tradicional ye una carta escrita por miles de xeneraciones, que pasó de mano en mano. Somos lo que somos gracies a esi intercambiu.'; +} + +// Path: intro.slides.inventory +class _Translations$intro$slides$inventory$ast extends Translations$intro$slides$inventory$en { + _Translations$intro$slides$inventory$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'El to bancu de simiente, nel bolsu'; + @override String get body => 'Apunta qué tienes, de qué añu, cuánto y d\'ónde vieno, col nome que tu uses. Una semeya y un nome abasten pa entamar.'; +} + +// Path: intro.slides.privacy +class _Translations$intro$slides$privacy$ast extends Translations$intro$slides$privacy$en { + _Translations$intro$slides$privacy$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Tuyo y namás tuyo'; + @override String get body => 'Ensin cuenta, ensin internet, ensin rastrexadores. Los tos datos viven cifraos nel to preséu y namás se comparte lo que tu decidas.'; +} + +// Path: intro.slides.share +class _Translations$intro$slides$share$ast extends Translations$intro$slides$share$en { + _Translations$intro$slides$share$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Compartir, como siempres se fizo'; + @override String get body => 'Ufierta lo que te sobra —regalu, truecu o venta— y qu\'alguien cerca lo atope. Namás s\'amuesa la distancia averada, enxamás la to direición; el tratu ciérraslu en persona.'; +} + +// Path: intro.slides.plantare +class _Translations$intro$slides$plantare$ast extends Translations$intro$slides$plantare$en { + _Translations$intro$slides$plantare$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Semar ye multiplicar'; + @override String get body => 'Col Plantare, quien recibe promete devolver simiente más alantre. Y como pa devolvela hai que cultivala, cada préstamu multiplica\'l común.'; +} + +/// The flat map containing all translations for locale . +/// Only for edge cases! For simple maps, use the map function of this library. +/// +/// The Dart AOT compiler has issues with very large switch statements, +/// so the map is split into smaller functions (512 entries each). +extension on TranslationsAst { + dynamic _flatMapFunction(String path) { + return switch (path) { + 'avatar.title' => 'La to semeya o avatar', + 'avatar.fromPhoto' => 'Facer o escoyer una semeya', + 'avatar.illustration' => 'O escueyi un dibuxu', + 'avatar.remove' => 'Quitar', + 'favorites.title' => 'Favoritos', + 'favorites.empty' => 'Entá nun tienes favoritos. Guarda ofertes que te presten del mercáu.', + 'favorites.save' => 'Guardar en favoritos', + 'favorites.remove' => 'Quitar de favoritos', + 'favorites.unavailable' => 'Yá nun ta disponible', + 'seedSaving.title' => 'Guardar la so semiente', + 'seedSaving.subtitle' => 'Lo que fai falta pa caltener la variedá fiel', + 'seedSaving.lifeCycle' => 'Ciclu', + 'seedSaving.cycleAnnual' => 'Añal', + 'seedSaving.cycleBiennial' => 'Bienal — da semiente el 2.u añu', + 'seedSaving.cyclePerennial' => 'Perenne', + 'seedSaving.pollination' => 'Polinización', + 'seedSaving.pollSelf' => 'Autopolinízase', + 'seedSaving.pollCross' => 'Crúciase con otres', + 'seedSaving.pollMixed' => 'Autopolinízase, pero crúciase dacuando', + 'seedSaving.byInsect' => 'por inseutos', + 'seedSaving.byWind' => 'col vientu', + 'seedSaving.isolation' => 'Xébrala', + 'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m d\'otres variedaes', + 'seedSaving.isolationSingle' => ({required Object min}) => '${min} m d\'otres variedaes', + 'seedSaving.plants' => 'Guarda de delles plantes', + 'seedSaving.plantsValue' => ({required Object n}) => 'D\'a lo menos ${n} plantes', + 'seedSaving.processing' => 'Cómo llimpiala', + 'seedSaving.procDry' => 'Semiente seca (mayar)', + 'seedSaving.procWet' => 'Semiente húmeda (fermentar y llavar)', + 'seedSaving.difficulty' => 'Dificultá', + 'seedSaving.diffEasy' => 'Fácil', + 'seedSaving.diffMedium' => 'Media', + 'seedSaving.diffHard' => 'Difícil', + 'seedSaving.advisory' => 'Orientativu — afaílu al to clima y variedá.', + 'seedSaving.sourcePrefix' => 'Fonte', + 'calendar.title' => 'Esti mes', + 'calendar.filterChip' => 'Esti mes', + 'calendar.selfNote' => 'Lo qu\'anotasti nes tos variedaes.', + 'calendar.nothing' => ({required Object month}) => 'Nada anotao pa ${month}.', + 'app.title' => 'Tane', + 'bootstrap.failed' => 'Tane nun pudo aniciar', + 'bootstrap.retry' => 'Reintentar', + 'common.save' => 'Guardar', + 'common.cancel' => 'Encaboxar', + 'common.delete' => 'Desaniciar', + 'common.edit' => 'Editar', + 'common.type' => 'Triba', + 'common.comingSoon' => 'Aína', + 'home.tagline' => 'Comparte y cultiva simiente llocal', + 'home.openMarket' => 'Mercáu', + 'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca', + 'home.yourInventory' => 'El to inventariu', + 'home.yourInventorySubtitle' => 'Remana la to simiente', + 'photo.camera' => 'Facer una semeya', + 'photo.gallery' => 'Escoyer de la galería', + 'photo.setAsCover' => 'Poner de portada', + 'photo.isCover' => 'Semeya de portada', + 'photo.deleteConfirm' => '¿Desaniciar esta semeya?', + 'menu.tagline' => 'el to bancu de simiente', + 'menu.inventory' => 'Inventariu', + 'menu.market' => 'Mercáu', + 'menu.profile' => 'El to perfil', + 'menu.chat' => 'Charra', + 'menu.wishlist' => 'Favoritos', + 'menu.following' => 'Siguiendo', + 'menu.plantares' => 'Plantares', + 'menu.sales' => 'Ventes', + 'menu.calendar' => 'Calendariu', + 'menu.settings' => 'Axustes', + 'settings.language' => 'Llingua', + 'settings.systemLanguage' => 'Llingua del sistema', + 'settings.langEs' => 'Español', + 'settings.langEn' => 'English', + 'settings.langPt' => 'Português', + 'settings.langAst' => 'Asturianu', + 'settings.langFr' => 'Français', + 'settings.langDe' => 'Deutsch', + 'settings.langJa' => '日本語', + 'settings.about' => 'Tocante a', + 'settings.aboutText' => 'Inventariu llocal y cifráu pa simiente tradicional. AGPL-3.0.', + 'settings.aboutOpen' => 'Tocante a Tane', + 'backup.title' => 'Copia de seguranza', + 'backup.autoBackupTitle' => 'Copies automátiques', + 'backup.autoBackupLast' => ({required Object date, required Object days}) => 'Cabera copia\'l ${date} · cada ${days} díes', + 'backup.autoBackupNone' => ({required Object days}) => 'Guárdase una copia automáticamente cada ${days} díes', + 'backup.exportJson' => 'Guardar una copia de seguranza', + 'backup.exportJsonSubtitle' => 'Una copia completa pa guardar a salvo, restaurar dempués o pasar a otru preséu', + 'backup.importJson' => 'Restaurar una copia', + 'backup.importJsonSubtitle' => 'Recupera una copia guardada — nun se dobla nada', + 'backup.exportCsv' => 'Esportar a una fueya de cálculu', + 'backup.exportCsvSubtitle' => 'Una llista cenciella pa Excel o LibreOffice — ensin semeyes', + 'backup.importCsv' => 'Importar una llista', + 'backup.importCsvSubtitle' => 'Amiesta entraes dende una fueya de cálculu', + 'backup.importConfirmTitle' => '¿Restaurar una copia?', + 'backup.importConfirmBody' => 'Les entraes fusiónense col to inventariu; si una entrada esiste en dambos llaos, gana la versión más nueva. Nun se dobla nada.', + 'backup.importCsvConfirmTitle' => '¿Importar una llista?', + 'backup.importCsvConfirmBody' => 'Cada filera amiéstase como una entrada nueva. Nun fusiona nin troca, asina qu\'importar el mesmu ficheru dos veces amiéstalu dos veces.', + 'backup.importAction' => 'Importar', + 'backup.exportSaved' => 'Copia guardada', + 'backup.cancelled' => 'Encaboxáu', + 'backup.importDone' => ({required Object added, required Object updated}) => 'Importáu: ${added} nueves, ${updated} anovaes', + 'backup.importCsvDone' => ({required Object count}) => 'Amestaes ${count} entraes', + 'backup.importFailed' => 'Esti ficheru nun se pudo lleer como una copia de Tane', + 'backup.failed' => 'Daqué salió mal', + 'backup.recoveryTitle' => 'El to códigu de recuperación', + 'backup.recoverySubtitle' => 'Imprémelu y guárdalu bien: abre les tos copies n\'otru preséu', + 'backup.recoveryIntro' => 'Esti códigu abre les tos copies guardaes y recupera\'l to bancu en cualquier preséu. Guarda dos copies en papel en sitios seguros, como la to meyor simiente. Quien lu tenga pue lleer les tos copies, asina que nun lu compartas con naide.', + 'backup.recoveryCopy' => 'Copiar', + 'backup.recoverySave' => 'Guardar la fueya', + 'backup.recoverySheetTitle' => 'Tane — la to fueya de recuperación', + 'backup.recoveryPromptTitle' => 'Escribi\'l to códigu de recuperación', + 'backup.recoveryPromptBody' => 'Esta copia guardóse con otru códigu. Escribi\'l códigu de la to fueya de recuperación p\'abrilla.', + 'backup.recoveryWrongCode' => 'Esi códigu nun abre esta copia', + 'about.title' => 'Tocante a', + 'about.kanji' => '種', + 'about.tagline' => 'Una app local-first y descentralizada pa remanar y compartir simiente y plantones tradicionales.', + 'about.intro' => 'Tane (種, «grana» en xaponés) ayuda a persones y coleutivos a llevar un inventariu amable del so bancu de simiente, decidir qué ufierten y compartilo llocalmente — ensin un intermediariu central que pueda controlalo, censuralo o ser multáu por ello. El so nome vien de tanemaki (種まき), «semar / esparder simiente». L\'oxetivu ye a la vez práuticu y políticu: sofitar les variedaes tradicionales de simiente y plantar cara al monopoliu simienteru.', + 'about.heritage' => 'El nome honra les vieyes tradiciones xaponeses d\'ayuda mutua alredor del arroz — yui (trabayu comunitariu compartíu) y tanomoshi (fondos de reciprocidá) — qu\'inspiraron el papel Plantare, la «moneda comunitaria pal intercambiu de simiente» (BAH-Semilleru, 2009, CC-BY-SA). Tane ye\'l Plantare dixital.', + 'about.version' => 'Versión', + 'about.license' => 'Llicencia', + 'about.licenseValue' => 'AGPL-3.0', + 'about.website' => 'Sitiu web', + 'about.sourceCode' => 'Códigu fonte', + 'about.translate' => 'Ayuda a traducir', + 'about.translateSubtitle' => 'Ayuda a traer Tane a la to llingua', + 'about.openSourceLicenses' => 'Llicencies de códigu abiertu', + 'about.openSourceLicensesSubtitle' => 'Biblioteques de terceros y les sos llicencies', + 'about.copyright' => ({required Object years}) => '© ${years} Asociación Comunes, baxo AGPLv3', + 'intro.skip' => 'Saltar', + 'intro.next' => 'Siguiente', + 'intro.start' => 'Entamar', + 'intro.menuEntry' => 'Cómo furrula Tane', + 'intro.slides.welcome.title' => 'La grana que te traxo hasta equí', + 'intro.slides.welcome.body' => 'Cada simiente tradicional ye una carta escrita por miles de xeneraciones, que pasó de mano en mano. Somos lo que somos gracies a esi intercambiu.', + 'intro.slides.inventory.title' => 'El to bancu de simiente, nel bolsu', + 'intro.slides.inventory.body' => 'Apunta qué tienes, de qué añu, cuánto y d\'ónde vieno, col nome que tu uses. Una semeya y un nome abasten pa entamar.', + 'intro.slides.privacy.title' => 'Tuyo y namás tuyo', + 'intro.slides.privacy.body' => 'Ensin cuenta, ensin internet, ensin rastrexadores. Los tos datos viven cifraos nel to preséu y namás se comparte lo que tu decidas.', + 'intro.slides.share.title' => 'Compartir, como siempres se fizo', + 'intro.slides.share.body' => 'Ufierta lo que te sobra —regalu, truecu o venta— y qu\'alguien cerca lo atope. Namás s\'amuesa la distancia averada, enxamás la to direición; el tratu ciérraslu en persona.', + 'intro.slides.plantare.title' => 'Semar ye multiplicar', + 'intro.slides.plantare.body' => 'Col Plantare, quien recibe promete devolver simiente más alantre. Y como pa devolvela hai que cultivala, cada préstamu multiplica\'l común.', + 'inventory.title' => 'Inventariu', + 'inventory.searchHint' => 'Guetar simiente', + 'inventory.empty' => 'Entá nun hai simiente. Toca + p\'amestar la primera.', + 'inventory.noMatches' => 'Nenguna simiente concasa colos filtros.', + 'inventory.clearFilters' => 'Quitar filtros', + 'inventory.uncategorized' => 'Ensin categoría', + 'inventory.needsReproductionFilter' => 'Por reproducir', + 'inventory.loadError' => 'Nun se pudo abrir el to bancu de granes. Seique taba ocupáu: prueba otra vuelta.', + 'inventory.retry' => 'Volver probar', + 'draft.capture' => 'Capturar semeyes', + 'draft.captured' => ({required Object n}) => '${n} capturaes por catalogar', + 'draft.triageTitle' => 'Por catalogar', + 'draft.triageCount' => ({required Object n}) => '${n} por catalogar', + 'draft.untitled' => 'Ensin nome', + 'draft.nameField' => 'Ponle nome a esta simiente', + 'draft.nameHint' => '¿Qué ye?', + 'draft.suggestFromPhoto' => 'Suxerir nome de la semeya', + 'draft.discard' => 'Descartar', + 'quickAdd.title' => 'Amestar una simiente', + 'quickAdd.labelField' => 'Nome', + 'quickAdd.labelRequired' => 'Ponle un nome', + 'quickAdd.addPhoto' => 'Amestar semeya', + 'quickAdd.quantity' => '¿Cuánta?', + 'quickAdd.more' => 'Amestar más…', + 'quickAdd.save' => 'Guardar', + 'quickAdd.saveAndAddAnother' => 'Guardar y amestar otra', + 'quickAdd.addedCount' => ({required Object n}) => '${n} amestaes', + 'quickAdd.cancel' => 'Encaboxar', + 'detail.notFound' => 'Esta simiente yá nun ta equí.', + 'detail.lots' => 'Llotes', + 'detail.noLots' => 'Entá nun hai llotes.', + 'detail.names' => 'Conocida tamién como', + 'detail.addName' => 'Amestar nome', + 'detail.links' => 'Enllaces', + 'detail.addLink' => 'Amestar enllaz', + 'detail.linkUrl' => 'URL', + 'detail.linkTitle' => 'Títulu (opcional)', + 'detail.reference' => 'Saber más', + 'detail.refGbif' => 'GBIF', + 'detail.refWikipedia' => 'Wikipedia', + 'detail.refWikispecies' => 'Wikispecies', + 'detail.notes' => 'Notes', + 'detail.addLot' => 'Amestar llote', + 'detail.editLot' => 'Editar llote', + 'detail.deleteConfirm' => '¿Desaniciar esta simiente?', + 'detail.year' => ({required Object year}) => 'Añu ${year}', + 'detail.noYear' => 'Añu desconocíu', + 'germination.title' => 'Germinación', + 'germination.add' => 'Amestar prueba', + 'germination.sampleSize' => 'Amuesa', + 'germination.germinated' => 'Germinaes', + 'germination.none' => 'Entá nun hai pruebes de germinación.', + 'germination.result' => ({required Object percent}) => '${percent}%', + 'viability.expiringSoon' => 'Úsala o multiplícala esta temporada', + 'viability.expiringSoonYears' => ({required Object years}) => 'Úsala o multiplícala esta temporada · dura ~${years} años', + 'viability.expired' => 'Pasa la so viabilidá típica — a multiplicar', + 'viability.expiredYears' => ({required Object years}) => 'Pasa la so viabilidá típica (~${years} años) — a multiplicar', + 'editVariety.title' => 'Editar simiente', + 'editVariety.name' => 'Nome', + 'editVariety.category' => 'Categoría', + 'editVariety.notes' => 'Notes', + 'editVariety.species' => 'Especie (del catálogu)', + 'editVariety.speciesHint' => 'Guetar una especie…', + 'editVariety.speciesSuggested' => 'Suxerida pol nome', + 'editVariety.organic' => 'Ecolóxica', + 'editVariety.organicHint' => 'Cultivada de mou ecolóxicu (eco)', + 'addLot.title' => 'Amestar llote', + 'addLot.year' => 'Data de coyecha', + 'addLot.quantity' => '¿Cuánta?', + 'addLot.amount' => 'Cantidá', + 'harvest.pickTitle' => 'Escoyer mes / añu', + 'harvest.anyMonth' => 'Cualquier mes', + 'harvest.noDate' => 'Indicar data de coyecha', + 'harvest.monthNames.0' => 'xineru', + 'harvest.monthNames.1' => 'febreru', + 'harvest.monthNames.2' => 'marzu', + 'harvest.monthNames.3' => 'abril', + 'harvest.monthNames.4' => 'mayu', + 'harvest.monthNames.5' => 'xunu', + 'harvest.monthNames.6' => 'xunetu', + 'harvest.monthNames.7' => 'agostu', + 'harvest.monthNames.8' => 'setiembre', + 'harvest.monthNames.9' => 'ochobre', + 'harvest.monthNames.10' => 'payares', + 'harvest.monthNames.11' => 'avientu', + 'lotType.seed' => 'Simiente', + 'lotType.plant' => 'Planta', + 'lotType.seedling' => 'Planton', + 'lotType.tree' => 'Árbol / matu', + 'lotType.bulb' => 'Bulbu / tubérculu', + 'lotType.cutting' => 'Esqueje', + 'presentation.title' => 'Envase', + 'presentation.none' => 'Ensin especificar', + 'presentation.pot' => 'Tiestu', + 'presentation.tray' => 'Bandexa', + 'presentation.plug' => 'Alvéolu', + 'presentation.bareRoot' => 'Raíz esnuda', + 'presentation.rootBall' => 'Cepellón', + 'provenance.section' => 'D\'ónde vien', + 'provenance.seedsFrom' => 'Simiente de', + 'provenance.seedsFromHint' => 'Quién la cultivó o la dio', + 'provenance.place' => 'Llugar', + 'provenance.placeHint' => 'D\'ónde vien (con provincia)', + 'provenance.addSeedsFrom' => 'Simiente de', + 'provenance.addPlace' => 'Llugar', + 'abundance.add' => 'Cuánta tengo', + 'abundance.title' => 'Cuánta tengo', + 'abundance.none' => 'Ensin indicar', + 'abundance.plentyToShare' => 'A esgaya pa compartir', + 'abundance.enoughToShare' => 'Abondo, pa compartir con mesura', + 'abundance.enoughForMe' => 'Abondo pa min', + 'abundance.runningLow' => 'Queda poca', + 'share.add' => '¿Compártesla?', + 'share.title' => '¿Compártesla?', + 'share.nudge' => 'Tienes a esgaya: podríes compartir un poco.', + 'share.price' => 'Preciu', + 'share.priceHint' => 'Déxalu baleru p\'alcordalu depués', + 'share.private' => 'Namás pa min', + 'share.gift' => 'Pa regalar', + 'share.exchange' => 'Pa trocar', + 'share.sell' => 'En venta', + 'share.filterChip' => 'Comparto', + 'share.printCatalog' => 'Imprentar lo que comparto', + 'share.catalogTitle' => 'Lo que comparto', + 'share.catalogSaved' => 'Catálogu guardáu', + 'share.cancelled' => 'Encaboxáu', + 'printLabels.action' => 'Imprentar etiquetes', + 'printLabels.title' => 'Imprentar etiquetes', + 'printLabels.selectHint' => 'Escueyi les semientes pa imprentar les sos etiquetes', + 'printLabels.selectAll' => 'Seleicionar too', + 'printLabels.selected' => ({required Object n}) => '${n} seleicionaes', + 'printLabels.none' => 'Seleiciona semientes primero', + 'printLabels.format' => 'Tamañu d\'etiqueta', + 'printLabels.formatStickers' => 'Pegatines pequeñes', + 'printLabels.formatStickersHint' => 'Munches etiquetes pequeñes per fueya — pa pegar en sobres', + 'printLabels.formatCards' => 'Tarxetes grandes', + 'printLabels.formatCardsHint' => 'Menos etiquetes, más grandes — pa botes y caxes', + 'printLabels.count' => ({required Object n}) => '${n} etiquetes', + 'printLabels.save' => 'Guardar etiquetes', + 'printLabels.saved' => 'Etiquetes guardaes', + 'printLabels.cancelled' => 'Encaboxáu', + 'cropCalendar.add' => 'Calendariu de cultivu', + 'cropCalendar.title' => 'Calendariu de cultivu', + 'cropCalendar.sow' => 'Sema', + 'cropCalendar.transplant' => 'Tresplante', + 'cropCalendar.flowering' => 'Floriamientu', + 'cropCalendar.fruiting' => 'Fructificación', + 'cropCalendar.seedHarvest' => 'Coyecha de simiente', + 'cropCalendar.editorHint' => 'Anota tú los meses típicos d\'esta variedá na to zona — son notes tuyes.', + 'cropCalendar.unset' => '—', + 'needsReproduction.label' => 'Reproducir esta temporada', + 'needsReproduction.hint' => 'Cultívala enantes de que s\'acabe la simiente', + 'needsReproduction.badge' => 'Por reproducir', + 'preservation.add' => 'Cómo se caltién', + 'preservation.title' => 'Cómo se caltién', + 'preservation.none' => 'Ensin especificar', + 'preservation.jarWithDesiccant' => 'Bote con desecante', + 'preservation.glassJar' => 'Bote de cristal', + 'preservation.paperEnvelope' => 'Sobre de papel', + 'preservation.paperBag' => 'Bolsa de papel', + 'preservation.plasticBag' => 'Bolsa de plásticu', + 'conditionCheck.advanced' => 'Caltenimientu y detalles del bancu', + 'conditionCheck.title' => 'Revisiones de caltenimientu', + 'conditionCheck.add' => 'Amestar revisión', + 'conditionCheck.containers' => 'Botes / recipientes', + 'conditionCheck.desiccant' => 'Desecante', + 'conditionCheck.none' => 'Entá nun hai revisiones.', + 'conditionCheck.summary' => ({required Object count, required Object state}) => '${count} bote(s) · ${state}', + 'desiccant.none' => 'Nun tien', + 'desiccant.add' => 'Pónse', + 'desiccant.replace' => 'Cámbiase', + 'desiccant.dry' => 'Azul — secu', + 'desiccant.fresh' => 'Recién puestu', + 'unit.aFew' => 'delles poques', + 'unit.some' => 'dalgunes', + 'unit.plenty' => 'munches', + 'unit.pinch' => 'una migaya', + 'unit.handful.singular' => 'puñáu', + 'unit.handful.plural' => 'puñaos', + 'unit.teaspoon.singular' => 'cuyaradina', + 'unit.teaspoon.plural' => 'cuyaradines', + 'unit.spoon.singular' => 'cuyar', + 'unit.spoon.plural' => 'cuyares', + 'unit.cup.singular' => 'taza', + 'unit.cup.plural' => 'tazes', + 'unit.jar.singular' => 'bote', + 'unit.jar.plural' => 'botes', + 'unit.sack.singular' => 'sacu', + 'unit.sack.plural' => 'sacos', + 'unit.packet.singular' => 'sobre', + 'unit.packet.plural' => 'sobres', + 'unit.cob.singular' => 'panoya', + 'unit.cob.plural' => 'panoyes', + 'unit.pod.singular' => 'vaina', + 'unit.pod.plural' => 'vaines', + 'unit.ear.singular' => 'espiga', + 'unit.ear.plural' => 'espigues', + 'unit.head.singular' => 'cabezuela', + 'unit.head.plural' => 'cabezueles', + 'unit.fruit.singular' => 'frutu', + 'unit.fruit.plural' => 'frutos', + 'unit.bulb.singular' => 'bulbu', + 'unit.bulb.plural' => 'bulbos', + 'unit.tuber.singular' => 'tubérculu', + 'unit.tuber.plural' => 'tubérculos', + 'unit.seedHead.singular' => 'cabeza de simiente', + 'unit.seedHead.plural' => 'cabeces de simiente', + 'unit.bunch.singular' => 'manoyu', + 'unit.bunch.plural' => 'manoyos', + 'unit.plant.singular' => 'planta', + 'unit.plant.plural' => 'plantes', + 'unit.pot.singular' => 'tiestu', + 'unit.pot.plural' => 'tiestos', + 'unit.tray.singular' => 'bandexa', + 'unit.tray.plural' => 'bandexes', + 'unit.seedling.singular' => 'plántula', + 'unit.seedling.plural' => 'plántules', + 'unit.tree.singular' => 'árbol', + 'unit.tree.plural' => 'árboles', + 'unit.cutting.singular' => 'esqueje', + 'unit.cutting.plural' => 'esquejes', + 'unit.grams.singular' => 'gramu', + 'unit.grams.plural' => 'gramos', + 'unit.count.singular' => 'grana', + 'unit.count.plural' => 'granes', + 'market.title' => 'Simiente cerca de ti', + 'market.subtitle' => 'Lo qu\'otres persones comparten cerca', + 'market.notSetUp' => 'Entá nun configurasti\'l compartir', + 'market.notSetUpBody' => 'Activa\'l compartir pa ver y dar simiente a xente cercano. Caltiénse averao — la to zona, enxamás la to direición esacta.', + 'market.setUp' => 'Configurar el compartir', + 'market.cantReach' => 'Nun se pue coneutar colos servidores agora mesmo', + 'market.cantReachBody' => 'Inténtalo otra vez, o revisa los servidores na configuración avanzada.', + 'market.retry' => 'Retentar', + 'market.setArea' => 'Indica la to zona', + 'market.setAreaBody' => 'Dile al mercáu la to zona averada pa ver simiente cerca.', + 'market.searching' => 'Guetando pela to zona…', + 'market.empty' => 'Entá nun hai simiente compartida cerca de ti', + 'market.near' => 'Cerca de ti', + 'market.contact' => 'Mensaxe', + 'market.mine' => 'Tu', + 'market.configTitle' => 'Configuración de compartir', + 'market.setupIntro' => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu\'otres persones atopen lo qu\'ufiertes, ensin nenguna empresa en mediu.', + 'market.areaLabel' => 'La to zona', + 'market.areaHelp' => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.', + 'market.areaSet' => 'La to zona ta puesta — averada, enxamás el to puntu esactu', + 'market.areaNotSet' => 'Zona ensin definir — usa\'l to allugamientu, o amiesta un códigu n\'Avanzáu', + 'market.advanced' => 'Avanzáu', + 'market.areaCodeLabel' => 'Códigu de zona', + 'market.areaCodeHint' => 'Un códigu curtiu como sp3e9 — non un nome de llugar', + 'market.serversLabel' => 'Servidores de la comunidá', + 'market.serversHelp' => 'Escueyi qué servidores usar. Déxalo asina si nun sabes.', + 'market.serversAdvanced' => 'Amestar otru servidor', + 'market.serverAddress' => 'Direición del servidor', + 'market.serverInvalid' => 'Introduz una direición válida (wss://…)', + 'market.save' => 'Guardar', + 'market.saved' => 'Guardáu', + 'market.wanted' => 'Gueto', + 'market.shareMine' => 'Compartir la mio simiente', + 'market.sharedCount' => ({required Object n}) => 'Compartíes ${n} simientes', + 'market.nothingToShare' => 'Marca primero dalguna simiente pa regalar, trocar o vender', + 'market.useLocation' => 'Usar el mio allugamientu averáu', + 'market.locationFailed' => 'Nun se pudo consiguir el to allugamientu — comprueba que l\'allugamientu ta activáu y el permisu concedíu', + 'market.queued' => 'Guardáu — compartirémosles cuando tengas conexón', + 'market.shareFailed' => 'Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.', + 'market.rangeLabel' => 'Hasta ónde guetar', + 'market.rangeNear' => 'Bien cerca', + 'market.rangeArea' => 'Pela mio zona', + 'market.rangeRegion' => 'La mio rexón', + 'market.sharedBy' => 'Compartíu por', + 'market.noProfile' => 'Esta persona entá nun compartió\'l so perfil', + 'market.copyId' => 'Copiar códigu', + 'market.idCopied' => 'Códigu copiáu', + 'market.photo' => 'Semeya', + 'profile.title' => 'El to perfil', + 'profile.name' => 'Nome', + 'profile.nameHint' => 'Cómo te ven los demás', + 'profile.about' => 'Sobre ti', + 'profile.aboutHint' => 'Una llinia — qué cultives, ónde', + 'profile.g1' => 'Direición Ğ1 (opcional)', + 'profile.g1Hint' => 'Pa que te paguen en Ğ1 — aparte de la to clave', + 'profile.yourId' => 'La to identidá', + 'profile.idHelp' => 'Compártela pa que te reconozan', + 'profile.copy' => 'Copiar', + 'profile.copied' => 'Copiao', + 'profile.save' => 'Guardar', + 'profile.saved' => 'Perfil guardáu', + 'profile.identities' => 'Les tos identidaes', + 'profile.identitiesHelp' => 'Ten identidaes separaes — cada una coles sos mensaxes y contactos. Toes salen de la to única copia de seguridá, asina que camudar nun amiesta nada que recordar.', + 'profile.identityLabel' => ({required Object n}) => 'Identidá ${n}', + 'profile.current' => 'N\'usu', + 'profile.newIdentity' => 'Identidá nueva', + 'profile.switchTitle' => '¿Camudar d\'identidá?', + 'profile.switchBody' => 'Los mensaxes y contactos guárdense per separao pa cada identidá. Pues volver cuando quieras.', + 'profile.switchAction' => 'Camudar', + 'chatList.title' => 'Mensaxes', + 'chatList.empty' => 'Entá nun hai charres. Escríbi-y a alguien dende\'l mercáu.', + 'chat.title' => 'Charra', + 'chat.hint' => 'Escribi un mensaxe…', + 'chat.send' => 'Unviar', + 'chat.empty' => 'Entá nun hai mensaxes — saluda', + 'chat.offline' => 'Configura\'l compartir pa unviar mensaxes', + 'chat.payG1' => 'Pagar en Ğ1', + 'chat.g1Copied' => 'Direición Ğ1 copiada — apégala na to cartera', + 'chat.today' => 'Güei', + 'chat.yesterday' => 'Ayeri', + 'chat.sendError' => 'Nun se pudo unviar — comprueba la conexón', + 'chat.noLinks' => 'Nun se permiten enllaces nos mensaxes', + 'trust.none' => 'Naide los avala entá', + 'trust.count' => ({required Object n}) => 'Avalada por ${n}', + 'trust.vouch' => 'Conozo a esta persona', + 'trust.vouched' => 'Avales a esta persona', + 'trust.circle' => 'Nel to círculu', + 'yourPeople.title' => 'La to xente', + 'yourPeople.help' => 'Persones que conoces y avales, y persones que t\'avalen.', + 'yourPeople.youVouchFor' => 'Tu avales a', + 'yourPeople.vouchesForYou' => 'Aválente', + 'yourPeople.youVouchForEmpty' => 'Entá nun avales a naide. Cuando conozas a daquién, abri la vuestra conversación y calca "Conozo a esta persona".', + 'yourPeople.vouchesForYouEmpty' => 'Naide t\'avala entá', + 'yourPeople.revoke' => 'Dexar d\'avalar', + 'yourPeople.revokeConfirm' => '¿Dexar d\'avalar a esta persona?', + 'yourPeople.offline' => 'Ensin conexón — vuelvi tentalo cuando teas en llinia', + 'ratings.rate' => 'Valorar a esta persona', + 'ratings.edit' => 'Editar la to valoración', + 'ratings.commentHint' => '¿Qué tal foi? (opcional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} de xente que conoces', + 'ratings.retract' => 'Quitar la to valoración', + 'ratings.saved' => 'Valoración guardada', + 'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Un Plantare ye\'l compromisu de reproducir una semilla y devolver una parte — asina una variedá sigue viaxando de mano en mano. Nun ye una venta.', + 'plantare.add' => 'Amestar compromisu', + 'plantare.empty' => 'Entá nun hai compromisos. Cuando compartas o recibas semilla col compromisu de reproducila y devolver daqué, anótalo equí.', + 'plantare.iReturn' => 'Reprodúzola y devuélvola yo', + 'plantare.owedToMe' => 'Devuélvenmela a min', + 'plantare.direction' => 'Quién reproduz y devuelve', + 'plantare.counterparty' => '¿Con quién?', + 'plantare.counterpartyHint' => 'Una persona o un coleutivu (opcional)', + 'plantare.owed' => '¿Qué se devuelve?', + 'plantare.owedHint' => 'p. ex. un puñáu la próxima temporada (opcional)', + 'plantare.note' => 'Nota (opcional)', + 'plantare.save' => 'Guardar', + 'plantare.markReturned' => 'Marcar devueltu', + 'plantare.markForgiven' => 'Dar por saldáu', + 'plantare.reopen' => 'Reabrir', + 'plantare.delete' => 'Quitar', + 'plantare.statusReturned' => 'Devueltu', + 'plantare.statusForgiven' => 'Saldáu', + 'plantare.openSection' => 'Pendientes', + 'plantare.settledSection' => 'Fechos', + 'plantare.removeConfirm' => '¿Quitar esti compromisu?', + 'plantare.returnBy' => ({required Object date}) => 'Devolver enantes del ${date}', + 'plantare.overdue' => 'vencíu', + 'plantare.dueByLabel' => 'Devolver enantes de (opcional)', + 'plantare.dueByHint' => 'Un recordatoriu suave, nunca obligatoriu', + 'plantare.pickDate' => 'Escoyer fecha', + 'plantare.clearDate' => 'Quitar fecha', + 'plantare.sectionTitle' => 'Compromisos', + 'handover.title' => 'Di o recibí semientes', + 'handover.help' => 'Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.', + 'handover.iGave' => 'Di semientes', + 'handover.iReceived' => 'Recibí semientes', + 'handover.whichLot' => '¿De qué llote?', + 'handover.howMuch' => '¿Cuánto?', + 'handover.allOfIt' => 'Too', + 'handover.partOfIt' => 'Una parte', + 'handover.paymentChip' => 'Hubo perres pel mediu', + 'handover.promiseGave' => 'Van devolveme semiente', + 'handover.promiseReceived' => 'Voi devolver semiente', + 'sale.title' => 'Ventes', + 'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.', + _ => null, + } ?? switch (path) { + 'sale.add' => 'Rexistrar venta', + 'sale.empty' => 'Entá nun hai ventes. Anota equí lo que vendes o merques.', + 'sale.iSold' => 'Vendí', + 'sale.iBought' => 'Mercué', + 'sale.direction' => '¿Vendes o merques?', + 'sale.counterparty' => '¿Con quién?', + 'sale.counterpartyHint' => 'Una persona o un coleutivu (opcional)', + 'sale.amount' => 'Importe', + 'sale.currency' => 'Moneda', + 'sale.currencyHint' => '€, Ğ1, hores… (opcional)', + 'sale.hours' => 'hores', + 'sale.note' => 'Nota (opcional)', + 'sale.save' => 'Guardar', + 'sale.delete' => 'Quitar', + 'sale.removeConfirm' => '¿Quitar esta venta?', + 'legal.title' => 'Privacidá y normes', + 'legal.subtitle' => 'La to privacidá, les normes del mercáu y la llegalidá de la simiente', + 'legal.privacyTitle' => 'La to privacidá', + 'legal.privacyBody' => 'Tane funciona ensin cuenta, y tolo que rexistres queda nel to preséu, cifrao. Nun hai publicidá, nin rastreadores, nin sirvidor de Tane.\n\nNun se comparte nada sacantes que tu quieras: cuando espublices una ofierta o\'l to perfil, o unvíes un mensaxe, viaxa per sirvidores comuñales. Les ofiertes namái lleven una zona averada — enxamás la to direición.\n\nLo qu\'espubliques ye público, y puen quedar copies mesmo dempués de retiralo — asina que comparte con procuru.', + 'legal.rulesTitle' => 'Les regles del xuegu', + 'legal.rulesBody' => 'Tane ye una ferramienta, non una tienda: los intercambeos alcuérdense direutamente ente persones y naide lleva comisión. Eso tamién quier dicir que tu respuendes de lo qu\'ufiertes y unvíes.\n\nNel mercáu: sé honestu cola to simiente, ufierta namái lo que puedas compartir, trata bien a la xente y nun faigas spam. Puedes bloquiar a cualquiera y denunciar ofiertes o persones qu\'incumplan les normes — les denuncies atiéndense nos sirvidores comuñales.', + 'legal.seedsTitle' => 'Tocante a compartir simiente y plantones', + 'legal.seedsBody' => 'Regalar ya intercambiar simiente ente aficionaos ta reconocío ampliamente na mayoría de países. Vender pue ser distinto: en munchos sitios, vender simiente de variedaes non rexistraes oficialmente ta restrinxío — consulta la to normativa enantes de pidir un preciu.\n\nUnviar simiente a otru país tamién suel tar restrinxío, y les variedaes protexíes comercialmente nun puen propagase ensin permisu. Ante la dulda, meyor local y meyor regalu.\n\nLo mesmo val pa plantones y plantes moces, pero la planta viva pue tener regles de tresporte y fitosanitaries más estrictes que la simiente: movela ente rexones o países pue requerir controles adicionales.', + 'legal.readFull' => 'Lleer los documentos completos na web', + 'marketGate.title' => 'Enantes d\'entrar al mercáu', + 'marketGate.intro' => 'El mercáu ye un espaciu compartíu ente vecines y vecinos. Al siguir, aceptes unes poques normes cencielles:', + 'marketGate.ruleHonest' => 'Sé honestu cola simiente qu\'ufiertes', + 'marketGate.ruleLegal' => 'Comparte namái lo que puedas compartir onde vives', + 'marketGate.ruleRespect' => 'Trata bien a la xente — ensin spam nin abusos', + 'marketGate.publicNote' => 'Lo qu\'espubliques equí ye público, y puen quedar copies anque lo retires dempués.', + 'marketGate.viewLegal' => 'Privacidá y normes', + 'marketGate.accept' => 'Acepto', + 'marketGate.decline' => 'Agora non', + 'report.offer' => 'Denunciar esta ofierta', + 'report.person' => 'Denunciar a esta persona', + 'report.title' => 'Denunciar', + 'report.prompt' => '¿Qué pasa?', + 'report.reasonSpam' => 'Spam o un engañu', + 'report.reasonAbuse' => 'Abusivo o irrespetuoso', + 'report.reasonIllegal' => 'Simiente que nun tendría d\'ufiertase', + 'report.reasonOther' => 'Otra cosa', + 'report.detailsHint' => 'Amiesta detalles (opcional)', + 'report.send' => 'Unviar denuncia', + 'report.sentHidden' => 'Denuncia unviada — yá nun vas ver esto', + 'report.failed' => 'Nun se pudo unviar la denuncia — revisa la conexón', + 'report.alsoBlock' => 'Bloquiar tamién a esta persona', + 'block.action' => 'Bloquiar a esta persona', + 'block.confirmTitle' => '¿Bloquiar a esta persona?', + 'block.confirmBody' => 'Vas dexar de ver les sos ofiertes y mensaxes. Puedes desbloquiala más alantre en Persones bloquiaes, na configuración de compartir.', + 'block.confirm' => 'Bloquiar', + 'block.blockedToast' => 'Persona bloquiada — les sos ofiertes y mensaxes queden anubríos', + 'block.manageTitle' => 'Persones bloquiaes', + 'block.manageEmpty' => 'Nun bloquiesti a naide', + 'block.unblock' => 'Desbloquiar', + _ => null, + }; + } +} diff --git a/apps/app_seeds/lib/i18n/strings_de.g.dart b/apps/app_seeds/lib/i18n/strings_de.g.dart new file mode 100644 index 0000000..460891b --- /dev/null +++ b/apps/app_seeds/lib/i18n/strings_de.g.dart @@ -0,0 +1,1983 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import +// dart format off + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'strings.g.dart'; + +// Path: +class TranslationsDe extends Translations with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + TranslationsDe({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = meta ?? TranslationMetadata( + locale: AppLocale.de, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); + + late final TranslationsDe _root = this; // ignore: unused_field + + @override + TranslationsDe $copyWith({TranslationMetadata? meta}) => TranslationsDe(meta: meta ?? this.$meta); + + // Translations + @override late final _Translations$avatar$de avatar = _Translations$avatar$de._(_root); + @override late final _Translations$favorites$de favorites = _Translations$favorites$de._(_root); + @override late final _Translations$seedSaving$de seedSaving = _Translations$seedSaving$de._(_root); + @override late final _Translations$calendar$de calendar = _Translations$calendar$de._(_root); + @override late final _Translations$app$de app = _Translations$app$de._(_root); + @override late final _Translations$bootstrap$de bootstrap = _Translations$bootstrap$de._(_root); + @override late final _Translations$common$de common = _Translations$common$de._(_root); + @override late final _Translations$home$de home = _Translations$home$de._(_root); + @override late final _Translations$photo$de photo = _Translations$photo$de._(_root); + @override late final _Translations$menu$de menu = _Translations$menu$de._(_root); + @override late final _Translations$settings$de settings = _Translations$settings$de._(_root); + @override late final _Translations$backup$de backup = _Translations$backup$de._(_root); + @override late final _Translations$about$de about = _Translations$about$de._(_root); + @override late final _Translations$intro$de intro = _Translations$intro$de._(_root); + @override late final _Translations$inventory$de inventory = _Translations$inventory$de._(_root); + @override late final _Translations$draft$de draft = _Translations$draft$de._(_root); + @override late final _Translations$quickAdd$de quickAdd = _Translations$quickAdd$de._(_root); + @override late final _Translations$detail$de detail = _Translations$detail$de._(_root); + @override late final _Translations$germination$de germination = _Translations$germination$de._(_root); + @override late final _Translations$viability$de viability = _Translations$viability$de._(_root); + @override late final _Translations$editVariety$de editVariety = _Translations$editVariety$de._(_root); + @override late final _Translations$addLot$de addLot = _Translations$addLot$de._(_root); + @override late final _Translations$harvest$de harvest = _Translations$harvest$de._(_root); + @override late final _Translations$lotType$de lotType = _Translations$lotType$de._(_root); + @override late final _Translations$presentation$de presentation = _Translations$presentation$de._(_root); + @override late final _Translations$provenance$de provenance = _Translations$provenance$de._(_root); + @override late final _Translations$abundance$de abundance = _Translations$abundance$de._(_root); + @override late final _Translations$share$de share = _Translations$share$de._(_root); + @override late final _Translations$printLabels$de printLabels = _Translations$printLabels$de._(_root); + @override late final _Translations$cropCalendar$de cropCalendar = _Translations$cropCalendar$de._(_root); + @override late final _Translations$needsReproduction$de needsReproduction = _Translations$needsReproduction$de._(_root); + @override late final _Translations$preservation$de preservation = _Translations$preservation$de._(_root); + @override late final _Translations$conditionCheck$de conditionCheck = _Translations$conditionCheck$de._(_root); + @override late final _Translations$desiccant$de desiccant = _Translations$desiccant$de._(_root); + @override late final _Translations$unit$de unit = _Translations$unit$de._(_root); + @override late final _Translations$market$de market = _Translations$market$de._(_root); + @override late final _Translations$profile$de profile = _Translations$profile$de._(_root); + @override late final _Translations$chatList$de chatList = _Translations$chatList$de._(_root); + @override late final _Translations$chat$de chat = _Translations$chat$de._(_root); + @override late final _Translations$trust$de trust = _Translations$trust$de._(_root); + @override late final _Translations$yourPeople$de yourPeople = _Translations$yourPeople$de._(_root); + @override late final _Translations$ratings$de ratings = _Translations$ratings$de._(_root); + @override late final _Translations$notifications$de notifications = _Translations$notifications$de._(_root); + @override late final _Translations$plantare$de plantare = _Translations$plantare$de._(_root); + @override late final _Translations$handover$de handover = _Translations$handover$de._(_root); + @override late final _Translations$sale$de sale = _Translations$sale$de._(_root); + @override late final _Translations$legal$de legal = _Translations$legal$de._(_root); + @override late final _Translations$marketGate$de marketGate = _Translations$marketGate$de._(_root); + @override late final _Translations$report$de report = _Translations$report$de._(_root); + @override late final _Translations$block$de block = _Translations$block$de._(_root); +} + +// Path: avatar +class _Translations$avatar$de extends Translations$avatar$en { + _Translations$avatar$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Dein Foto oder Avatar'; + @override String get fromPhoto => 'Foto machen oder auswählen'; + @override String get illustration => 'Oder wähle eine Illustration'; + @override String get remove => 'Entfernen'; +} + +// Path: favorites +class _Translations$favorites$de extends Translations$favorites$en { + _Translations$favorites$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoriten'; + @override String get empty => 'Noch keine Favoriten. Speichere Angebote, die dir auf dem Markt gefallen.'; + @override String get save => 'Zu Favoriten speichern'; + @override String get remove => 'Aus Favoriten entfernen'; + @override String get unavailable => 'Nicht mehr verfügbar'; +} + +// Path: seedSaving +class _Translations$seedSaving$de extends Translations$seedSaving$en { + _Translations$seedSaving$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Samen vervielfältigen'; + @override String get subtitle => 'Was nötig ist, um die Sorte echt zu erhalten'; + @override String get lifeCycle => 'Zyklus'; + @override String get cycleAnnual => 'Einjährig'; + @override String get cycleBiennial => 'Zweijährig - Samen im 2. Jahr'; + @override String get cyclePerennial => 'Mehrjährig'; + @override String get pollination => 'Bestäubung'; + @override String get pollSelf => 'Selbstbestäubend'; + @override String get pollCross => 'Kreuzt mit anderen'; + @override String get pollMixed => 'Selbstbestäubend, manchmal Fremdbefruchtung'; + @override String get byInsect => 'durch Insekten'; + @override String get byWind => 'durch Wind'; + @override String get isolation => 'Abstand halten'; + @override String isolationRange({required Object min, required Object max}) => '${min}–${max} m zu anderen Sorten'; + @override String isolationSingle({required Object min}) => '${min} m zu anderen Sorten'; + @override String get plants => 'Samen von mehreren Pflanzen sammeln'; + @override String plantsValue({required Object n}) => 'Von mindestens ${n} Pflanzen'; + @override String get processing => 'Samen reinigen'; + @override String get procDry => 'Trockensamen (ausdreschen)'; + @override String get procWet => 'Feuchtsamen (fermentieren und spülen)'; + @override String get difficulty => 'Schwierigkeit'; + @override String get diffEasy => 'Leicht'; + @override String get diffMedium => 'Mittel'; + @override String get diffHard => 'Schwer'; + @override String get advisory => 'Allgemeine Hinweise - passe sie an dein Klima und deine Sorte an.'; + @override String get sourcePrefix => 'Quelle'; +} + +// Path: calendar +class _Translations$calendar$de extends Translations$calendar$en { + _Translations$calendar$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Dieser Monat'; + @override String get filterChip => 'Dieser Monat'; + @override String get selfNote => 'Was du in deinen Sorten notiert hast.'; + @override String nothing({required Object month}) => 'Nichts für ${month} notiert.'; +} + +// Path: app +class _Translations$app$de extends Translations$app$en { + _Translations$app$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Tane'; +} + +// Path: bootstrap +class _Translations$bootstrap$de extends Translations$bootstrap$en { + _Translations$bootstrap$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get failed => 'Tane konnte nicht starten'; + @override String get retry => 'Versuchen'; +} + +// Path: common +class _Translations$common$de extends Translations$common$en { + _Translations$common$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get save => 'Speichern'; + @override String get cancel => 'Abbrechen'; + @override String get delete => 'Löschen'; + @override String get edit => 'Bearbeiten'; + @override String get type => 'Typ'; + @override String get comingSoon => 'Bald'; + @override String get offline => 'Offline - Teilen ist unterbrochen'; +} + +// Path: home +class _Translations$home$de extends Translations$home$en { + _Translations$home$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get tagline => 'Teile und baue lokale Samen an'; + @override String get openMarket => 'Markt'; + @override String get openMarketSubtitle => 'Entdecke und teile Samen in deiner Nähe'; + @override String get yourInventory => 'Dein Bestand'; + @override String get yourInventorySubtitle => 'Verwalte deine Samen'; +} + +// Path: photo +class _Translations$photo$de extends Translations$photo$en { + _Translations$photo$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get camera => 'Foto machen'; + @override String get gallery => 'Aus Galerie wählen'; + @override String get setAsCover => 'Als Deckblatt setzen'; + @override String get isCover => 'Deckblatt-Foto'; + @override String get deleteConfirm => 'Dieses Foto löschen?'; +} + +// Path: menu +class _Translations$menu$de extends Translations$menu$en { + _Translations$menu$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get tagline => 'deine Saatgutbank'; + @override String get inventory => 'Bestand'; + @override String get market => 'Markt'; + @override String get profile => 'Dein Profil'; + @override String get chat => 'Chat'; + @override String get wishlist => 'Favoriten'; + @override String get following => 'Gefolgt'; + @override String get plantares => 'Plantares'; + @override String get sales => 'Verkäufe'; + @override String get calendar => 'Kalender'; + @override String get settings => 'Einstellungen'; +} + +// Path: settings +class _Translations$settings$de extends Translations$settings$en { + _Translations$settings$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get language => 'Sprache'; + @override String get systemLanguage => 'Systemsprache'; + @override String get langEs => 'Español'; + @override String get langEn => 'English'; + @override String get langPt => 'Português'; + @override String get langAst => 'Asturianu'; + @override String get about => 'Über'; + @override String get aboutText => 'Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.'; + @override String get aboutOpen => 'Über Tane'; + @override String get langDe => 'Deutsch'; + @override String get langFr => 'Français'; + @override String get langJa => '日本語'; +} + +// Path: backup +class _Translations$backup$de extends Translations$backup$en { + _Translations$backup$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Sicherung und Wiederherstellung'; + @override String get autoBackupTitle => 'Automatische Sicherungen'; + @override String autoBackupLast({required Object date, required Object days}) => 'Letzte Sicherung ${date} · alle ${days} Tage'; + @override String autoBackupNone({required Object days}) => 'Eine Kopie wird automatisch alle ${days} Tage gespeichert'; + @override String get exportJson => 'Sicherung speichern'; + @override String get exportJsonSubtitle => 'Eine komplette Kopie zum sicheren Aufbewahren, später wiederherstellen oder auf ein anderes Gerät übertragen'; + @override String get importJson => 'Sicherung wiederherstellen'; + @override String get importJsonSubtitle => 'Bringe eine gespeicherte Kopie zurück - nichts wird dupliziert'; + @override String get exportCsv => 'In eine Tabelle exportieren'; + @override String get exportCsvSubtitle => 'Eine einfache Liste für Excel oder LibreOffice - ohne Fotos'; + @override String get importCsv => 'Eine Liste importieren'; + @override String get importCsvSubtitle => 'Einträge aus einer Tabelle hinzufügen'; + @override String get importConfirmTitle => 'Sicherung wiederherstellen?'; + @override String get importConfirmBody => 'Einträge werden mit deinem Bestand zusammengeführt; wenn ein Eintrag auf beiden Seiten existiert, gewinnt die neuere Version. Nichts wird dupliziert.'; + @override String get importCsvConfirmTitle => 'Liste importieren?'; + @override String get importCsvConfirmBody => 'Jede Zeile wird als neuer Eintrag hinzugefügt. Dies führt nicht zusammen und ersetzt nicht, also bringt zweimaliger Import dieselbe Datei zweimal ein.'; + @override String get importAction => 'Importieren'; + @override String get exportSaved => 'Kopie gespeichert'; + @override String get cancelled => 'Abgebrochen'; + @override String importDone({required Object added, required Object updated}) => 'Importiert: ${added} neu, ${updated} aktualisiert'; + @override String importCsvDone({required Object count}) => '${count} Einträge hinzugefügt'; + @override String get importFailed => 'Diese Datei konnte nicht als Tane-Kopie gelesen werden'; + @override String get failed => 'Etwas ist schief gelaufen'; + @override String get recoveryTitle => 'Dein Wiederherstellungscode'; + @override String get recoverySubtitle => 'Drucke ihn und bewahre ihn auf - er öffnet deine Kopien auf einem neuen Gerät'; + @override String get recoveryIntro => 'Dieser Code öffnet deine gespeicherten Kopien und stellt deine Bank auf jedem Gerät wieder her. Bewahre zwei Papierkopien an sicheren Orten auf, wie dein bestes Saatgut. Jeder, der ihn hat, kann deine Kopien lesen, also teile ihn mit niemandem.'; + @override String get recoveryCopy => 'Kopieren'; + @override String get recoverySave => 'Blatt speichern'; + @override String get recoverySheetTitle => 'Tane - dein Wiederherstellungsblatt'; + @override String get recoveryPromptTitle => 'Gib deinen Wiederherstellungscode ein'; + @override String get recoveryPromptBody => 'Diese Kopie wurde mit einem anderen Code gespeichert. Gib den Code von deinem Wiederherstellungsblatt ein, um sie zu öffnen.'; + @override String get recoveryWrongCode => 'Dieser Code öffnet diese Kopie nicht'; +} + +// Path: about +class _Translations$about$de extends Translations$about$en { + _Translations$about$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Über'; + @override String get kanji => '種'; + @override String get tagline => 'Eine lokal speichernde, dezentralisierte App zum Verwalten und Teilen traditioneller Samen und Setzlinge.'; + @override String get intro => 'Tane (種, \'Samen\' auf Japanisch) hilft Menschen und Gemeinschaften, eine freundliche Saatgutbank zu führen, zu entscheiden, was sie anbieten, und es lokal zu teilen - ohne einen zentralen Vermittler, der es kontrollieren, zensieren oder dafür bestraft werden könnte. Der Name kommt von tanemaki (種まき), \'säen / Samen verstreuen\'. Das Ziel ist praktisch und politisch zugleich: traditionelle Samensorten unterstützen und gegen das Saatgutmonopol angehen.'; + @override String get heritage => 'Der Name würdigt alte japanische Traditionen der gegenseitigen Hilfe rund um Reis - yui (gemeinsame Gemeinschaftsarbeit) und tanomoshi (Gegenseitigkeitsfonds) - die das Papier Plantare inspiriert haben, die \'Gemeinschaftswährung für Samenaustausch\' (BAH-Semillero, 2009, CC-BY-SA). Tane ist das digitale Plantare.'; + @override String get version => 'Version'; + @override String get license => 'Lizenz'; + @override String get licenseValue => 'AGPL-3.0'; + @override String get website => 'Webseite'; + @override String get sourceCode => 'Quellcode'; + @override String get translate => 'Beim Übersetzen helfen'; + @override String get translateSubtitle => 'Hilf, Tane in deine Sprache zu bringen'; + @override String get openSourceLicenses => 'Open-Source-Lizenzen'; + @override String get openSourceLicensesSubtitle => 'Bibliotheken von Drittanbietern und ihre Lizenzen'; + @override String copyright({required Object years}) => '© ${years} Comunes Association, unter AGPLv3'; +} + +// Path: intro +class _Translations$intro$de extends Translations$intro$en { + _Translations$intro$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get skip => 'Überspringen'; + @override String get next => 'Nächste'; + @override String get start => 'Anfangen'; + @override String get menuEntry => 'Wie Tane funktioniert'; + @override late final _Translations$intro$slides$de slides = _Translations$intro$slides$de._(_root); +} + +// Path: inventory +class _Translations$inventory$de extends Translations$inventory$en { + _Translations$inventory$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Bestand'; + @override String get searchHint => 'Samen suchen'; + @override String get empty => 'Noch keine Samen. Tippe +, um deine erste hinzuzufügen.'; + @override String get noMatches => 'Keine Samen passen zu deinen Filtern.'; + @override String get clearFilters => 'Filter löschen'; + @override String get uncategorized => 'Unkategorisiert'; + @override String get needsReproductionFilter => 'Zu vermehren'; + @override String get loadError => 'Konnte deine Saatgutbank nicht öffnen. Sie war vielleicht beschäftigt - versuche es erneut.'; + @override String get retry => 'Versuchen'; +} + +// Path: draft +class _Translations$draft$de extends Translations$draft$en { + _Translations$draft$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get capture => 'Fotos erfassen'; + @override String captured({required Object n}) => '${n} erfasst zum Katalogisieren'; + @override String get triageTitle => 'Zum Katalogisieren'; + @override String triageCount({required Object n}) => '${n} zum Katalogisieren'; + @override String get untitled => 'Unbenannt'; + @override String get nameField => 'Benenne diesen Samen'; + @override String get nameHint => 'Was ist es?'; + @override String get suggestFromPhoto => 'Namen aus Foto vorschlagen'; + @override String get discard => 'Verwerfen'; +} + +// Path: quickAdd +class _Translations$quickAdd$de extends Translations$quickAdd$en { + _Translations$quickAdd$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Einen Samen hinzufügen'; + @override String get labelField => 'Name'; + @override String get labelRequired => 'Gib ihm einen Namen'; + @override String get addPhoto => 'Foto hinzufügen'; + @override String get quantity => 'Wie viel?'; + @override String get more => 'Mehr hinzufügen…'; + @override String get save => 'Speichern'; + @override String get saveAndAddAnother => 'Speichern und noch einen hinzufügen'; + @override String addedCount({required Object n}) => '${n} hinzugefügt'; + @override String get cancel => 'Abbrechen'; +} + +// Path: detail +class _Translations$detail$de extends Translations$detail$en { + _Translations$detail$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get notFound => 'Dieser Samen ist nicht mehr hier.'; + @override String get lots => 'Partien'; + @override String get noLots => 'Noch keine Partien.'; + @override String get names => 'Auch bekannt als'; + @override String get addName => 'Namen hinzufügen'; + @override String get links => 'Links'; + @override String get addLink => 'Link hinzufügen'; + @override String get linkUrl => 'URL'; + @override String get linkTitle => 'Titel (optional)'; + @override String get reference => 'Mehr erfahren'; + @override String get refGbif => 'GBIF'; + @override String get refWikipedia => 'Wikipedia'; + @override String get refWikispecies => 'Wikispecies'; + @override String get notes => 'Notizen'; + @override String get addLot => 'Partie hinzufügen'; + @override String get editLot => 'Partie bearbeiten'; + @override String get deleteConfirm => 'Diesen Samen löschen?'; + @override String year({required Object year}) => 'Jahr ${year}'; + @override String get noYear => 'Jahr unbekannt'; +} + +// Path: germination +class _Translations$germination$de extends Translations$germination$en { + _Translations$germination$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Keimung'; + @override String get add => 'Test hinzufügen'; + @override String get sampleSize => 'Stichprobengröße'; + @override String get germinated => 'Gekeimt'; + @override String get none => 'Noch keine Keimtests.'; + @override String result({required Object percent}) => '${percent}%'; +} + +// Path: viability +class _Translations$viability$de extends Translations$viability$en { + _Translations$viability$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get expiringSoon => 'Verwende oder vervielfältige diese Saison'; + @override String expiringSoonYears({required Object years}) => 'Verwende oder vervielfältige diese Saison · hält ~${years} Jahre'; + @override String get expired => 'Über typische Keimfähigkeit hinaus - zu vervielfältigen'; + @override String expiredYears({required Object years}) => 'Über typische Keimfähigkeit (~${years} Jahre) - zu vervielfältigen'; +} + +// Path: editVariety +class _Translations$editVariety$de extends Translations$editVariety$en { + _Translations$editVariety$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Samen bearbeiten'; + @override String get name => 'Name'; + @override String get category => 'Kategorie'; + @override String get notes => 'Notizen'; + @override String get species => 'Art (aus Katalog)'; + @override String get speciesHint => 'Durchsuche eine Art…'; + @override String get speciesSuggested => 'Aus dem Namen vorgeschlagen'; + @override String get organic => 'Bio'; + @override String get organicHint => 'Biologisch angebaut (Öko)'; +} + +// Path: addLot +class _Translations$addLot$de extends Translations$addLot$en { + _Translations$addLot$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Partie hinzufügen'; + @override String get year => 'Erntedate'; + @override String get quantity => 'Wie viel?'; + @override String get amount => 'Menge'; +} + +// Path: harvest +class _Translations$harvest$de extends Translations$harvest$en { + _Translations$harvest$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get pickTitle => 'Wähle Monat / Jahr'; + @override String get anyMonth => 'Jeder Monat'; + @override String get noDate => 'Erntedate setzen'; + @override List get monthNames => [ + 'Januar', + 'Februar', + 'März', + 'April', + 'Mai', + 'Juni', + 'Juli', + 'August', + 'September', + 'Oktober', + 'November', + 'Dezember', + ]; +} + +// Path: lotType +class _Translations$lotType$de extends Translations$lotType$en { + _Translations$lotType$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get seed => 'Samen'; + @override String get plant => 'Pflanze'; + @override String get seedling => 'Setzling'; + @override String get tree => 'Baum / Strauch'; + @override String get bulb => 'Zwiebel / Knolle'; + @override String get cutting => 'Steckling'; +} + +// Path: presentation +class _Translations$presentation$de extends Translations$presentation$en { + _Translations$presentation$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Verpackung'; + @override String get none => 'Unspezifisch'; + @override String get pot => 'Topf'; + @override String get tray => 'Tablett'; + @override String get plug => 'Topfplatte'; + @override String get bareRoot => 'Nacktwurzel'; + @override String get rootBall => 'Wurzelballen'; +} + +// Path: provenance +class _Translations$provenance$de extends Translations$provenance$en { + _Translations$provenance$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get section => 'Woher es kommt'; + @override String get seedsFrom => 'Samen von'; + @override String get seedsFromHint => 'Wer sie anbaute oder gab'; + @override String get place => 'Ort'; + @override String get placeHint => 'Woher sie kommen (mit Gegend)'; + @override String get addSeedsFrom => 'Samen von'; + @override String get addPlace => 'Ort'; +} + +// Path: abundance +class _Translations$abundance$de extends Translations$abundance$en { + _Translations$abundance$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get add => 'Wie viel ich habe'; + @override String get title => 'Wie viel ich habe'; + @override String get none => 'Nicht gesetzt'; + @override String get plentyToShare => 'Reichlich zum Teilen'; + @override String get enoughToShare => 'Genug zum etwas Teilen'; + @override String get enoughForMe => 'Genug für mich'; + @override String get runningLow => 'Wird knapp'; +} + +// Path: share +class _Translations$share$de extends Translations$share$en { + _Translations$share$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get add => 'Teilst du es?'; + @override String get title => 'Teilst du es?'; + @override String get nudge => 'Du hast reichlich - du könntest etwas teilen.'; + @override String get price => 'Preis'; + @override String get priceHint => 'Lass es leer, um es später zu vereinbaren'; + @override String get private => 'Nur für mich'; + @override String get gift => 'Zum Verschenken'; + @override String get exchange => 'Zum Tauschen'; + @override String get sell => 'Zum Verkauf'; + @override String get filterChip => 'Ich teile'; + @override String get printCatalog => 'Drucke, was ich teile'; + @override String get catalogTitle => 'Was ich teile'; + @override String get catalogSaved => 'Katalog gespeichert'; + @override String get cancelled => 'Abgebrochen'; +} + +// Path: printLabels +class _Translations$printLabels$de extends Translations$printLabels$en { + _Translations$printLabels$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get action => 'Etiketten drucken'; + @override String get title => 'Etiketten drucken'; + @override String get selectHint => 'Wähle die Samen, für die du Etiketten drucken möchtest'; + @override String get selectAll => 'Alles auswählen'; + @override String selected({required Object n}) => '${n} ausgewählt'; + @override String get none => 'Wähle zuerst Samen aus'; + @override String get format => 'Etikettengröße'; + @override String get formatStickers => 'Kleine Aufkleber'; + @override String get formatStickersHint => 'Viele kleine Etiketten pro Seite - zum Aufkleben auf Päckchen'; + @override String get formatCards => 'Große Karten'; + @override String get formatCardsHint => 'Weniger, größere Etiketten - für Gläser und Boxen'; + @override String count({required Object n}) => '${n} Etiketten'; + @override String get save => 'Etiketten speichern'; + @override String get saved => 'Etiketten gespeichert'; + @override String get cancelled => 'Abgebrochen'; +} + +// Path: cropCalendar +class _Translations$cropCalendar$de extends Translations$cropCalendar$en { + _Translations$cropCalendar$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get add => 'Anbaukalender'; + @override String get title => 'Anbaukalender'; + @override String get sow => 'Säen'; + @override String get transplant => 'Pflanzen'; + @override String get flowering => 'Blüte'; + @override String get fruiting => 'Fruchtbildung'; + @override String get seedHarvest => 'Samenernte'; + @override String get editorHint => 'Notiere die typischen Monate für diese Sorte in deinem Gebiet - das sind deine eigenen Notizen.'; + @override String get unset => '—'; +} + +// Path: needsReproduction +class _Translations$needsReproduction$de extends Translations$needsReproduction$en { + _Translations$needsReproduction$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get label => 'Diese Saison vermehren'; + @override String get hint => 'Baue es an, bevor der Samen ausgeht'; + @override String get badge => 'Zu vermehren'; +} + +// Path: preservation +class _Translations$preservation$de extends Translations$preservation$en { + _Translations$preservation$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get add => 'Wie es aufbewahrt wird'; + @override String get title => 'Wie es aufbewahrt wird'; + @override String get none => 'Unspezifisch'; + @override String get jarWithDesiccant => 'Glas mit Trockenmittel'; + @override String get glassJar => 'Glasgefäß'; + @override String get paperEnvelope => 'Papierumschlag'; + @override String get paperBag => 'Papierbeutel'; + @override String get plasticBag => 'Plastikbeutel'; +} + +// Path: conditionCheck +class _Translations$conditionCheck$de extends Translations$conditionCheck$en { + _Translations$conditionCheck$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get advanced => 'Lagerung und Saatgutbank-Details'; + @override String get title => 'Lagerchecks'; + @override String get add => 'Check hinzufügen'; + @override String get containers => 'Gläser / Behälter'; + @override String get desiccant => 'Trockenmittel'; + @override String get none => 'Noch keine Lagerchecks.'; + @override String summary({required Object count, required Object state}) => '${count} Glas(gläser) · ${state}'; +} + +// Path: desiccant +class _Translations$desiccant$de extends Translations$desiccant$en { + _Translations$desiccant$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get none => 'Keine'; + @override String get add => 'Eine hinzufügen'; + @override String get replace => 'Austauschen'; + @override String get dry => 'Blau - trocken'; + @override String get fresh => 'Gerade erneuert'; +} + +// Path: unit +class _Translations$unit$de extends Translations$unit$en { + _Translations$unit$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get aFew => 'einige'; + @override String get some => 'einige'; + @override String get plenty => 'viele'; + @override String get pinch => 'eine Prise'; + @override late final _Translations$unit$handful$de handful = _Translations$unit$handful$de._(_root); + @override late final _Translations$unit$teaspoon$de teaspoon = _Translations$unit$teaspoon$de._(_root); + @override late final _Translations$unit$spoon$de spoon = _Translations$unit$spoon$de._(_root); + @override late final _Translations$unit$cup$de cup = _Translations$unit$cup$de._(_root); + @override late final _Translations$unit$jar$de jar = _Translations$unit$jar$de._(_root); + @override late final _Translations$unit$sack$de sack = _Translations$unit$sack$de._(_root); + @override late final _Translations$unit$packet$de packet = _Translations$unit$packet$de._(_root); + @override late final _Translations$unit$cob$de cob = _Translations$unit$cob$de._(_root); + @override late final _Translations$unit$pod$de pod = _Translations$unit$pod$de._(_root); + @override late final _Translations$unit$ear$de ear = _Translations$unit$ear$de._(_root); + @override late final _Translations$unit$head$de head = _Translations$unit$head$de._(_root); + @override late final _Translations$unit$fruit$de fruit = _Translations$unit$fruit$de._(_root); + @override late final _Translations$unit$bulb$de bulb = _Translations$unit$bulb$de._(_root); + @override late final _Translations$unit$tuber$de tuber = _Translations$unit$tuber$de._(_root); + @override late final _Translations$unit$seedHead$de seedHead = _Translations$unit$seedHead$de._(_root); + @override late final _Translations$unit$bunch$de bunch = _Translations$unit$bunch$de._(_root); + @override late final _Translations$unit$plant$de plant = _Translations$unit$plant$de._(_root); + @override late final _Translations$unit$pot$de pot = _Translations$unit$pot$de._(_root); + @override late final _Translations$unit$tray$de tray = _Translations$unit$tray$de._(_root); + @override late final _Translations$unit$seedling$de seedling = _Translations$unit$seedling$de._(_root); + @override late final _Translations$unit$tree$de tree = _Translations$unit$tree$de._(_root); + @override late final _Translations$unit$cutting$de cutting = _Translations$unit$cutting$de._(_root); + @override late final _Translations$unit$grams$de grams = _Translations$unit$grams$de._(_root); + @override late final _Translations$unit$count$de count = _Translations$unit$count$de._(_root); +} + +// Path: market +class _Translations$market$de extends Translations$market$en { + _Translations$market$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Samen in deiner Nähe'; + @override String get subtitle => 'Was andere in der Nähe teilen'; + @override String get notSetUp => 'Teilen ist noch nicht eingerichtet'; + @override String get notSetUpBody => 'Aktiviere Teilen, um Samen zu sehen und mit Menschen in der Nähe zu teilen. Es bleibt ungefähr - deine Zone, nie deine genaue Adresse.'; + @override String get setUp => 'Teilen einrichten'; + @override String get cantReach => 'Kann die Gemeinschaftsserver gerade nicht erreichen'; + @override String get cantReachBody => 'Versuche es erneut, oder überprüfe die Server unter Erweiterte Einrichtung.'; + @override String get retry => 'Erneut versuchen'; + @override String get setArea => 'Stelle dein Gebiet ein'; + @override String get setAreaBody => 'Teile dem Markt ungefähr mit, wo du bist, um Samen in deiner Nähe zu sehen.'; + @override String get searching => 'Schaue nach deinem Gebiet…'; + @override String get empty => 'Noch keine Samen in deiner Nähe geteilt'; + @override String get searchHint => 'Suche nach diesen Samen'; + @override String get noMatches => 'Keine geteilten Samen passen zu deiner Suche'; + @override String get near => 'In deiner Nähe'; + @override String get contact => 'Nachricht'; + @override String get mine => 'Du'; + @override String get configTitle => 'Teilen-Einrichtung'; + @override String get setupIntro => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.'; + @override String get areaLabel => 'Dein Gebiet'; + @override String get areaHelp => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.'; + @override String get areaSet => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt'; + @override String get areaNotSet => 'Gebiet noch nicht gesetzt - verwende deinen Standort oder füge einen Code unter Erweiterte hinzu'; + @override String get advanced => 'Erweiterte'; + @override String get areaCodeLabel => 'Gebiets-Code'; + @override String get areaCodeHint => 'Ein kurzer Code wie sp3e9 - kein Ortsname'; + @override String get serversLabel => 'Gemeinschaftsserver'; + @override String get serversHelp => 'Wähle, welche Server verwendet werden. Lass die Standardwerte, wenn du dir nicht sicher bist.'; + @override String get serversAdvanced => 'Füge einen weiteren Server hinzu'; + @override String get serverAddress => 'Serveradresse'; + @override String get serverInvalid => 'Gib eine gültige Adresse ein (wss://…)'; + @override String get save => 'Speichern'; + @override String get saved => 'Gespeichert'; + @override String get wanted => 'Gesucht'; + @override String get shareMine => 'Meine Samen teilen'; + @override String sharedCount({required Object n}) => '${n} Samen in der Nähe geteilt'; + @override String get nothingToShare => 'Markiere zuerst einige Samen zum Verschenken, Tauschen oder Verkaufen'; + @override String get useLocation => 'Meinen ungefähren Standort verwenden'; + @override String get locationFailed => 'Konnte deinen Standort nicht ermitteln - überprüfe, dass Standort aktiviert ist und die Berechtigung erteilt wurde'; + @override String get queued => 'Gespeichert - wir werden diese teilen, wenn du verbunden bist'; + @override String get shareFailed => 'Konnte die Gemeinschaftsserver nicht erreichen - deine Samen wurden nicht geteilt. Versuche es gleich erneut.'; + @override String get rangeLabel => 'Wie weit zum Suchen'; + @override String get rangeNear => 'Sehr nah'; + @override String get rangeArea => 'Um mich herum'; + @override String get rangeRegion => 'Meine Region'; + @override String get sharedBy => 'Geteilt von'; + @override String get noProfile => 'Diese Person hat noch kein Profil geteilt'; + @override String get copyId => 'Code kopieren'; + @override String get idCopied => 'Code kopiert'; + @override String get photo => 'Foto'; +} + +// Path: profile +class _Translations$profile$de extends Translations$profile$en { + _Translations$profile$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Dein Profil'; + @override String get name => 'Anzeigename'; + @override String get nameHint => 'Wie dich andere sehen'; + @override String get about => 'Über'; + @override String get aboutHint => 'Eine kurze Zeile - was du anbaust, wo'; + @override String get g1 => 'Ğ1-Adresse (optional)'; + @override String get g1Hint => 'Damit dich Leute in Ğ1 bezahlen können - getrennt von deinem Schlüssel'; + @override String get yourId => 'Deine Identität'; + @override String get idHelp => 'Teile dies, damit dich Leute erkennen'; + @override String get copy => 'Kopieren'; + @override String get copied => 'Kopiert'; + @override String get save => 'Speichern'; + @override String get saved => 'Profil gespeichert'; + @override String get identities => 'Deine Identitäten'; + @override String get identitiesHelp => 'Halte separate Identitäten - jede mit ihren eigenen Nachrichten und Kontakten. Alle kommen aus deiner einen Sicherung, also Wechsel fügt nichts hinzu, das man sich merken muss.'; + @override String identityLabel({required Object n}) => 'Identität ${n}'; + @override String get current => 'In Gebrauch'; + @override String get newIdentity => 'Neue Identität'; + @override String get switchTitle => 'Identität wechseln?'; + @override String get switchBody => 'Nachrichten und Kontakte werden für jede Identität getrennt aufbewahrt. Du kannst jederzeit zurückwechseln.'; + @override String get switchAction => 'Wechsel'; +} + +// Path: chatList +class _Translations$chatList$de extends Translations$chatList$en { + _Translations$chatList$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Nachrichten'; + @override String get empty => 'Noch keine Gespräche. Schreibe jemanden vom Markt an.'; +} + +// Path: chat +class _Translations$chat$de extends Translations$chat$en { + _Translations$chat$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Chat'; + @override String get hint => 'Schreibe eine Nachricht…'; + @override String get send => 'Senden'; + @override String get empty => 'Noch keine Nachrichten - sag hallo'; + @override String get offline => 'Richte Teilen ein, um Nachrichten zu senden'; + @override String get payG1 => 'In Ğ1 bezahlen'; + @override String get g1Copied => 'Ğ1-Adresse kopiert - füge sie in dein Portemonnaie ein'; + @override String get today => 'Heute'; + @override String get yesterday => 'Gestern'; + @override String get sendError => 'Konnte nicht senden - überprüfe deine Verbindung'; + @override String get noLinks => 'Links sind in Nachrichten nicht erlaubt'; +} + +// Path: trust +class _Translations$trust$de extends Translations$trust$en { + _Translations$trust$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get none => 'Noch niemand bürgt für sie'; + @override String count({required Object n}) => 'Von ${n} bestätigt'; + @override String get vouch => 'Ich kenne diese Person'; + @override String get vouched => 'Du bürgst für sie'; + @override String get circle => 'In deinem Kreis'; +} + +// Path: yourPeople +class _Translations$yourPeople$de extends Translations$yourPeople$en { + _Translations$yourPeople$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Deine Leute'; + @override String get help => 'Menschen, die du kennst und für die du einstehst, und Menschen, die für dich einstehen.'; + @override String get youVouchFor => 'Du stehst ein für'; + @override String get vouchesForYou => 'Sie stehen für dich ein'; + @override String get youVouchForEmpty => 'Du stehst noch für niemanden ein. Wenn du jemanden triffst, öffne deinen Chat mit ihnen und tippe \'Ich kenne diese Person\'.'; + @override String get vouchesForYouEmpty => 'Noch niemand steht für dich ein'; + @override String get revoke => 'Nicht mehr einstehen'; + @override String get revokeConfirm => 'Nicht mehr für diese Person einstehen?'; + @override String get offline => 'Du bist offline - versuche es, wenn du verbunden bist'; +} + +// Path: ratings +class _Translations$ratings$de extends Translations$ratings$en { + _Translations$ratings$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get rate => 'Bewerte diese Person'; + @override String get edit => 'Bearbeite deine Bewertung'; + @override String get commentHint => 'Wie war es? (optional)'; + @override String fromYourCircle({required Object n}) => '${n} von Leuten, die du kennst'; + @override String get retract => 'Entferne deine Bewertung'; + @override String get saved => 'Bewertung gespeichert'; +} + +// Path: notifications +class _Translations$notifications$de extends Translations$notifications$en { + _Translations$notifications$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Neue Nachricht von ${name}'; +} + +// Path: plantare +class _Translations$plantare$de extends Translations$plantare$en { + _Translations$plantare$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Ein Plantare ist ein Versprechen, einen Samen zu vervielfältigen und etwas zurückzugeben - wie eine Sorte von Hand zu Hand reist. Es ist kein Verkauf.'; + @override String get add => 'Verpflichtung hinzufügen'; + @override String get empty => 'Noch keine Verpflichtungen. Wenn du einen Samen mit dem Versprechen teilst oder erhältst, ihn anzubauen und etwas zurückzugeben, notiere es hier.'; + @override String get iReturn => 'Ich baue es an und gebe es zurück'; + @override String get owedToMe => 'Sie geben mir etwas zurück'; + @override String get direction => 'Wer baut an und gibt zurück'; + @override String get counterparty => 'Mit wem?'; + @override String get counterpartyHint => 'Eine Person oder ein Kollektiv (optional)'; + @override String get owed => 'Was kommt zurück?'; + @override String get owedHint => 'z.B. eine Handvoll nächste Saison (optional)'; + @override String get note => 'Notiz (optional)'; + @override String get save => 'Speichern'; + @override String get markReturned => 'Als zurückgegeben markieren'; + @override String get markForgiven => 'Absehen lassen'; + @override String get reopen => 'Erneut öffnen'; + @override String get delete => 'Entfernen'; + @override String get statusReturned => 'Zurückgegeben'; + @override String get statusForgiven => 'Erledigt'; + @override String get openSection => 'Offen'; + @override String get settledSection => 'Erledigt'; + @override String get removeConfirm => 'Diese Verpflichtung entfernen?'; +} + +// Path: handover +class _Translations$handover$de extends Translations$handover$en { + _Translations$handover$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Samen wechselten den Besitzer'; + @override String get help => 'Ein Geschenk, ein Tausch oder ein Verkauf - notiere es auf, mit oder ohne Rückgabeversprechen.'; + @override String get iGave => 'Ich gab Samen'; + @override String get iReceived => 'Ich erhielt Samen'; + @override String get whichLot => 'Welche Partie?'; + @override String get howMuch => 'Wie viel?'; + @override String get allOfIt => 'Alles'; + @override String get partOfIt => 'Ein Teil'; + @override String get paymentChip => 'Geld wechselte den Besitzer'; + @override String get promiseGave => 'Sie geben mir Samen zurück'; + @override String get promiseReceived => 'Ich gebe Samen zurück'; +} + +// Path: sale +class _Translations$sale$de extends Translations$sale$en { + _Translations$sale$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Verkäufe'; + @override String get help => 'Notiere Samen, die du verkauft oder gekauft hast - Geld, Ğ1 oder jede Währung. Ein eigenes Modell abseits Geschenk und Plantare. Es wird nie eine Kommission auf Samen erhoben.'; + @override String get add => 'Verkauf notieren'; + @override String get empty => 'Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.'; + @override String get iSold => 'Ich verkaufte'; + @override String get iBought => 'Ich kaufte'; + @override String get direction => 'Verkauft oder gekauft?'; + @override String get counterparty => 'Mit wem?'; + @override String get counterpartyHint => 'Eine Person oder ein Kollektiv (optional)'; + @override String get amount => 'Betrag'; + @override String get currency => 'Währung'; + @override String get currencyHint => '€, Ğ1, Stunden… (optional)'; + @override String get hours => 'Stunden'; + @override String get note => 'Notiz (optional)'; + @override String get save => 'Speichern'; + @override String get delete => 'Entfernen'; + @override String get removeConfirm => 'Diesen Verkauf entfernen?'; +} + +// Path: legal +class _Translations$legal$de extends Translations$legal$en { + _Translations$legal$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Datenschutz und Regeln'; + @override String get subtitle => 'Dein Datenschutz, die Marktregeln und die Legalität von Samen'; + @override String get privacyTitle => 'Dein Datenschutz'; + @override String get privacyBody => 'Tane funktioniert ohne Konto, und alles, das du notierst, bleibt auf deinem Gerät, verschlüsselt. Es gibt keine Werbung, keine Tracker und keinen Tane-Server.\n\nNichts wird geteilt, es sei denn, du wählst es: Wenn du ein Angebot oder dein Profil veröffentlichst oder eine Nachricht sendest, reist es durch Gemeinschaftsserver. Angebote tragen nur eine ungefähre Zone - nie deine Adresse.\n\nWas du veröffentlichst, ist öffentlich, und Kopien können bleiben, selbst nachdem du es entfernt hast - teile also mit Bedacht.'; + @override String get rulesTitle => 'Die Spielregeln'; + @override String get rulesBody => 'Tane ist ein Werkzeug, kein Laden: Austausch wird direkt zwischen Menschen vereinbart, und niemand nimmt eine Provision. Das bedeutet auch, dass du verantwortlich bist für das, das du anbietest und sendest.\n\nAuf dem Markt: Sei ehrlich mit deinen Samen, biete nur an, was du teilen darfst, behandle Menschen gut und spamme nicht. Du kannst jeden blockieren und Angebote oder Menschen melden, die die Regeln brechen - Meldungen werden auf den Gemeinschaftsservern bearbeitet.'; + @override String get seedsTitle => 'Über das Teilen von Samen und Setzlingen'; + @override String get seedsBody => 'Das Verschenken und Tauschen von Samen zwischen Hobbyisten ist in den meisten Ländern weit verbreitet. Verkaufen kann anders sein: An vielen Orten ist der Verkauf von Samen nicht offiziell registrierter Sorten eingeschränkt - überprüfe deine örtlichen Vorschriften, bevor du einen Preis fragst.\n\nSamen in ein anderes Land zu schicken ist auch oft eingeschränkt, und kommerziell geschützte Sorten dürfen nicht ohne Genehmigung vermehrt werden. Im Zweifelsfall lieber lokal und lieber Geschenk.\n\nDasselbe gilt für Setzlinge und Jungpflanzen, aber lebende Pflanzen können strengeren Transport- und Pflanzenschutzregeln unterliegen als Samen — sie über Regionen oder Grenzen zu bewegen kann zusätzliche Kontrollen erfordern.'; + @override String get readFull => 'Lese die vollständigen Dokumente online'; +} + +// Path: marketGate +class _Translations$marketGate$de extends Translations$marketGate$en { + _Translations$marketGate$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Bevor du den Markt betrittst'; + @override String get intro => 'Der Markt ist ein gemeinsamer Raum zwischen Nachbarn. Wenn du fortfährst, akzeptierst du einige einfache Regeln:'; + @override String get ruleHonest => 'Sei ehrlich mit den Samen, die du anbietest'; + @override String get ruleLegal => 'Teile nur, was du teilen darfst, wo du lebst'; + @override String get ruleRespect => 'Behandle Menschen gut - kein Spam, kein Missbrauch'; + @override String get publicNote => 'Was du hier veröffentlichst, ist öffentlich, und Kopien können bleiben, selbst wenn du es später entfernst.'; + @override String get viewLegal => 'Datenschutz und Regeln'; + @override String get accept => 'Ich akzeptiere'; + @override String get decline => 'Nicht jetzt'; +} + +// Path: report +class _Translations$report$de extends Translations$report$en { + _Translations$report$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get offer => 'Melde dieses Angebot'; + @override String get person => 'Melde diese Person'; + @override String get title => 'Melden'; + @override String get prompt => 'Was ist falsch?'; + @override String get reasonSpam => 'Spam oder Betrug'; + @override String get reasonAbuse => 'Beleidigend oder respektlos'; + @override String get reasonIllegal => 'Samen, die nicht angeboten werden sollten'; + @override String get reasonOther => 'Etwas anderes'; + @override String get detailsHint => 'Füge Details hinzu (optional)'; + @override String get send => 'Meldung senden'; + @override String get sentHidden => 'Meldung gesendet - du wirst dies nicht mehr sehen'; + @override String get failed => 'Konnte die Meldung nicht senden - überprüfe deine Verbindung'; + @override String get alsoBlock => 'Diese Person auch blockieren'; +} + +// Path: block +class _Translations$block$de extends Translations$block$en { + _Translations$block$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get action => 'Diese Person blockieren'; + @override String get confirmTitle => 'Diese Person blockieren?'; + @override String get confirmBody => 'Du wirst ihre Angebote und Nachrichten nicht mehr sehen. Du kannst sie später unter Blockierte Leute in der Teilen-Einrichtung entsperren.'; + @override String get confirm => 'Blockieren'; + @override String get blockedToast => 'Blockiert - ihre Angebote und Nachrichten sind verborgen'; + @override String get manageTitle => 'Blockierte Leute'; + @override String get manageEmpty => 'Du hast niemanden blockiert'; + @override String get unblock => 'Entsperren'; +} + +// Path: intro.slides +class _Translations$intro$slides$de extends Translations$intro$slides$en { + _Translations$intro$slides$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override late final _Translations$intro$slides$welcome$de welcome = _Translations$intro$slides$welcome$de._(_root); + @override late final _Translations$intro$slides$inventory$de inventory = _Translations$intro$slides$inventory$de._(_root); + @override late final _Translations$intro$slides$privacy$de privacy = _Translations$intro$slides$privacy$de._(_root); + @override late final _Translations$intro$slides$share$de share = _Translations$intro$slides$share$de._(_root); + @override late final _Translations$intro$slides$plantare$de plantare = _Translations$intro$slides$plantare$de._(_root); +} + +// Path: unit.handful +class _Translations$unit$handful$de extends Translations$unit$handful$en { + _Translations$unit$handful$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Handvoll'; + @override String get plural => 'Handvoll'; +} + +// Path: unit.teaspoon +class _Translations$unit$teaspoon$de extends Translations$unit$teaspoon$en { + _Translations$unit$teaspoon$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Teelöffel'; + @override String get plural => 'Teelöffel'; +} + +// Path: unit.spoon +class _Translations$unit$spoon$de extends Translations$unit$spoon$en { + _Translations$unit$spoon$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Löffel'; + @override String get plural => 'Löffel'; +} + +// Path: unit.cup +class _Translations$unit$cup$de extends Translations$unit$cup$en { + _Translations$unit$cup$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Tasse'; + @override String get plural => 'Tassen'; +} + +// Path: unit.jar +class _Translations$unit$jar$de extends Translations$unit$jar$en { + _Translations$unit$jar$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Glas'; + @override String get plural => 'Gläser'; +} + +// Path: unit.sack +class _Translations$unit$sack$de extends Translations$unit$sack$en { + _Translations$unit$sack$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Sack'; + @override String get plural => 'Säcke'; +} + +// Path: unit.packet +class _Translations$unit$packet$de extends Translations$unit$packet$en { + _Translations$unit$packet$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Päckchen'; + @override String get plural => 'Päckchen'; +} + +// Path: unit.cob +class _Translations$unit$cob$de extends Translations$unit$cob$en { + _Translations$unit$cob$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Kolben'; + @override String get plural => 'Kolben'; +} + +// Path: unit.pod +class _Translations$unit$pod$de extends Translations$unit$pod$en { + _Translations$unit$pod$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Schote'; + @override String get plural => 'Schoten'; +} + +// Path: unit.ear +class _Translations$unit$ear$de extends Translations$unit$ear$en { + _Translations$unit$ear$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Ähre'; + @override String get plural => 'Ähren'; +} + +// Path: unit.head +class _Translations$unit$head$de extends Translations$unit$head$en { + _Translations$unit$head$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Kopf'; + @override String get plural => 'Köpfe'; +} + +// Path: unit.fruit +class _Translations$unit$fruit$de extends Translations$unit$fruit$en { + _Translations$unit$fruit$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Frucht'; + @override String get plural => 'Früchte'; +} + +// Path: unit.bulb +class _Translations$unit$bulb$de extends Translations$unit$bulb$en { + _Translations$unit$bulb$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Zwiebel'; + @override String get plural => 'Zwiebeln'; +} + +// Path: unit.tuber +class _Translations$unit$tuber$de extends Translations$unit$tuber$en { + _Translations$unit$tuber$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Knolle'; + @override String get plural => 'Knollen'; +} + +// Path: unit.seedHead +class _Translations$unit$seedHead$de extends Translations$unit$seedHead$en { + _Translations$unit$seedHead$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Samenkopf'; + @override String get plural => 'Samenköpfe'; +} + +// Path: unit.bunch +class _Translations$unit$bunch$de extends Translations$unit$bunch$en { + _Translations$unit$bunch$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Bund'; + @override String get plural => 'Bunde'; +} + +// Path: unit.plant +class _Translations$unit$plant$de extends Translations$unit$plant$en { + _Translations$unit$plant$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Pflanze'; + @override String get plural => 'Pflanzen'; +} + +// Path: unit.pot +class _Translations$unit$pot$de extends Translations$unit$pot$en { + _Translations$unit$pot$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Topf'; + @override String get plural => 'Töpfe'; +} + +// Path: unit.tray +class _Translations$unit$tray$de extends Translations$unit$tray$en { + _Translations$unit$tray$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Tablett'; + @override String get plural => 'Tabletts'; +} + +// Path: unit.seedling +class _Translations$unit$seedling$de extends Translations$unit$seedling$en { + _Translations$unit$seedling$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Setzling'; + @override String get plural => 'Setzlinge'; +} + +// Path: unit.tree +class _Translations$unit$tree$de extends Translations$unit$tree$en { + _Translations$unit$tree$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Baum'; + @override String get plural => 'Bäume'; +} + +// Path: unit.cutting +class _Translations$unit$cutting$de extends Translations$unit$cutting$en { + _Translations$unit$cutting$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Steckling'; + @override String get plural => 'Stecklinge'; +} + +// Path: unit.grams +class _Translations$unit$grams$de extends Translations$unit$grams$en { + _Translations$unit$grams$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Gramm'; + @override String get plural => 'Gramm'; +} + +// Path: unit.count +class _Translations$unit$count$de extends Translations$unit$count$en { + _Translations$unit$count$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get singular => 'Samen'; + @override String get plural => 'Samen'; +} + +// Path: intro.slides.welcome +class _Translations$intro$slides$welcome$de extends Translations$intro$slides$welcome$en { + _Translations$intro$slides$welcome$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Der Samen, der dich hierher brachte'; + @override String get body => 'Jeder traditionelle Samen ist ein Brief, geschrieben von Tausenden von Generationen, weitergegeben von Hand zu Hand. Wir sind, was wir sind, dank dieses Teilens.'; +} + +// Path: intro.slides.inventory +class _Translations$intro$slides$inventory$de extends Translations$intro$slides$inventory$en { + _Translations$intro$slides$inventory$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Deine Saatgutbank in deiner Tasche'; + @override String get body => 'Notiere, was du hast, von welchem Jahr, wie viel und woher es kam - mit dem Namen, den du verwendest. Ein Foto und ein Name reichen aus, um zu beginnen.'; +} + +// Path: intro.slides.privacy +class _Translations$intro$slides$privacy$de extends Translations$intro$slides$privacy$en { + _Translations$intro$slides$privacy$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Deins und nur deins'; + @override String get body => 'Kein Konto, kein Internet, keine Tracker. Deine Daten leben verschlüsselt auf deinem Gerät, und nur das, das du wählst, wird je geteilt.'; +} + +// Path: intro.slides.share +class _Translations$intro$slides$share$de extends Translations$intro$slides$share$en { + _Translations$intro$slides$share$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Teilen, wie es immer gemacht wurde'; + @override String get body => 'Biete an, was du übrig hast - Geschenk, Tausch oder Verkauf - und lass jemanden in deiner Nähe es finden. Nur ein ungefährer Abstand wird gezeigt, nie deine Adresse; du schließt das Geschäft von Angesicht zu Angesicht.'; +} + +// Path: intro.slides.plantare +class _Translations$intro$slides$plantare$de extends Translations$intro$slides$plantare$en { + _Translations$intro$slides$plantare$de._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Säen ist vervielfältigen'; + @override String get body => 'Bei einem Plantare verspricht, wer einen Samen erhält, später etwas zurückzugeben. Und da Zurückgeben bedeutet, es anzubauen, vervielfältigt jede Leihe das Gemeingut.'; +} + +/// The flat map containing all translations for locale . +/// Only for edge cases! For simple maps, use the map function of this library. +/// +/// The Dart AOT compiler has issues with very large switch statements, +/// so the map is split into smaller functions (512 entries each). +extension on TranslationsDe { + dynamic _flatMapFunction(String path) { + return switch (path) { + 'avatar.title' => 'Dein Foto oder Avatar', + 'avatar.fromPhoto' => 'Foto machen oder auswählen', + 'avatar.illustration' => 'Oder wähle eine Illustration', + 'avatar.remove' => 'Entfernen', + 'favorites.title' => 'Favoriten', + 'favorites.empty' => 'Noch keine Favoriten. Speichere Angebote, die dir auf dem Markt gefallen.', + 'favorites.save' => 'Zu Favoriten speichern', + 'favorites.remove' => 'Aus Favoriten entfernen', + 'favorites.unavailable' => 'Nicht mehr verfügbar', + 'seedSaving.title' => 'Samen vervielfältigen', + 'seedSaving.subtitle' => 'Was nötig ist, um die Sorte echt zu erhalten', + 'seedSaving.lifeCycle' => 'Zyklus', + 'seedSaving.cycleAnnual' => 'Einjährig', + 'seedSaving.cycleBiennial' => 'Zweijährig - Samen im 2. Jahr', + 'seedSaving.cyclePerennial' => 'Mehrjährig', + 'seedSaving.pollination' => 'Bestäubung', + 'seedSaving.pollSelf' => 'Selbstbestäubend', + 'seedSaving.pollCross' => 'Kreuzt mit anderen', + 'seedSaving.pollMixed' => 'Selbstbestäubend, manchmal Fremdbefruchtung', + 'seedSaving.byInsect' => 'durch Insekten', + 'seedSaving.byWind' => 'durch Wind', + 'seedSaving.isolation' => 'Abstand halten', + 'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m zu anderen Sorten', + 'seedSaving.isolationSingle' => ({required Object min}) => '${min} m zu anderen Sorten', + 'seedSaving.plants' => 'Samen von mehreren Pflanzen sammeln', + 'seedSaving.plantsValue' => ({required Object n}) => 'Von mindestens ${n} Pflanzen', + 'seedSaving.processing' => 'Samen reinigen', + 'seedSaving.procDry' => 'Trockensamen (ausdreschen)', + 'seedSaving.procWet' => 'Feuchtsamen (fermentieren und spülen)', + 'seedSaving.difficulty' => 'Schwierigkeit', + 'seedSaving.diffEasy' => 'Leicht', + 'seedSaving.diffMedium' => 'Mittel', + 'seedSaving.diffHard' => 'Schwer', + 'seedSaving.advisory' => 'Allgemeine Hinweise - passe sie an dein Klima und deine Sorte an.', + 'seedSaving.sourcePrefix' => 'Quelle', + 'calendar.title' => 'Dieser Monat', + 'calendar.filterChip' => 'Dieser Monat', + 'calendar.selfNote' => 'Was du in deinen Sorten notiert hast.', + 'calendar.nothing' => ({required Object month}) => 'Nichts für ${month} notiert.', + 'app.title' => 'Tane', + 'bootstrap.failed' => 'Tane konnte nicht starten', + 'bootstrap.retry' => 'Versuchen', + 'common.save' => 'Speichern', + 'common.cancel' => 'Abbrechen', + 'common.delete' => 'Löschen', + 'common.edit' => 'Bearbeiten', + 'common.type' => 'Typ', + 'common.comingSoon' => 'Bald', + 'common.offline' => 'Offline - Teilen ist unterbrochen', + 'home.tagline' => 'Teile und baue lokale Samen an', + 'home.openMarket' => 'Markt', + 'home.openMarketSubtitle' => 'Entdecke und teile Samen in deiner Nähe', + 'home.yourInventory' => 'Dein Bestand', + 'home.yourInventorySubtitle' => 'Verwalte deine Samen', + 'photo.camera' => 'Foto machen', + 'photo.gallery' => 'Aus Galerie wählen', + 'photo.setAsCover' => 'Als Deckblatt setzen', + 'photo.isCover' => 'Deckblatt-Foto', + 'photo.deleteConfirm' => 'Dieses Foto löschen?', + 'menu.tagline' => 'deine Saatgutbank', + 'menu.inventory' => 'Bestand', + 'menu.market' => 'Markt', + 'menu.profile' => 'Dein Profil', + 'menu.chat' => 'Chat', + 'menu.wishlist' => 'Favoriten', + 'menu.following' => 'Gefolgt', + 'menu.plantares' => 'Plantares', + 'menu.sales' => 'Verkäufe', + 'menu.calendar' => 'Kalender', + 'menu.settings' => 'Einstellungen', + 'settings.language' => 'Sprache', + 'settings.systemLanguage' => 'Systemsprache', + 'settings.langEs' => 'Español', + 'settings.langEn' => 'English', + 'settings.langPt' => 'Português', + 'settings.langAst' => 'Asturianu', + 'settings.about' => 'Über', + 'settings.aboutText' => 'Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.', + 'settings.aboutOpen' => 'Über Tane', + 'settings.langDe' => 'Deutsch', + 'settings.langFr' => 'Français', + 'settings.langJa' => '日本語', + 'backup.title' => 'Sicherung und Wiederherstellung', + 'backup.autoBackupTitle' => 'Automatische Sicherungen', + 'backup.autoBackupLast' => ({required Object date, required Object days}) => 'Letzte Sicherung ${date} · alle ${days} Tage', + 'backup.autoBackupNone' => ({required Object days}) => 'Eine Kopie wird automatisch alle ${days} Tage gespeichert', + 'backup.exportJson' => 'Sicherung speichern', + 'backup.exportJsonSubtitle' => 'Eine komplette Kopie zum sicheren Aufbewahren, später wiederherstellen oder auf ein anderes Gerät übertragen', + 'backup.importJson' => 'Sicherung wiederherstellen', + 'backup.importJsonSubtitle' => 'Bringe eine gespeicherte Kopie zurück - nichts wird dupliziert', + 'backup.exportCsv' => 'In eine Tabelle exportieren', + 'backup.exportCsvSubtitle' => 'Eine einfache Liste für Excel oder LibreOffice - ohne Fotos', + 'backup.importCsv' => 'Eine Liste importieren', + 'backup.importCsvSubtitle' => 'Einträge aus einer Tabelle hinzufügen', + 'backup.importConfirmTitle' => 'Sicherung wiederherstellen?', + 'backup.importConfirmBody' => 'Einträge werden mit deinem Bestand zusammengeführt; wenn ein Eintrag auf beiden Seiten existiert, gewinnt die neuere Version. Nichts wird dupliziert.', + 'backup.importCsvConfirmTitle' => 'Liste importieren?', + 'backup.importCsvConfirmBody' => 'Jede Zeile wird als neuer Eintrag hinzugefügt. Dies führt nicht zusammen und ersetzt nicht, also bringt zweimaliger Import dieselbe Datei zweimal ein.', + 'backup.importAction' => 'Importieren', + 'backup.exportSaved' => 'Kopie gespeichert', + 'backup.cancelled' => 'Abgebrochen', + 'backup.importDone' => ({required Object added, required Object updated}) => 'Importiert: ${added} neu, ${updated} aktualisiert', + 'backup.importCsvDone' => ({required Object count}) => '${count} Einträge hinzugefügt', + 'backup.importFailed' => 'Diese Datei konnte nicht als Tane-Kopie gelesen werden', + 'backup.failed' => 'Etwas ist schief gelaufen', + 'backup.recoveryTitle' => 'Dein Wiederherstellungscode', + 'backup.recoverySubtitle' => 'Drucke ihn und bewahre ihn auf - er öffnet deine Kopien auf einem neuen Gerät', + 'backup.recoveryIntro' => 'Dieser Code öffnet deine gespeicherten Kopien und stellt deine Bank auf jedem Gerät wieder her. Bewahre zwei Papierkopien an sicheren Orten auf, wie dein bestes Saatgut. Jeder, der ihn hat, kann deine Kopien lesen, also teile ihn mit niemandem.', + 'backup.recoveryCopy' => 'Kopieren', + 'backup.recoverySave' => 'Blatt speichern', + 'backup.recoverySheetTitle' => 'Tane - dein Wiederherstellungsblatt', + 'backup.recoveryPromptTitle' => 'Gib deinen Wiederherstellungscode ein', + 'backup.recoveryPromptBody' => 'Diese Kopie wurde mit einem anderen Code gespeichert. Gib den Code von deinem Wiederherstellungsblatt ein, um sie zu öffnen.', + 'backup.recoveryWrongCode' => 'Dieser Code öffnet diese Kopie nicht', + 'about.title' => 'Über', + 'about.kanji' => '種', + 'about.tagline' => 'Eine lokal speichernde, dezentralisierte App zum Verwalten und Teilen traditioneller Samen und Setzlinge.', + 'about.intro' => 'Tane (種, \'Samen\' auf Japanisch) hilft Menschen und Gemeinschaften, eine freundliche Saatgutbank zu führen, zu entscheiden, was sie anbieten, und es lokal zu teilen - ohne einen zentralen Vermittler, der es kontrollieren, zensieren oder dafür bestraft werden könnte. Der Name kommt von tanemaki (種まき), \'säen / Samen verstreuen\'. Das Ziel ist praktisch und politisch zugleich: traditionelle Samensorten unterstützen und gegen das Saatgutmonopol angehen.', + 'about.heritage' => 'Der Name würdigt alte japanische Traditionen der gegenseitigen Hilfe rund um Reis - yui (gemeinsame Gemeinschaftsarbeit) und tanomoshi (Gegenseitigkeitsfonds) - die das Papier Plantare inspiriert haben, die \'Gemeinschaftswährung für Samenaustausch\' (BAH-Semillero, 2009, CC-BY-SA). Tane ist das digitale Plantare.', + 'about.version' => 'Version', + 'about.license' => 'Lizenz', + 'about.licenseValue' => 'AGPL-3.0', + 'about.website' => 'Webseite', + 'about.sourceCode' => 'Quellcode', + 'about.translate' => 'Beim Übersetzen helfen', + 'about.translateSubtitle' => 'Hilf, Tane in deine Sprache zu bringen', + 'about.openSourceLicenses' => 'Open-Source-Lizenzen', + 'about.openSourceLicensesSubtitle' => 'Bibliotheken von Drittanbietern und ihre Lizenzen', + 'about.copyright' => ({required Object years}) => '© ${years} Comunes Association, unter AGPLv3', + 'intro.skip' => 'Überspringen', + 'intro.next' => 'Nächste', + 'intro.start' => 'Anfangen', + 'intro.menuEntry' => 'Wie Tane funktioniert', + 'intro.slides.welcome.title' => 'Der Samen, der dich hierher brachte', + 'intro.slides.welcome.body' => 'Jeder traditionelle Samen ist ein Brief, geschrieben von Tausenden von Generationen, weitergegeben von Hand zu Hand. Wir sind, was wir sind, dank dieses Teilens.', + 'intro.slides.inventory.title' => 'Deine Saatgutbank in deiner Tasche', + 'intro.slides.inventory.body' => 'Notiere, was du hast, von welchem Jahr, wie viel und woher es kam - mit dem Namen, den du verwendest. Ein Foto und ein Name reichen aus, um zu beginnen.', + 'intro.slides.privacy.title' => 'Deins und nur deins', + 'intro.slides.privacy.body' => 'Kein Konto, kein Internet, keine Tracker. Deine Daten leben verschlüsselt auf deinem Gerät, und nur das, das du wählst, wird je geteilt.', + 'intro.slides.share.title' => 'Teilen, wie es immer gemacht wurde', + 'intro.slides.share.body' => 'Biete an, was du übrig hast - Geschenk, Tausch oder Verkauf - und lass jemanden in deiner Nähe es finden. Nur ein ungefährer Abstand wird gezeigt, nie deine Adresse; du schließt das Geschäft von Angesicht zu Angesicht.', + 'intro.slides.plantare.title' => 'Säen ist vervielfältigen', + 'intro.slides.plantare.body' => 'Bei einem Plantare verspricht, wer einen Samen erhält, später etwas zurückzugeben. Und da Zurückgeben bedeutet, es anzubauen, vervielfältigt jede Leihe das Gemeingut.', + 'inventory.title' => 'Bestand', + 'inventory.searchHint' => 'Samen suchen', + 'inventory.empty' => 'Noch keine Samen. Tippe +, um deine erste hinzuzufügen.', + 'inventory.noMatches' => 'Keine Samen passen zu deinen Filtern.', + 'inventory.clearFilters' => 'Filter löschen', + 'inventory.uncategorized' => 'Unkategorisiert', + 'inventory.needsReproductionFilter' => 'Zu vermehren', + 'inventory.loadError' => 'Konnte deine Saatgutbank nicht öffnen. Sie war vielleicht beschäftigt - versuche es erneut.', + 'inventory.retry' => 'Versuchen', + 'draft.capture' => 'Fotos erfassen', + 'draft.captured' => ({required Object n}) => '${n} erfasst zum Katalogisieren', + 'draft.triageTitle' => 'Zum Katalogisieren', + 'draft.triageCount' => ({required Object n}) => '${n} zum Katalogisieren', + 'draft.untitled' => 'Unbenannt', + 'draft.nameField' => 'Benenne diesen Samen', + 'draft.nameHint' => 'Was ist es?', + 'draft.suggestFromPhoto' => 'Namen aus Foto vorschlagen', + 'draft.discard' => 'Verwerfen', + 'quickAdd.title' => 'Einen Samen hinzufügen', + 'quickAdd.labelField' => 'Name', + 'quickAdd.labelRequired' => 'Gib ihm einen Namen', + 'quickAdd.addPhoto' => 'Foto hinzufügen', + 'quickAdd.quantity' => 'Wie viel?', + 'quickAdd.more' => 'Mehr hinzufügen…', + 'quickAdd.save' => 'Speichern', + 'quickAdd.saveAndAddAnother' => 'Speichern und noch einen hinzufügen', + 'quickAdd.addedCount' => ({required Object n}) => '${n} hinzugefügt', + 'quickAdd.cancel' => 'Abbrechen', + 'detail.notFound' => 'Dieser Samen ist nicht mehr hier.', + 'detail.lots' => 'Partien', + 'detail.noLots' => 'Noch keine Partien.', + 'detail.names' => 'Auch bekannt als', + 'detail.addName' => 'Namen hinzufügen', + 'detail.links' => 'Links', + 'detail.addLink' => 'Link hinzufügen', + 'detail.linkUrl' => 'URL', + 'detail.linkTitle' => 'Titel (optional)', + 'detail.reference' => 'Mehr erfahren', + 'detail.refGbif' => 'GBIF', + 'detail.refWikipedia' => 'Wikipedia', + 'detail.refWikispecies' => 'Wikispecies', + 'detail.notes' => 'Notizen', + 'detail.addLot' => 'Partie hinzufügen', + 'detail.editLot' => 'Partie bearbeiten', + 'detail.deleteConfirm' => 'Diesen Samen löschen?', + 'detail.year' => ({required Object year}) => 'Jahr ${year}', + 'detail.noYear' => 'Jahr unbekannt', + 'germination.title' => 'Keimung', + 'germination.add' => 'Test hinzufügen', + 'germination.sampleSize' => 'Stichprobengröße', + 'germination.germinated' => 'Gekeimt', + 'germination.none' => 'Noch keine Keimtests.', + 'germination.result' => ({required Object percent}) => '${percent}%', + 'viability.expiringSoon' => 'Verwende oder vervielfältige diese Saison', + 'viability.expiringSoonYears' => ({required Object years}) => 'Verwende oder vervielfältige diese Saison · hält ~${years} Jahre', + 'viability.expired' => 'Über typische Keimfähigkeit hinaus - zu vervielfältigen', + 'viability.expiredYears' => ({required Object years}) => 'Über typische Keimfähigkeit (~${years} Jahre) - zu vervielfältigen', + 'editVariety.title' => 'Samen bearbeiten', + 'editVariety.name' => 'Name', + 'editVariety.category' => 'Kategorie', + 'editVariety.notes' => 'Notizen', + 'editVariety.species' => 'Art (aus Katalog)', + 'editVariety.speciesHint' => 'Durchsuche eine Art…', + 'editVariety.speciesSuggested' => 'Aus dem Namen vorgeschlagen', + 'editVariety.organic' => 'Bio', + 'editVariety.organicHint' => 'Biologisch angebaut (Öko)', + 'addLot.title' => 'Partie hinzufügen', + 'addLot.year' => 'Erntedate', + 'addLot.quantity' => 'Wie viel?', + 'addLot.amount' => 'Menge', + 'harvest.pickTitle' => 'Wähle Monat / Jahr', + 'harvest.anyMonth' => 'Jeder Monat', + 'harvest.noDate' => 'Erntedate setzen', + 'harvest.monthNames.0' => 'Januar', + 'harvest.monthNames.1' => 'Februar', + 'harvest.monthNames.2' => 'März', + 'harvest.monthNames.3' => 'April', + 'harvest.monthNames.4' => 'Mai', + 'harvest.monthNames.5' => 'Juni', + 'harvest.monthNames.6' => 'Juli', + 'harvest.monthNames.7' => 'August', + 'harvest.monthNames.8' => 'September', + 'harvest.monthNames.9' => 'Oktober', + 'harvest.monthNames.10' => 'November', + 'harvest.monthNames.11' => 'Dezember', + 'lotType.seed' => 'Samen', + 'lotType.plant' => 'Pflanze', + 'lotType.seedling' => 'Setzling', + 'lotType.tree' => 'Baum / Strauch', + 'lotType.bulb' => 'Zwiebel / Knolle', + 'lotType.cutting' => 'Steckling', + 'presentation.title' => 'Verpackung', + 'presentation.none' => 'Unspezifisch', + 'presentation.pot' => 'Topf', + 'presentation.tray' => 'Tablett', + 'presentation.plug' => 'Topfplatte', + 'presentation.bareRoot' => 'Nacktwurzel', + 'presentation.rootBall' => 'Wurzelballen', + 'provenance.section' => 'Woher es kommt', + 'provenance.seedsFrom' => 'Samen von', + 'provenance.seedsFromHint' => 'Wer sie anbaute oder gab', + 'provenance.place' => 'Ort', + 'provenance.placeHint' => 'Woher sie kommen (mit Gegend)', + 'provenance.addSeedsFrom' => 'Samen von', + 'provenance.addPlace' => 'Ort', + 'abundance.add' => 'Wie viel ich habe', + 'abundance.title' => 'Wie viel ich habe', + 'abundance.none' => 'Nicht gesetzt', + 'abundance.plentyToShare' => 'Reichlich zum Teilen', + 'abundance.enoughToShare' => 'Genug zum etwas Teilen', + 'abundance.enoughForMe' => 'Genug für mich', + 'abundance.runningLow' => 'Wird knapp', + 'share.add' => 'Teilst du es?', + 'share.title' => 'Teilst du es?', + 'share.nudge' => 'Du hast reichlich - du könntest etwas teilen.', + 'share.price' => 'Preis', + 'share.priceHint' => 'Lass es leer, um es später zu vereinbaren', + 'share.private' => 'Nur für mich', + 'share.gift' => 'Zum Verschenken', + 'share.exchange' => 'Zum Tauschen', + 'share.sell' => 'Zum Verkauf', + 'share.filterChip' => 'Ich teile', + 'share.printCatalog' => 'Drucke, was ich teile', + 'share.catalogTitle' => 'Was ich teile', + 'share.catalogSaved' => 'Katalog gespeichert', + 'share.cancelled' => 'Abgebrochen', + 'printLabels.action' => 'Etiketten drucken', + 'printLabels.title' => 'Etiketten drucken', + 'printLabels.selectHint' => 'Wähle die Samen, für die du Etiketten drucken möchtest', + 'printLabels.selectAll' => 'Alles auswählen', + 'printLabels.selected' => ({required Object n}) => '${n} ausgewählt', + 'printLabels.none' => 'Wähle zuerst Samen aus', + 'printLabels.format' => 'Etikettengröße', + 'printLabels.formatStickers' => 'Kleine Aufkleber', + 'printLabels.formatStickersHint' => 'Viele kleine Etiketten pro Seite - zum Aufkleben auf Päckchen', + 'printLabels.formatCards' => 'Große Karten', + 'printLabels.formatCardsHint' => 'Weniger, größere Etiketten - für Gläser und Boxen', + 'printLabels.count' => ({required Object n}) => '${n} Etiketten', + 'printLabels.save' => 'Etiketten speichern', + 'printLabels.saved' => 'Etiketten gespeichert', + 'printLabels.cancelled' => 'Abgebrochen', + 'cropCalendar.add' => 'Anbaukalender', + 'cropCalendar.title' => 'Anbaukalender', + 'cropCalendar.sow' => 'Säen', + 'cropCalendar.transplant' => 'Pflanzen', + 'cropCalendar.flowering' => 'Blüte', + 'cropCalendar.fruiting' => 'Fruchtbildung', + 'cropCalendar.seedHarvest' => 'Samenernte', + 'cropCalendar.editorHint' => 'Notiere die typischen Monate für diese Sorte in deinem Gebiet - das sind deine eigenen Notizen.', + 'cropCalendar.unset' => '—', + 'needsReproduction.label' => 'Diese Saison vermehren', + 'needsReproduction.hint' => 'Baue es an, bevor der Samen ausgeht', + 'needsReproduction.badge' => 'Zu vermehren', + 'preservation.add' => 'Wie es aufbewahrt wird', + 'preservation.title' => 'Wie es aufbewahrt wird', + 'preservation.none' => 'Unspezifisch', + 'preservation.jarWithDesiccant' => 'Glas mit Trockenmittel', + 'preservation.glassJar' => 'Glasgefäß', + 'preservation.paperEnvelope' => 'Papierumschlag', + 'preservation.paperBag' => 'Papierbeutel', + 'preservation.plasticBag' => 'Plastikbeutel', + 'conditionCheck.advanced' => 'Lagerung und Saatgutbank-Details', + 'conditionCheck.title' => 'Lagerchecks', + 'conditionCheck.add' => 'Check hinzufügen', + 'conditionCheck.containers' => 'Gläser / Behälter', + 'conditionCheck.desiccant' => 'Trockenmittel', + 'conditionCheck.none' => 'Noch keine Lagerchecks.', + 'conditionCheck.summary' => ({required Object count, required Object state}) => '${count} Glas(gläser) · ${state}', + 'desiccant.none' => 'Keine', + 'desiccant.add' => 'Eine hinzufügen', + 'desiccant.replace' => 'Austauschen', + 'desiccant.dry' => 'Blau - trocken', + 'desiccant.fresh' => 'Gerade erneuert', + 'unit.aFew' => 'einige', + 'unit.some' => 'einige', + 'unit.plenty' => 'viele', + 'unit.pinch' => 'eine Prise', + 'unit.handful.singular' => 'Handvoll', + 'unit.handful.plural' => 'Handvoll', + 'unit.teaspoon.singular' => 'Teelöffel', + 'unit.teaspoon.plural' => 'Teelöffel', + 'unit.spoon.singular' => 'Löffel', + 'unit.spoon.plural' => 'Löffel', + 'unit.cup.singular' => 'Tasse', + 'unit.cup.plural' => 'Tassen', + 'unit.jar.singular' => 'Glas', + 'unit.jar.plural' => 'Gläser', + 'unit.sack.singular' => 'Sack', + 'unit.sack.plural' => 'Säcke', + 'unit.packet.singular' => 'Päckchen', + 'unit.packet.plural' => 'Päckchen', + 'unit.cob.singular' => 'Kolben', + 'unit.cob.plural' => 'Kolben', + 'unit.pod.singular' => 'Schote', + 'unit.pod.plural' => 'Schoten', + 'unit.ear.singular' => 'Ähre', + 'unit.ear.plural' => 'Ähren', + 'unit.head.singular' => 'Kopf', + 'unit.head.plural' => 'Köpfe', + 'unit.fruit.singular' => 'Frucht', + 'unit.fruit.plural' => 'Früchte', + 'unit.bulb.singular' => 'Zwiebel', + 'unit.bulb.plural' => 'Zwiebeln', + 'unit.tuber.singular' => 'Knolle', + 'unit.tuber.plural' => 'Knollen', + 'unit.seedHead.singular' => 'Samenkopf', + 'unit.seedHead.plural' => 'Samenköpfe', + 'unit.bunch.singular' => 'Bund', + 'unit.bunch.plural' => 'Bunde', + 'unit.plant.singular' => 'Pflanze', + 'unit.plant.plural' => 'Pflanzen', + 'unit.pot.singular' => 'Topf', + 'unit.pot.plural' => 'Töpfe', + 'unit.tray.singular' => 'Tablett', + 'unit.tray.plural' => 'Tabletts', + 'unit.seedling.singular' => 'Setzling', + 'unit.seedling.plural' => 'Setzlinge', + 'unit.tree.singular' => 'Baum', + 'unit.tree.plural' => 'Bäume', + 'unit.cutting.singular' => 'Steckling', + 'unit.cutting.plural' => 'Stecklinge', + 'unit.grams.singular' => 'Gramm', + 'unit.grams.plural' => 'Gramm', + 'unit.count.singular' => 'Samen', + 'unit.count.plural' => 'Samen', + 'market.title' => 'Samen in deiner Nähe', + 'market.subtitle' => 'Was andere in der Nähe teilen', + 'market.notSetUp' => 'Teilen ist noch nicht eingerichtet', + 'market.notSetUpBody' => 'Aktiviere Teilen, um Samen zu sehen und mit Menschen in der Nähe zu teilen. Es bleibt ungefähr - deine Zone, nie deine genaue Adresse.', + 'market.setUp' => 'Teilen einrichten', + 'market.cantReach' => 'Kann die Gemeinschaftsserver gerade nicht erreichen', + 'market.cantReachBody' => 'Versuche es erneut, oder überprüfe die Server unter Erweiterte Einrichtung.', + 'market.retry' => 'Erneut versuchen', + 'market.setArea' => 'Stelle dein Gebiet ein', + 'market.setAreaBody' => 'Teile dem Markt ungefähr mit, wo du bist, um Samen in deiner Nähe zu sehen.', + 'market.searching' => 'Schaue nach deinem Gebiet…', + 'market.empty' => 'Noch keine Samen in deiner Nähe geteilt', + 'market.searchHint' => 'Suche nach diesen Samen', + 'market.noMatches' => 'Keine geteilten Samen passen zu deiner Suche', + 'market.near' => 'In deiner Nähe', + 'market.contact' => 'Nachricht', + 'market.mine' => 'Du', + 'market.configTitle' => 'Teilen-Einrichtung', + 'market.setupIntro' => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.', + 'market.areaLabel' => 'Dein Gebiet', + 'market.areaHelp' => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.', + 'market.areaSet' => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt', + 'market.areaNotSet' => 'Gebiet noch nicht gesetzt - verwende deinen Standort oder füge einen Code unter Erweiterte hinzu', + 'market.advanced' => 'Erweiterte', + 'market.areaCodeLabel' => 'Gebiets-Code', + 'market.areaCodeHint' => 'Ein kurzer Code wie sp3e9 - kein Ortsname', + 'market.serversLabel' => 'Gemeinschaftsserver', + 'market.serversHelp' => 'Wähle, welche Server verwendet werden. Lass die Standardwerte, wenn du dir nicht sicher bist.', + 'market.serversAdvanced' => 'Füge einen weiteren Server hinzu', + 'market.serverAddress' => 'Serveradresse', + 'market.serverInvalid' => 'Gib eine gültige Adresse ein (wss://…)', + 'market.save' => 'Speichern', + 'market.saved' => 'Gespeichert', + 'market.wanted' => 'Gesucht', + 'market.shareMine' => 'Meine Samen teilen', + 'market.sharedCount' => ({required Object n}) => '${n} Samen in der Nähe geteilt', + 'market.nothingToShare' => 'Markiere zuerst einige Samen zum Verschenken, Tauschen oder Verkaufen', + 'market.useLocation' => 'Meinen ungefähren Standort verwenden', + 'market.locationFailed' => 'Konnte deinen Standort nicht ermitteln - überprüfe, dass Standort aktiviert ist und die Berechtigung erteilt wurde', + 'market.queued' => 'Gespeichert - wir werden diese teilen, wenn du verbunden bist', + 'market.shareFailed' => 'Konnte die Gemeinschaftsserver nicht erreichen - deine Samen wurden nicht geteilt. Versuche es gleich erneut.', + 'market.rangeLabel' => 'Wie weit zum Suchen', + 'market.rangeNear' => 'Sehr nah', + 'market.rangeArea' => 'Um mich herum', + 'market.rangeRegion' => 'Meine Region', + 'market.sharedBy' => 'Geteilt von', + 'market.noProfile' => 'Diese Person hat noch kein Profil geteilt', + 'market.copyId' => 'Code kopieren', + 'market.idCopied' => 'Code kopiert', + 'market.photo' => 'Foto', + 'profile.title' => 'Dein Profil', + 'profile.name' => 'Anzeigename', + 'profile.nameHint' => 'Wie dich andere sehen', + 'profile.about' => 'Über', + 'profile.aboutHint' => 'Eine kurze Zeile - was du anbaust, wo', + 'profile.g1' => 'Ğ1-Adresse (optional)', + 'profile.g1Hint' => 'Damit dich Leute in Ğ1 bezahlen können - getrennt von deinem Schlüssel', + 'profile.yourId' => 'Deine Identität', + 'profile.idHelp' => 'Teile dies, damit dich Leute erkennen', + 'profile.copy' => 'Kopieren', + 'profile.copied' => 'Kopiert', + 'profile.save' => 'Speichern', + 'profile.saved' => 'Profil gespeichert', + 'profile.identities' => 'Deine Identitäten', + 'profile.identitiesHelp' => 'Halte separate Identitäten - jede mit ihren eigenen Nachrichten und Kontakten. Alle kommen aus deiner einen Sicherung, also Wechsel fügt nichts hinzu, das man sich merken muss.', + 'profile.identityLabel' => ({required Object n}) => 'Identität ${n}', + 'profile.current' => 'In Gebrauch', + 'profile.newIdentity' => 'Neue Identität', + 'profile.switchTitle' => 'Identität wechseln?', + 'profile.switchBody' => 'Nachrichten und Kontakte werden für jede Identität getrennt aufbewahrt. Du kannst jederzeit zurückwechseln.', + 'profile.switchAction' => 'Wechsel', + 'chatList.title' => 'Nachrichten', + 'chatList.empty' => 'Noch keine Gespräche. Schreibe jemanden vom Markt an.', + 'chat.title' => 'Chat', + 'chat.hint' => 'Schreibe eine Nachricht…', + 'chat.send' => 'Senden', + 'chat.empty' => 'Noch keine Nachrichten - sag hallo', + 'chat.offline' => 'Richte Teilen ein, um Nachrichten zu senden', + 'chat.payG1' => 'In Ğ1 bezahlen', + 'chat.g1Copied' => 'Ğ1-Adresse kopiert - füge sie in dein Portemonnaie ein', + 'chat.today' => 'Heute', + 'chat.yesterday' => 'Gestern', + 'chat.sendError' => 'Konnte nicht senden - überprüfe deine Verbindung', + 'chat.noLinks' => 'Links sind in Nachrichten nicht erlaubt', + 'trust.none' => 'Noch niemand bürgt für sie', + 'trust.count' => ({required Object n}) => 'Von ${n} bestätigt', + 'trust.vouch' => 'Ich kenne diese Person', + 'trust.vouched' => 'Du bürgst für sie', + 'trust.circle' => 'In deinem Kreis', + 'yourPeople.title' => 'Deine Leute', + 'yourPeople.help' => 'Menschen, die du kennst und für die du einstehst, und Menschen, die für dich einstehen.', + 'yourPeople.youVouchFor' => 'Du stehst ein für', + 'yourPeople.vouchesForYou' => 'Sie stehen für dich ein', + 'yourPeople.youVouchForEmpty' => 'Du stehst noch für niemanden ein. Wenn du jemanden triffst, öffne deinen Chat mit ihnen und tippe \'Ich kenne diese Person\'.', + 'yourPeople.vouchesForYouEmpty' => 'Noch niemand steht für dich ein', + 'yourPeople.revoke' => 'Nicht mehr einstehen', + 'yourPeople.revokeConfirm' => 'Nicht mehr für diese Person einstehen?', + 'yourPeople.offline' => 'Du bist offline - versuche es, wenn du verbunden bist', + 'ratings.rate' => 'Bewerte diese Person', + 'ratings.edit' => 'Bearbeite deine Bewertung', + 'ratings.commentHint' => 'Wie war es? (optional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} von Leuten, die du kennst', + 'ratings.retract' => 'Entferne deine Bewertung', + 'ratings.saved' => 'Bewertung gespeichert', + 'notifications.newMessageFrom' => ({required Object name}) => 'Neue Nachricht von ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Ein Plantare ist ein Versprechen, einen Samen zu vervielfältigen und etwas zurückzugeben - wie eine Sorte von Hand zu Hand reist. Es ist kein Verkauf.', + 'plantare.add' => 'Verpflichtung hinzufügen', + 'plantare.empty' => 'Noch keine Verpflichtungen. Wenn du einen Samen mit dem Versprechen teilst oder erhältst, ihn anzubauen und etwas zurückzugeben, notiere es hier.', + 'plantare.iReturn' => 'Ich baue es an und gebe es zurück', + 'plantare.owedToMe' => 'Sie geben mir etwas zurück', + 'plantare.direction' => 'Wer baut an und gibt zurück', + 'plantare.counterparty' => 'Mit wem?', + 'plantare.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)', + 'plantare.owed' => 'Was kommt zurück?', + 'plantare.owedHint' => 'z.B. eine Handvoll nächste Saison (optional)', + 'plantare.note' => 'Notiz (optional)', + 'plantare.save' => 'Speichern', + 'plantare.markReturned' => 'Als zurückgegeben markieren', + 'plantare.markForgiven' => 'Absehen lassen', + 'plantare.reopen' => 'Erneut öffnen', + 'plantare.delete' => 'Entfernen', + 'plantare.statusReturned' => 'Zurückgegeben', + 'plantare.statusForgiven' => 'Erledigt', + 'plantare.openSection' => 'Offen', + 'plantare.settledSection' => 'Erledigt', + 'plantare.removeConfirm' => 'Diese Verpflichtung entfernen?', + 'handover.title' => 'Samen wechselten den Besitzer', + 'handover.help' => 'Ein Geschenk, ein Tausch oder ein Verkauf - notiere es auf, mit oder ohne Rückgabeversprechen.', + 'handover.iGave' => 'Ich gab Samen', + 'handover.iReceived' => 'Ich erhielt Samen', + 'handover.whichLot' => 'Welche Partie?', + 'handover.howMuch' => 'Wie viel?', + 'handover.allOfIt' => 'Alles', + 'handover.partOfIt' => 'Ein Teil', + 'handover.paymentChip' => 'Geld wechselte den Besitzer', + 'handover.promiseGave' => 'Sie geben mir Samen zurück', + 'handover.promiseReceived' => 'Ich gebe Samen zurück', + 'sale.title' => 'Verkäufe', + 'sale.help' => 'Notiere Samen, die du verkauft oder gekauft hast - Geld, Ğ1 oder jede Währung. Ein eigenes Modell abseits Geschenk und Plantare. Es wird nie eine Kommission auf Samen erhoben.', + 'sale.add' => 'Verkauf notieren', + 'sale.empty' => 'Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.', + 'sale.iSold' => 'Ich verkaufte', + 'sale.iBought' => 'Ich kaufte', + _ => null, + } ?? switch (path) { + 'sale.direction' => 'Verkauft oder gekauft?', + 'sale.counterparty' => 'Mit wem?', + 'sale.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)', + 'sale.amount' => 'Betrag', + 'sale.currency' => 'Währung', + 'sale.currencyHint' => '€, Ğ1, Stunden… (optional)', + 'sale.hours' => 'Stunden', + 'sale.note' => 'Notiz (optional)', + 'sale.save' => 'Speichern', + 'sale.delete' => 'Entfernen', + 'sale.removeConfirm' => 'Diesen Verkauf entfernen?', + 'legal.title' => 'Datenschutz und Regeln', + 'legal.subtitle' => 'Dein Datenschutz, die Marktregeln und die Legalität von Samen', + 'legal.privacyTitle' => 'Dein Datenschutz', + 'legal.privacyBody' => 'Tane funktioniert ohne Konto, und alles, das du notierst, bleibt auf deinem Gerät, verschlüsselt. Es gibt keine Werbung, keine Tracker und keinen Tane-Server.\n\nNichts wird geteilt, es sei denn, du wählst es: Wenn du ein Angebot oder dein Profil veröffentlichst oder eine Nachricht sendest, reist es durch Gemeinschaftsserver. Angebote tragen nur eine ungefähre Zone - nie deine Adresse.\n\nWas du veröffentlichst, ist öffentlich, und Kopien können bleiben, selbst nachdem du es entfernt hast - teile also mit Bedacht.', + 'legal.rulesTitle' => 'Die Spielregeln', + 'legal.rulesBody' => 'Tane ist ein Werkzeug, kein Laden: Austausch wird direkt zwischen Menschen vereinbart, und niemand nimmt eine Provision. Das bedeutet auch, dass du verantwortlich bist für das, das du anbietest und sendest.\n\nAuf dem Markt: Sei ehrlich mit deinen Samen, biete nur an, was du teilen darfst, behandle Menschen gut und spamme nicht. Du kannst jeden blockieren und Angebote oder Menschen melden, die die Regeln brechen - Meldungen werden auf den Gemeinschaftsservern bearbeitet.', + 'legal.seedsTitle' => 'Über das Teilen von Samen und Setzlingen', + 'legal.seedsBody' => 'Das Verschenken und Tauschen von Samen zwischen Hobbyisten ist in den meisten Ländern weit verbreitet. Verkaufen kann anders sein: An vielen Orten ist der Verkauf von Samen nicht offiziell registrierter Sorten eingeschränkt - überprüfe deine örtlichen Vorschriften, bevor du einen Preis fragst.\n\nSamen in ein anderes Land zu schicken ist auch oft eingeschränkt, und kommerziell geschützte Sorten dürfen nicht ohne Genehmigung vermehrt werden. Im Zweifelsfall lieber lokal und lieber Geschenk.\n\nDasselbe gilt für Setzlinge und Jungpflanzen, aber lebende Pflanzen können strengeren Transport- und Pflanzenschutzregeln unterliegen als Samen — sie über Regionen oder Grenzen zu bewegen kann zusätzliche Kontrollen erfordern.', + 'legal.readFull' => 'Lese die vollständigen Dokumente online', + 'marketGate.title' => 'Bevor du den Markt betrittst', + 'marketGate.intro' => 'Der Markt ist ein gemeinsamer Raum zwischen Nachbarn. Wenn du fortfährst, akzeptierst du einige einfache Regeln:', + 'marketGate.ruleHonest' => 'Sei ehrlich mit den Samen, die du anbietest', + 'marketGate.ruleLegal' => 'Teile nur, was du teilen darfst, wo du lebst', + 'marketGate.ruleRespect' => 'Behandle Menschen gut - kein Spam, kein Missbrauch', + 'marketGate.publicNote' => 'Was du hier veröffentlichst, ist öffentlich, und Kopien können bleiben, selbst wenn du es später entfernst.', + 'marketGate.viewLegal' => 'Datenschutz und Regeln', + 'marketGate.accept' => 'Ich akzeptiere', + 'marketGate.decline' => 'Nicht jetzt', + 'report.offer' => 'Melde dieses Angebot', + 'report.person' => 'Melde diese Person', + 'report.title' => 'Melden', + 'report.prompt' => 'Was ist falsch?', + 'report.reasonSpam' => 'Spam oder Betrug', + 'report.reasonAbuse' => 'Beleidigend oder respektlos', + 'report.reasonIllegal' => 'Samen, die nicht angeboten werden sollten', + 'report.reasonOther' => 'Etwas anderes', + 'report.detailsHint' => 'Füge Details hinzu (optional)', + 'report.send' => 'Meldung senden', + 'report.sentHidden' => 'Meldung gesendet - du wirst dies nicht mehr sehen', + 'report.failed' => 'Konnte die Meldung nicht senden - überprüfe deine Verbindung', + 'report.alsoBlock' => 'Diese Person auch blockieren', + 'block.action' => 'Diese Person blockieren', + 'block.confirmTitle' => 'Diese Person blockieren?', + 'block.confirmBody' => 'Du wirst ihre Angebote und Nachrichten nicht mehr sehen. Du kannst sie später unter Blockierte Leute in der Teilen-Einrichtung entsperren.', + 'block.confirm' => 'Blockieren', + 'block.blockedToast' => 'Blockiert - ihre Angebote und Nachrichten sind verborgen', + 'block.manageTitle' => 'Blockierte Leute', + 'block.manageEmpty' => 'Du hast niemanden blockiert', + 'block.unblock' => 'Entsperren', + _ => null, + }; + } +} diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 5d4adf7..725c1a4 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -40,7 +40,12 @@ class Translations with BaseTranslations { Translations $copyWith({TranslationMetadata? meta}) => Translations(meta: meta ?? this.$meta); // Translations + late final Translations$avatar$en avatar = Translations$avatar$en.internal(_root); + late final Translations$favorites$en favorites = Translations$favorites$en.internal(_root); + late final Translations$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root); + late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root); late final Translations$app$en app = Translations$app$en.internal(_root); + late final Translations$bootstrap$en bootstrap = Translations$bootstrap$en.internal(_root); late final Translations$common$en common = Translations$common$en.internal(_root); late final Translations$home$en home = Translations$home$en.internal(_root); late final Translations$photo$en photo = Translations$photo$en.internal(_root); @@ -63,12 +68,181 @@ class Translations with BaseTranslations { late final Translations$provenance$en provenance = Translations$provenance$en.internal(_root); late final Translations$abundance$en abundance = Translations$abundance$en.internal(_root); late final Translations$share$en share = Translations$share$en.internal(_root); + late final Translations$printLabels$en printLabels = Translations$printLabels$en.internal(_root); late final Translations$cropCalendar$en cropCalendar = Translations$cropCalendar$en.internal(_root); late final Translations$needsReproduction$en needsReproduction = Translations$needsReproduction$en.internal(_root); late final Translations$preservation$en preservation = Translations$preservation$en.internal(_root); late final Translations$conditionCheck$en conditionCheck = Translations$conditionCheck$en.internal(_root); late final Translations$desiccant$en desiccant = Translations$desiccant$en.internal(_root); late final Translations$unit$en unit = Translations$unit$en.internal(_root); + late final Translations$market$en market = Translations$market$en.internal(_root); + late final Translations$profile$en profile = Translations$profile$en.internal(_root); + late final Translations$chatList$en chatList = Translations$chatList$en.internal(_root); + late final Translations$chat$en chat = Translations$chat$en.internal(_root); + late final Translations$trust$en trust = Translations$trust$en.internal(_root); + late final Translations$yourPeople$en yourPeople = Translations$yourPeople$en.internal(_root); + late final Translations$ratings$en ratings = Translations$ratings$en.internal(_root); + late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root); + late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root); + late final Translations$handover$en handover = Translations$handover$en.internal(_root); + late final Translations$sale$en sale = Translations$sale$en.internal(_root); + late final Translations$legal$en legal = Translations$legal$en.internal(_root); + late final Translations$marketGate$en marketGate = Translations$marketGate$en.internal(_root); + late final Translations$report$en report = Translations$report$en.internal(_root); + late final Translations$block$en block = Translations$block$en.internal(_root); +} + +// Path: avatar +class Translations$avatar$en { + Translations$avatar$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Your photo or avatar' + String get title => 'Your photo or avatar'; + + /// en: 'Take or choose a photo' + String get fromPhoto => 'Take or choose a photo'; + + /// en: 'Or pick an illustration' + String get illustration => 'Or pick an illustration'; + + /// en: 'Remove' + String get remove => 'Remove'; +} + +// Path: favorites +class Translations$favorites$en { + Translations$favorites$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Favorites' + String get title => 'Favorites'; + + /// en: 'No favorites yet. Save offers you like from the market.' + String get empty => 'No favorites yet. Save offers you like from the market.'; + + /// en: 'Save to favorites' + String get save => 'Save to favorites'; + + /// en: 'Remove from favorites' + String get remove => 'Remove from favorites'; + + /// en: 'No longer available' + String get unavailable => 'No longer available'; +} + +// Path: seedSaving +class Translations$seedSaving$en { + Translations$seedSaving$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Saving its seed' + String get title => 'Saving its seed'; + + /// en: 'What it takes to keep the variety true' + String get subtitle => 'What it takes to keep the variety true'; + + /// en: 'Cycle' + String get lifeCycle => 'Cycle'; + + /// en: 'Annual' + String get cycleAnnual => 'Annual'; + + /// en: 'Biennial — seeds in year 2' + String get cycleBiennial => 'Biennial — seeds in year 2'; + + /// en: 'Perennial' + String get cyclePerennial => 'Perennial'; + + /// en: 'Pollination' + String get pollination => 'Pollination'; + + /// en: 'Self-pollinating' + String get pollSelf => 'Self-pollinating'; + + /// en: 'Crosses with others' + String get pollCross => 'Crosses with others'; + + /// en: 'Self-pollinates, sometimes crosses' + String get pollMixed => 'Self-pollinates, sometimes crosses'; + + /// en: 'by insects' + String get byInsect => 'by insects'; + + /// en: 'on the wind' + String get byWind => 'on the wind'; + + /// en: 'Keep apart' + String get isolation => 'Keep apart'; + + /// en: '{min}–{max} m from other varieties' + String isolationRange({required Object min, required Object max}) => '${min}–${max} m from other varieties'; + + /// en: '{min} m from other varieties' + String isolationSingle({required Object min}) => '${min} m from other varieties'; + + /// en: 'Save from several plants' + String get plants => 'Save from several plants'; + + /// en: 'From at least {n} plants' + String plantsValue({required Object n}) => 'From at least ${n} plants'; + + /// en: 'Cleaning the seed' + String get processing => 'Cleaning the seed'; + + /// en: 'Dry seed (thresh)' + String get procDry => 'Dry seed (thresh)'; + + /// en: 'Wet seed (ferment and rinse)' + String get procWet => 'Wet seed (ferment and rinse)'; + + /// en: 'Difficulty' + String get difficulty => 'Difficulty'; + + /// en: 'Easy' + String get diffEasy => 'Easy'; + + /// en: 'Medium' + String get diffMedium => 'Medium'; + + /// en: 'Hard' + String get diffHard => 'Hard'; + + /// en: 'General guidance — adapt it to your climate and variety.' + String get advisory => 'General guidance — adapt it to your climate and variety.'; + + /// en: 'Source' + String get sourcePrefix => 'Source'; +} + +// Path: calendar +class Translations$calendar$en { + Translations$calendar$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'This month' + String get title => 'This month'; + + /// en: 'This month' + String get filterChip => 'This month'; + + /// en: 'What you've noted in your varieties.' + String get selfNote => 'What you\'ve noted in your varieties.'; + + /// en: 'Nothing noted for {month}.' + String nothing({required Object month}) => 'Nothing noted for ${month}.'; } // Path: app @@ -79,8 +253,23 @@ class Translations$app$en { // Translations - /// en: 'Tanemaki' - String get title => 'Tanemaki'; + /// en: 'Tane' + String get title => 'Tane'; +} + +// Path: bootstrap +class Translations$bootstrap$en { + Translations$bootstrap$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Tane couldn't start' + String get failed => 'Tane couldn\'t start'; + + /// en: 'Try again' + String get retry => 'Try again'; } // Path: common @@ -108,6 +297,9 @@ class Translations$common$en { /// en: 'Coming soon' String get comingSoon => 'Coming soon'; + + /// en: 'You're offline — sharing is paused' + String get offline => 'You\'re offline — sharing is paused'; } // Path: home @@ -121,11 +313,11 @@ class Translations$home$en { /// en: 'Share and grow local seeds' String get tagline => 'Share and grow local seeds'; - /// en: 'Open market' - String get openMarket => 'Open market'; + /// en: 'Market' + String get openMarket => 'Market'; - /// en: 'Browse and exchange' - String get openMarketSubtitle => 'Browse and exchange'; + /// en: 'Discover and share seeds nearby' + String get openMarketSubtitle => 'Discover and share seeds nearby'; /// en: 'Your inventory' String get yourInventory => 'Your inventory'; @@ -181,12 +373,21 @@ class Translations$menu$en { /// en: 'Chat' String get chat => 'Chat'; - /// en: 'Wishlist' - String get wishlist => 'Wishlist'; + /// en: 'Favorites' + String get wishlist => 'Favorites'; /// en: 'Following' String get following => 'Following'; + /// en: 'Plantares' + String get plantares => 'Plantares'; + + /// en: 'Sales' + String get sales => 'Sales'; + + /// en: 'Calendar' + String get calendar => 'Calendar'; + /// en: 'Settings' String get settings => 'Settings'; } @@ -214,14 +415,26 @@ class Translations$settings$en { /// en: 'Português' String get langPt => 'Português'; + /// en: 'Asturianu' + String get langAst => 'Asturianu'; + + /// en: 'Français' + String get langFr => 'Français'; + + /// en: 'Deutsch' + String get langDe => 'Deutsch'; + + /// en: '日本語' + String get langJa => '日本語'; + /// en: 'About' String get about => 'About'; /// en: 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.' String get aboutText => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.'; - /// en: 'About Tanemaki' - String get aboutOpen => 'About Tanemaki'; + /// en: 'About Tane' + String get aboutOpen => 'About Tane'; } // Path: backup @@ -235,6 +448,15 @@ class Translations$backup$en { /// en: 'Backup & restore' String get title => 'Backup & restore'; + /// en: 'Automatic backups' + String get autoBackupTitle => 'Automatic backups'; + + /// en: 'Last copy saved {date} · every {days} days' + String autoBackupLast({required Object date, required Object days}) => 'Last copy saved ${date} · every ${days} days'; + + /// en: 'A copy is kept automatically every {days} days' + String autoBackupNone({required Object days}) => 'A copy is kept automatically every ${days} days'; + /// en: 'Save a backup' String get exportJson => 'Save a backup'; @@ -286,8 +508,8 @@ class Translations$backup$en { /// en: 'Added {count} entries' String importCsvDone({required Object count}) => 'Added ${count} entries'; - /// en: 'This file could not be read as a Tanemaki copy' - String get importFailed => 'This file could not be read as a Tanemaki copy'; + /// en: 'This file could not be read as a Tane copy' + String get importFailed => 'This file could not be read as a Tane copy'; /// en: 'Something went wrong' String get failed => 'Something went wrong'; @@ -307,8 +529,8 @@ class Translations$backup$en { /// en: 'Save the sheet' String get recoverySave => 'Save the sheet'; - /// en: 'Tanemaki — your recovery sheet' - String get recoverySheetTitle => 'Tanemaki — your recovery sheet'; + /// en: 'Tane — your recovery sheet' + String get recoverySheetTitle => 'Tane — your recovery sheet'; /// en: 'Enter your recovery code' String get recoveryPromptTitle => 'Enter your recovery code'; @@ -331,17 +553,17 @@ class Translations$about$en { /// en: 'About' String get title => 'About'; - /// en: '種まき' - String get kanji => '種まき'; + /// en: '種' + String get kanji => '種'; /// en: 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.' String get tagline => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.'; - /// en: 'Tanemaki (種まき, "to sow / scatter seeds" in Japanese; short name Tane, 種 "seed") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.' - String get intro => 'Tanemaki (種まき, "to sow / scatter seeds" in Japanese; short name Tane, 種 "seed") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.'; + /// en: 'Tane (種, "seed" in Japanese) helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. Its name comes from tanemaki (種まき), "to sow / scatter seeds". The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.' + String get intro => 'Tane (種, "seed" in Japanese) helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. Its name comes from tanemaki (種まき), "to sow / scatter seeds". The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.'; - /// en: 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare.' - String get heritage => 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare.'; + /// en: 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tane is the digital Plantare.' + String get heritage => 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tane is the digital Plantare.'; /// en: 'Version' String get version => 'Version'; @@ -355,6 +577,15 @@ class Translations$about$en { /// en: 'Website' String get website => 'Website'; + /// en: 'Source code' + String get sourceCode => 'Source code'; + + /// en: 'Help translate' + String get translate => 'Help translate'; + + /// en: 'Help bring Tane to your language' + String get translateSubtitle => 'Help bring Tane to your language'; + /// en: 'Open-source licenses' String get openSourceLicenses => 'Open-source licenses'; @@ -382,8 +613,8 @@ class Translations$intro$en { /// en: 'Get started' String get start => 'Get started'; - /// en: 'How Tanemaki works' - String get menuEntry => 'How Tanemaki works'; + /// en: 'How Tane works' + String get menuEntry => 'How Tane works'; late final Translations$intro$slides$en slides = Translations$intro$slides$en.internal(_root); } @@ -416,6 +647,12 @@ class Translations$inventory$en { /// en: 'To regrow' String get needsReproductionFilter => 'To regrow'; + + /// en: 'Couldn't open your seed bank. It may just have been busy — try again.' + String get loadError => 'Couldn\'t open your seed bank. It may just have been busy — try again.'; + + /// en: 'Try again' + String get retry => 'Try again'; } // Path: draft @@ -831,6 +1068,12 @@ class Translations$share$en { /// en: 'You have plenty — you could share some.' String get nudge => 'You have plenty — you could share some.'; + /// en: 'Price' + String get price => 'Price'; + + /// en: 'Leave it empty to agree it later' + String get priceHint => 'Leave it empty to agree it later'; + /// en: 'Just for me' String get private => 'Just for me'; @@ -859,6 +1102,60 @@ class Translations$share$en { String get cancelled => 'Cancelled'; } +// Path: printLabels +class Translations$printLabels$en { + Translations$printLabels$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Print labels' + String get action => 'Print labels'; + + /// en: 'Print labels' + String get title => 'Print labels'; + + /// en: 'Pick the seeds to print labels for' + String get selectHint => 'Pick the seeds to print labels for'; + + /// en: 'Select all' + String get selectAll => 'Select all'; + + /// en: '{n} selected' + String selected({required Object n}) => '${n} selected'; + + /// en: 'Select seeds first' + String get none => 'Select seeds first'; + + /// en: 'Label size' + String get format => 'Label size'; + + /// en: 'Small stickers' + String get formatStickers => 'Small stickers'; + + /// en: 'Many small labels per page — to peel onto packets' + String get formatStickersHint => 'Many small labels per page — to peel onto packets'; + + /// en: 'Large cards' + String get formatCards => 'Large cards'; + + /// en: 'Fewer, bigger labels — for jars and boxes' + String get formatCardsHint => 'Fewer, bigger labels — for jars and boxes'; + + /// en: '{n} labels' + String count({required Object n}) => '${n} labels'; + + /// en: 'Save labels' + String get save => 'Save labels'; + + /// en: 'Labels saved' + String get saved => 'Labels saved'; + + /// en: 'Cancelled' + String get cancelled => 'Cancelled'; +} + // Path: cropCalendar class Translations$cropCalendar$en { Translations$cropCalendar$en.internal(this._root); @@ -888,6 +1185,9 @@ class Translations$cropCalendar$en { /// en: 'Seed harvest' String get seedHarvest => 'Seed harvest'; + /// en: 'Note the months typical for this variety in your area — these are your own notes.' + String get editorHint => 'Note the months typical for this variety in your area — these are your own notes.'; + /// en: '—' String get unset => '—'; } @@ -1043,6 +1343,813 @@ class Translations$unit$en { late final Translations$unit$count$en count = Translations$unit$count$en.internal(_root); } +// Path: market +class Translations$market$en { + Translations$market$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Seeds near you' + String get title => 'Seeds near you'; + + /// en: 'What others are sharing nearby' + String get subtitle => 'What others are sharing nearby'; + + /// en: 'Sharing isn't set up yet' + String get notSetUp => 'Sharing isn\'t set up yet'; + + /// en: 'Turn on sharing to see and give seeds to people near you. It stays rough — your zone, never your exact address.' + String get notSetUpBody => 'Turn on sharing to see and give seeds to people near you. It stays rough — your zone, never your exact address.'; + + /// en: 'Set up sharing' + String get setUp => 'Set up sharing'; + + /// en: 'Can't reach the community servers right now' + String get cantReach => 'Can\'t reach the community servers right now'; + + /// en: 'Try again, or check the servers under Advanced setup.' + String get cantReachBody => 'Try again, or check the servers under Advanced setup.'; + + /// en: 'Retry' + String get retry => 'Retry'; + + /// en: 'Set your area' + String get setArea => 'Set your area'; + + /// en: 'Tell the market roughly where you are to see seeds nearby.' + String get setAreaBody => 'Tell the market roughly where you are to see seeds nearby.'; + + /// en: 'Looking around your area…' + String get searching => 'Looking around your area…'; + + /// en: 'No seeds shared near you yet' + String get empty => 'No seeds shared near you yet'; + + /// en: 'Search these seeds' + String get searchHint => 'Search these seeds'; + + /// en: 'No shared seeds match your search' + String get noMatches => 'No shared seeds match your search'; + + /// en: 'Near you' + String get near => 'Near you'; + + /// en: 'Message' + String get contact => 'Message'; + + /// en: 'You' + String get mine => 'You'; + + /// en: 'Sharing setup' + String get configTitle => 'Sharing setup'; + + /// en: 'Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.' + String get setupIntro => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.'; + + /// en: 'Your area' + String get areaLabel => 'Your area'; + + /// en: 'Kept rough on purpose — your zone, never an exact spot.' + String get areaHelp => 'Kept rough on purpose — your zone, never an exact spot.'; + + /// en: 'Your area is set — kept coarse, never your exact spot' + String get areaSet => 'Your area is set — kept coarse, never your exact spot'; + + /// en: 'Area not set yet — use your location, or add a code under Advanced' + String get areaNotSet => 'Area not set yet — use your location, or add a code under Advanced'; + + /// en: 'Advanced' + String get advanced => 'Advanced'; + + /// en: 'Area code' + String get areaCodeLabel => 'Area code'; + + /// en: 'A short code like sp3e9 — not a place name' + String get areaCodeHint => 'A short code like sp3e9 — not a place name'; + + /// en: 'Community servers' + String get serversLabel => 'Community servers'; + + /// en: 'Choose which servers to use. Leave the defaults if unsure.' + String get serversHelp => 'Choose which servers to use. Leave the defaults if unsure.'; + + /// en: 'Add another server' + String get serversAdvanced => 'Add another server'; + + /// en: 'Server address' + String get serverAddress => 'Server address'; + + /// en: 'Enter a valid address (wss://…)' + String get serverInvalid => 'Enter a valid address (wss://…)'; + + /// en: 'Save' + String get save => 'Save'; + + /// en: 'Saved' + String get saved => 'Saved'; + + /// en: 'Wanted' + String get wanted => 'Wanted'; + + /// en: 'Share my seeds' + String get shareMine => 'Share my seeds'; + + /// en: 'Shared {n} seeds nearby' + String sharedCount({required Object n}) => 'Shared ${n} seeds nearby'; + + /// en: 'Mark some seeds to give, swap or sell first' + String get nothingToShare => 'Mark some seeds to give, swap or sell first'; + + /// en: 'Use my approximate location' + String get useLocation => 'Use my approximate location'; + + /// en: 'Couldn't get your location — check that location is on and the permission is granted' + String get locationFailed => 'Couldn\'t get your location — check that location is on and the permission is granted'; + + /// en: 'Saved — we'll share these when you're connected' + String get queued => 'Saved — we\'ll share these when you\'re connected'; + + /// en: 'Couldn't reach the community servers — your seeds weren't shared. Try again in a moment.' + String get shareFailed => 'Couldn\'t reach the community servers — your seeds weren\'t shared. Try again in a moment.'; + + /// en: 'How far to look' + String get rangeLabel => 'How far to look'; + + /// en: 'Very close' + String get rangeNear => 'Very close'; + + /// en: 'Around here' + String get rangeArea => 'Around here'; + + /// en: 'My region' + String get rangeRegion => 'My region'; + + /// en: 'Shared by' + String get sharedBy => 'Shared by'; + + /// en: 'This person hasn't shared a profile yet' + String get noProfile => 'This person hasn\'t shared a profile yet'; + + /// en: 'Copy code' + String get copyId => 'Copy code'; + + /// en: 'Code copied' + String get idCopied => 'Code copied'; + + /// en: 'Photo' + String get photo => 'Photo'; +} + +// Path: profile +class Translations$profile$en { + Translations$profile$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Your profile' + String get title => 'Your profile'; + + /// en: 'Display name' + String get name => 'Display name'; + + /// en: 'How others see you' + String get nameHint => 'How others see you'; + + /// en: 'About' + String get about => 'About'; + + /// en: 'A short line — what you grow, where' + String get aboutHint => 'A short line — what you grow, where'; + + /// en: 'Ğ1 address (optional)' + String get g1 => 'Ğ1 address (optional)'; + + /// en: 'So people can pay you in Ğ1 — separate from your key' + String get g1Hint => 'So people can pay you in Ğ1 — separate from your key'; + + /// en: 'Your identity' + String get yourId => 'Your identity'; + + /// en: 'Share this so people can recognise you' + String get idHelp => 'Share this so people can recognise you'; + + /// en: 'Copy' + String get copy => 'Copy'; + + /// en: 'Copied' + String get copied => 'Copied'; + + /// en: 'Save' + String get save => 'Save'; + + /// en: 'Profile saved' + String get saved => 'Profile saved'; + + /// en: 'Your identities' + String get identities => 'Your identities'; + + /// en: 'Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.' + String get identitiesHelp => 'Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.'; + + /// en: 'Identity {n}' + String identityLabel({required Object n}) => 'Identity ${n}'; + + /// en: 'In use' + String get current => 'In use'; + + /// en: 'New identity' + String get newIdentity => 'New identity'; + + /// en: 'Switch identity?' + String get switchTitle => 'Switch identity?'; + + /// en: 'Messages and contacts are kept separate for each identity. You can switch back anytime.' + String get switchBody => 'Messages and contacts are kept separate for each identity. You can switch back anytime.'; + + /// en: 'Switch' + String get switchAction => 'Switch'; +} + +// Path: chatList +class Translations$chatList$en { + Translations$chatList$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Messages' + String get title => 'Messages'; + + /// en: 'No conversations yet. Message someone from the market.' + String get empty => 'No conversations yet. Message someone from the market.'; +} + +// Path: chat +class Translations$chat$en { + Translations$chat$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Chat' + String get title => 'Chat'; + + /// en: 'Write a message…' + String get hint => 'Write a message…'; + + /// en: 'Send' + String get send => 'Send'; + + /// en: 'No messages yet — say hello' + String get empty => 'No messages yet — say hello'; + + /// en: 'Set up sharing to send messages' + String get offline => 'Set up sharing to send messages'; + + /// en: 'Pay in Ğ1' + String get payG1 => 'Pay in Ğ1'; + + /// en: 'Ğ1 address copied — paste it in your wallet' + String get g1Copied => 'Ğ1 address copied — paste it in your wallet'; + + /// en: 'Today' + String get today => 'Today'; + + /// en: 'Yesterday' + String get yesterday => 'Yesterday'; + + /// en: 'Couldn't send — check your connection' + String get sendError => 'Couldn\'t send — check your connection'; + + /// en: 'Links aren't allowed in messages' + String get noLinks => 'Links aren\'t allowed in messages'; +} + +// Path: trust +class Translations$trust$en { + Translations$trust$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'No one vouches for them yet' + String get none => 'No one vouches for them yet'; + + /// en: 'Vouched for by {n}' + String count({required Object n}) => 'Vouched for by ${n}'; + + /// en: 'I know this person' + String get vouch => 'I know this person'; + + /// en: 'You vouch for them' + String get vouched => 'You vouch for them'; + + /// en: 'In your circle' + String get circle => 'In your circle'; +} + +// Path: yourPeople +class Translations$yourPeople$en { + Translations$yourPeople$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Your people' + String get title => 'Your people'; + + /// en: 'People you've met and vouch for, and people who vouch for you.' + String get help => 'People you\'ve met and vouch for, and people who vouch for you.'; + + /// en: 'You vouch for' + String get youVouchFor => 'You vouch for'; + + /// en: 'They vouch for you' + String get vouchesForYou => 'They vouch for you'; + + /// en: 'You don't vouch for anyone yet. When you meet someone, open your chat with them and tap "I know this person".' + String get youVouchForEmpty => 'You don\'t vouch for anyone yet. When you meet someone, open your chat with them and tap "I know this person".'; + + /// en: 'No one vouches for you yet' + String get vouchesForYouEmpty => 'No one vouches for you yet'; + + /// en: 'Stop vouching' + String get revoke => 'Stop vouching'; + + /// en: 'Stop vouching for this person?' + String get revokeConfirm => 'Stop vouching for this person?'; + + /// en: 'You're offline — try again when you're connected' + String get offline => 'You\'re offline — try again when you\'re connected'; +} + +// Path: ratings +class Translations$ratings$en { + Translations$ratings$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Rate this person' + String get rate => 'Rate this person'; + + /// en: 'Edit your rating' + String get edit => 'Edit your rating'; + + /// en: 'How did it go? (optional)' + String get commentHint => 'How did it go? (optional)'; + + /// en: '{n} from people you know' + String fromYourCircle({required Object n}) => '${n} from people you know'; + + /// en: 'Remove your rating' + String get retract => 'Remove your rating'; + + /// en: 'Rating saved' + String get saved => 'Rating saved'; +} + +// Path: notifications +class Translations$notifications$en { + Translations$notifications$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'New message from {name}' + String newMessageFrom({required Object name}) => 'New message from ${name}'; +} + +// Path: plantare +class Translations$plantare$en { + Translations$plantare$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Plantares' + String get title => 'Plantares'; + + /// en: 'A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It's not a sale.' + String get help => 'A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It\'s not a sale.'; + + /// en: 'Add a commitment' + String get add => 'Add a commitment'; + + /// en: 'No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.' + String get empty => 'No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.'; + + /// en: 'I'll grow it out & return some' + String get iReturn => 'I\'ll grow it out & return some'; + + /// en: 'They'll return some to me' + String get owedToMe => 'They\'ll return some to me'; + + /// en: 'Who reproduces & returns' + String get direction => 'Who reproduces & returns'; + + /// en: 'With whom?' + String get counterparty => 'With whom?'; + + /// en: 'A person or a collective (optional)' + String get counterpartyHint => 'A person or a collective (optional)'; + + /// en: 'What comes back?' + String get owed => 'What comes back?'; + + /// en: 'e.g. a handful next season (optional)' + String get owedHint => 'e.g. a handful next season (optional)'; + + /// en: 'Note (optional)' + String get note => 'Note (optional)'; + + /// en: 'Save' + String get save => 'Save'; + + /// en: 'Mark returned' + String get markReturned => 'Mark returned'; + + /// en: 'Let it go' + String get markForgiven => 'Let it go'; + + /// en: 'Reopen' + String get reopen => 'Reopen'; + + /// en: 'Remove' + String get delete => 'Remove'; + + /// en: 'Returned' + String get statusReturned => 'Returned'; + + /// en: 'Settled' + String get statusForgiven => 'Settled'; + + /// en: 'Open' + String get openSection => 'Open'; + + /// en: 'Done' + String get settledSection => 'Done'; + + /// en: 'Remove this commitment?' + String get removeConfirm => 'Remove this commitment?'; + + /// en: 'Return by {date}' + String returnBy({required Object date}) => 'Return by ${date}'; + + /// en: 'overdue' + String get overdue => 'overdue'; + + /// en: 'Return by (optional)' + String get dueByLabel => 'Return by (optional)'; + + /// en: 'A gentle reminder, never enforced' + String get dueByHint => 'A gentle reminder, never enforced'; + + /// en: 'Pick a date' + String get pickDate => 'Pick a date'; + + /// en: 'Clear date' + String get clearDate => 'Clear date'; + + /// en: 'Commitments' + String get sectionTitle => 'Commitments'; + + /// en: 'Propose a signed Plantaré' + String get propose => 'Propose a signed Plantaré'; + + /// en: 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.' + String get proposeHelp => 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.'; + + /// en: 'With {name}' + String proposeTo({required Object name}) => 'With ${name}'; + + /// en: 'Proposal sent — waiting for them to sign' + String get sent => 'Proposal sent — waiting for them to sign'; + + /// en: 'Which seed?' + String get seedLabel => 'Which seed?'; + + /// en: 'The variety this promise is about' + String get seedHint => 'The variety this promise is about'; + + /// en: 'What comes back?' + String get returnKindLabel => 'What comes back?'; + + /// en: 'A similar amount of seed' + String get returnSimilar => 'A similar amount of seed'; + + /// en: 'open-pollinated · non-GMO · organically grown' + String get returnSimilarNote => 'open-pollinated · non-GMO · organically grown'; + + /// en: 'Some hours of work' + String get returnWork => 'Some hours of work'; + + /// en: 'Something else' + String get returnOther => 'Something else'; + + /// en: 'How many hours?' + String get workHoursLabel => 'How many hours?'; + + /// en: 'Waiting for your reply' + String get proposalsSection => 'Waiting for your reply'; + + /// en: '{name} proposes a Plantaré' + String incomingFrom({required Object name}) => '${name} proposes a Plantaré'; + + /// en: 'Accept & sign' + String get accept => 'Accept & sign'; + + /// en: 'Decline' + String get declineAction => 'Decline'; + + /// en: 'Reason (optional)' + String get declineReasonHint => 'Reason (optional)'; + + /// en: 'Signed — you both hold it now' + String get acceptedToast => 'Signed — you both hold it now'; + + /// en: 'Declined' + String get declinedToast => 'Declined'; + + /// en: 'Awaiting signature' + String get badgeAwaiting => 'Awaiting signature'; + + /// en: 'Signed by both' + String get badgeSigned => 'Signed by both'; + + /// en: 'Declined' + String get badgeDeclined => 'Declined'; + + /// en: 'You're offline — it'll be sent when you reconnect' + String get offline => 'You\'re offline — it\'ll be sent when you reconnect'; +} + +// Path: handover +class Translations$handover$en { + Translations$handover$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Seeds changed hands' + String get title => 'Seeds changed hands'; + + /// en: 'A gift, a swap or a sale — note it down, with a return promise or without.' + String get help => 'A gift, a swap or a sale — note it down, with a return promise or without.'; + + /// en: 'I gave seeds' + String get iGave => 'I gave seeds'; + + /// en: 'I received seeds' + String get iReceived => 'I received seeds'; + + /// en: 'Which batch?' + String get whichLot => 'Which batch?'; + + /// en: 'How much?' + String get howMuch => 'How much?'; + + /// en: 'All of it' + String get allOfIt => 'All of it'; + + /// en: 'A part' + String get partOfIt => 'A part'; + + /// en: 'Money changed hands' + String get paymentChip => 'Money changed hands'; + + /// en: 'They'll return me seed' + String get promiseGave => 'They\'ll return me seed'; + + /// en: 'I'll return seed' + String get promiseReceived => 'I\'ll return seed'; +} + +// Path: sale +class Translations$sale$en { + Translations$sale$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Sales' + String get title => 'Sales'; + + /// en: 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.' + String get help => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.'; + + /// en: 'Record a sale' + String get add => 'Record a sale'; + + /// en: 'No sales yet. Note here what you sell or buy.' + String get empty => 'No sales yet. Note here what you sell or buy.'; + + /// en: 'I sold' + String get iSold => 'I sold'; + + /// en: 'I bought' + String get iBought => 'I bought'; + + /// en: 'Sold or bought?' + String get direction => 'Sold or bought?'; + + /// en: 'With whom?' + String get counterparty => 'With whom?'; + + /// en: 'A person or a collective (optional)' + String get counterpartyHint => 'A person or a collective (optional)'; + + /// en: 'Amount' + String get amount => 'Amount'; + + /// en: 'Currency' + String get currency => 'Currency'; + + /// en: '€, Ğ1, hours… (optional)' + String get currencyHint => '€, Ğ1, hours… (optional)'; + + /// en: 'hours' + String get hours => 'hours'; + + /// en: 'Note (optional)' + String get note => 'Note (optional)'; + + /// en: 'Save' + String get save => 'Save'; + + /// en: 'Remove' + String get delete => 'Remove'; + + /// en: 'Remove this sale?' + String get removeConfirm => 'Remove this sale?'; +} + +// Path: legal +class Translations$legal$en { + Translations$legal$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Privacy & rules' + String get title => 'Privacy & rules'; + + /// en: 'Your privacy, the market rules, and sharing seeds legally' + String get subtitle => 'Your privacy, the market rules, and sharing seeds legally'; + + /// en: 'Your privacy' + String get privacyTitle => 'Your privacy'; + + /// en: 'Tane works without an account, and everything you record stays on your device, encrypted. There are no ads, no trackers, and no Tane server. Nothing is shared unless you choose to: when you publish an offer or your profile, or send a message, it travels through community-run servers. Offers only ever carry a rough zone — never your address. What you publish is public, and copies may remain even after you remove it — so share with care.' + String get privacyBody => 'Tane works without an account, and everything you record stays on your device, encrypted. There are no ads, no trackers, and no Tane server.\n\nNothing is shared unless you choose to: when you publish an offer or your profile, or send a message, it travels through community-run servers. Offers only ever carry a rough zone — never your address.\n\nWhat you publish is public, and copies may remain even after you remove it — so share with care.'; + + /// en: 'Rules of the road' + String get rulesTitle => 'Rules of the road'; + + /// en: 'Tane is a tool, not a shop: exchanges are agreed directly between people, and nobody takes a cut. That also means you are responsible for what you offer and send. In the market: be honest about your seeds, only offer what you may share, treat people well, and don't spam. You can block anyone, and report offers or people that break the rules — reports are acted on in the community servers.' + String get rulesBody => 'Tane is a tool, not a shop: exchanges are agreed directly between people, and nobody takes a cut. That also means you are responsible for what you offer and send.\n\nIn the market: be honest about your seeds, only offer what you may share, treat people well, and don\'t spam. You can block anyone, and report offers or people that break the rules — reports are acted on in the community servers.'; + + /// en: 'About sharing seeds and seedlings' + String get seedsTitle => 'About sharing seeds and seedlings'; + + /// en: 'Gifting and swapping seeds between amateurs is broadly recognized in most countries. Selling can be different: in many places, selling seed of varieties that aren't officially registered is restricted — check your local rules before asking a price. Sending seeds to another country is often restricted too, and commercially protected varieties must not be propagated without permission. When in doubt, keep it local and keep it a gift. The same spirit applies to seedlings and young plants, but live plants can carry stricter transport and plant-health rules than seeds — moving them across regions or borders may need extra checks.' + String get seedsBody => 'Gifting and swapping seeds between amateurs is broadly recognized in most countries. Selling can be different: in many places, selling seed of varieties that aren\'t officially registered is restricted — check your local rules before asking a price.\n\nSending seeds to another country is often restricted too, and commercially protected varieties must not be propagated without permission. When in doubt, keep it local and keep it a gift.\n\nThe same spirit applies to seedlings and young plants, but live plants can carry stricter transport and plant-health rules than seeds — moving them across regions or borders may need extra checks.'; + + /// en: 'Read the full documents online' + String get readFull => 'Read the full documents online'; +} + +// Path: marketGate +class Translations$marketGate$en { + Translations$marketGate$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Before you join the market' + String get title => 'Before you join the market'; + + /// en: 'The market is a shared space between neighbours. By continuing, you agree to a few simple rules:' + String get intro => 'The market is a shared space between neighbours. By continuing, you agree to a few simple rules:'; + + /// en: 'Be honest about the seeds you offer' + String get ruleHonest => 'Be honest about the seeds you offer'; + + /// en: 'Only share what you're allowed to share where you live' + String get ruleLegal => 'Only share what you\'re allowed to share where you live'; + + /// en: 'Treat people well — no spam, no abuse' + String get ruleRespect => 'Treat people well — no spam, no abuse'; + + /// en: 'What you publish here is public, and copies may remain even if you remove it later.' + String get publicNote => 'What you publish here is public, and copies may remain even if you remove it later.'; + + /// en: 'Privacy & rules' + String get viewLegal => 'Privacy & rules'; + + /// en: 'I agree' + String get accept => 'I agree'; + + /// en: 'Not now' + String get decline => 'Not now'; +} + +// Path: report +class Translations$report$en { + Translations$report$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Report this offer' + String get offer => 'Report this offer'; + + /// en: 'Report this person' + String get person => 'Report this person'; + + /// en: 'Report' + String get title => 'Report'; + + /// en: 'What's wrong?' + String get prompt => 'What\'s wrong?'; + + /// en: 'Spam or a scam' + String get reasonSpam => 'Spam or a scam'; + + /// en: 'Abusive or disrespectful' + String get reasonAbuse => 'Abusive or disrespectful'; + + /// en: 'Seeds that shouldn't be offered' + String get reasonIllegal => 'Seeds that shouldn\'t be offered'; + + /// en: 'Something else' + String get reasonOther => 'Something else'; + + /// en: 'Add details (optional)' + String get detailsHint => 'Add details (optional)'; + + /// en: 'Send report' + String get send => 'Send report'; + + /// en: 'Report sent — you won't see this anymore' + String get sentHidden => 'Report sent — you won\'t see this anymore'; + + /// en: 'Couldn't send the report — check your connection' + String get failed => 'Couldn\'t send the report — check your connection'; + + /// en: 'Also block this person' + String get alsoBlock => 'Also block this person'; +} + +// Path: block +class Translations$block$en { + Translations$block$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Block this person' + String get action => 'Block this person'; + + /// en: 'Block this person?' + String get confirmTitle => 'Block this person?'; + + /// en: 'You won't see their offers or messages anymore. You can unblock them later under Blocked people in the sharing setup.' + String get confirmBody => 'You won\'t see their offers or messages anymore. You can unblock them later under Blocked people in the sharing setup.'; + + /// en: 'Block' + String get confirm => 'Block'; + + /// en: 'Blocked — their offers and messages are hidden' + String get blockedToast => 'Blocked — their offers and messages are hidden'; + + /// en: 'Blocked people' + String get manageTitle => 'Blocked people'; + + /// en: 'You haven't blocked anyone' + String get manageEmpty => 'You haven\'t blocked anyone'; + + /// en: 'Unblock' + String get unblock => 'Unblock'; +} + // Path: intro.slides class Translations$intro$slides$en { Translations$intro$slides$en.internal(this._root); @@ -1500,16 +2607,58 @@ class Translations$intro$slides$plantare$en { extension on Translations { dynamic _flatMapFunction(String path) { return switch (path) { - 'app.title' => 'Tanemaki', + 'avatar.title' => 'Your photo or avatar', + 'avatar.fromPhoto' => 'Take or choose a photo', + 'avatar.illustration' => 'Or pick an illustration', + 'avatar.remove' => 'Remove', + 'favorites.title' => 'Favorites', + 'favorites.empty' => 'No favorites yet. Save offers you like from the market.', + 'favorites.save' => 'Save to favorites', + 'favorites.remove' => 'Remove from favorites', + 'favorites.unavailable' => 'No longer available', + 'seedSaving.title' => 'Saving its seed', + 'seedSaving.subtitle' => 'What it takes to keep the variety true', + 'seedSaving.lifeCycle' => 'Cycle', + 'seedSaving.cycleAnnual' => 'Annual', + 'seedSaving.cycleBiennial' => 'Biennial — seeds in year 2', + 'seedSaving.cyclePerennial' => 'Perennial', + 'seedSaving.pollination' => 'Pollination', + 'seedSaving.pollSelf' => 'Self-pollinating', + 'seedSaving.pollCross' => 'Crosses with others', + 'seedSaving.pollMixed' => 'Self-pollinates, sometimes crosses', + 'seedSaving.byInsect' => 'by insects', + 'seedSaving.byWind' => 'on the wind', + 'seedSaving.isolation' => 'Keep apart', + 'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m from other varieties', + 'seedSaving.isolationSingle' => ({required Object min}) => '${min} m from other varieties', + 'seedSaving.plants' => 'Save from several plants', + 'seedSaving.plantsValue' => ({required Object n}) => 'From at least ${n} plants', + 'seedSaving.processing' => 'Cleaning the seed', + 'seedSaving.procDry' => 'Dry seed (thresh)', + 'seedSaving.procWet' => 'Wet seed (ferment and rinse)', + 'seedSaving.difficulty' => 'Difficulty', + 'seedSaving.diffEasy' => 'Easy', + 'seedSaving.diffMedium' => 'Medium', + 'seedSaving.diffHard' => 'Hard', + 'seedSaving.advisory' => 'General guidance — adapt it to your climate and variety.', + 'seedSaving.sourcePrefix' => 'Source', + 'calendar.title' => 'This month', + 'calendar.filterChip' => 'This month', + 'calendar.selfNote' => 'What you\'ve noted in your varieties.', + 'calendar.nothing' => ({required Object month}) => 'Nothing noted for ${month}.', + 'app.title' => 'Tane', + 'bootstrap.failed' => 'Tane couldn\'t start', + 'bootstrap.retry' => 'Try again', 'common.save' => 'Save', 'common.cancel' => 'Cancel', 'common.delete' => 'Delete', 'common.edit' => 'Edit', 'common.type' => 'Type', 'common.comingSoon' => 'Coming soon', + 'common.offline' => 'You\'re offline — sharing is paused', 'home.tagline' => 'Share and grow local seeds', - 'home.openMarket' => 'Open market', - 'home.openMarketSubtitle' => 'Browse and exchange', + 'home.openMarket' => 'Market', + 'home.openMarketSubtitle' => 'Discover and share seeds nearby', 'home.yourInventory' => 'Your inventory', 'home.yourInventorySubtitle' => 'Manage your seeds', 'photo.camera' => 'Take a photo', @@ -1522,18 +2671,28 @@ extension on Translations { 'menu.market' => 'Market', 'menu.profile' => 'Your profile', 'menu.chat' => 'Chat', - 'menu.wishlist' => 'Wishlist', + 'menu.wishlist' => 'Favorites', 'menu.following' => 'Following', + 'menu.plantares' => 'Plantares', + 'menu.sales' => 'Sales', + 'menu.calendar' => 'Calendar', 'menu.settings' => 'Settings', 'settings.language' => 'Language', 'settings.systemLanguage' => 'System language', 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langAst' => 'Asturianu', + 'settings.langFr' => 'Français', + 'settings.langDe' => 'Deutsch', + 'settings.langJa' => '日本語', 'settings.about' => 'About', 'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.', - 'settings.aboutOpen' => 'About Tanemaki', + 'settings.aboutOpen' => 'About Tane', 'backup.title' => 'Backup & restore', + 'backup.autoBackupTitle' => 'Automatic backups', + 'backup.autoBackupLast' => ({required Object date, required Object days}) => 'Last copy saved ${date} · every ${days} days', + 'backup.autoBackupNone' => ({required Object days}) => 'A copy is kept automatically every ${days} days', 'backup.exportJson' => 'Save a backup', 'backup.exportJsonSubtitle' => 'A complete copy to keep safe, restore later or move to another device', 'backup.importJson' => 'Restore a backup', @@ -1551,33 +2710,36 @@ extension on Translations { 'backup.cancelled' => 'Cancelled', 'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated', 'backup.importCsvDone' => ({required Object count}) => 'Added ${count} entries', - 'backup.importFailed' => 'This file could not be read as a Tanemaki copy', + 'backup.importFailed' => 'This file could not be read as a Tane copy', 'backup.failed' => 'Something went wrong', 'backup.recoveryTitle' => 'Your recovery code', 'backup.recoverySubtitle' => 'Print it and keep it safe — it opens your copies on a new device', 'backup.recoveryIntro' => 'This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.', 'backup.recoveryCopy' => 'Copy', 'backup.recoverySave' => 'Save the sheet', - 'backup.recoverySheetTitle' => 'Tanemaki — your recovery sheet', + 'backup.recoverySheetTitle' => 'Tane — your recovery sheet', 'backup.recoveryPromptTitle' => 'Enter your recovery code', 'backup.recoveryPromptBody' => 'This copy was saved with another code. Type the code from your recovery sheet to open it.', 'backup.recoveryWrongCode' => 'That code doesn\'t open this copy', 'about.title' => 'About', - 'about.kanji' => '種まき', + 'about.kanji' => '種', 'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.', - 'about.intro' => 'Tanemaki (種まき, "to sow / scatter seeds" in Japanese; short name Tane, 種 "seed") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.', - 'about.heritage' => 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare.', + 'about.intro' => 'Tane (種, "seed" in Japanese) helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. Its name comes from tanemaki (種まき), "to sow / scatter seeds". The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.', + 'about.heritage' => 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tane is the digital Plantare.', 'about.version' => 'Version', 'about.license' => 'License', 'about.licenseValue' => 'AGPL-3.0', 'about.website' => 'Website', + 'about.sourceCode' => 'Source code', + 'about.translate' => 'Help translate', + 'about.translateSubtitle' => 'Help bring Tane to your language', 'about.openSourceLicenses' => 'Open-source licenses', 'about.openSourceLicensesSubtitle' => 'Third-party libraries and their licenses', 'about.copyright' => ({required Object years}) => '© ${years} Comunes Association, under AGPLv3', 'intro.skip' => 'Skip', 'intro.next' => 'Next', 'intro.start' => 'Get started', - 'intro.menuEntry' => 'How Tanemaki works', + 'intro.menuEntry' => 'How Tane works', 'intro.slides.welcome.title' => 'The seed that brought you here', 'intro.slides.welcome.body' => 'Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing.', 'intro.slides.inventory.title' => 'Your seed bank, in your pocket', @@ -1595,6 +2757,8 @@ extension on Translations { 'inventory.clearFilters' => 'Clear filters', 'inventory.uncategorized' => 'Uncategorized', 'inventory.needsReproductionFilter' => 'To regrow', + 'inventory.loadError' => 'Couldn\'t open your seed bank. It may just have been busy — try again.', + 'inventory.retry' => 'Try again', 'draft.capture' => 'Capture photos', 'draft.captured' => ({required Object n}) => '${n} captured to catalogue', 'draft.triageTitle' => 'To catalogue', @@ -1701,6 +2865,8 @@ extension on Translations { 'share.add' => 'Do you share it?', 'share.title' => 'Do you share it?', 'share.nudge' => 'You have plenty — you could share some.', + 'share.price' => 'Price', + 'share.priceHint' => 'Leave it empty to agree it later', 'share.private' => 'Just for me', 'share.gift' => 'To give away', 'share.exchange' => 'To swap', @@ -1710,6 +2876,21 @@ extension on Translations { 'share.catalogTitle' => 'What I share', 'share.catalogSaved' => 'Catalog saved', 'share.cancelled' => 'Cancelled', + 'printLabels.action' => 'Print labels', + 'printLabels.title' => 'Print labels', + 'printLabels.selectHint' => 'Pick the seeds to print labels for', + 'printLabels.selectAll' => 'Select all', + 'printLabels.selected' => ({required Object n}) => '${n} selected', + 'printLabels.none' => 'Select seeds first', + 'printLabels.format' => 'Label size', + 'printLabels.formatStickers' => 'Small stickers', + 'printLabels.formatStickersHint' => 'Many small labels per page — to peel onto packets', + 'printLabels.formatCards' => 'Large cards', + 'printLabels.formatCardsHint' => 'Fewer, bigger labels — for jars and boxes', + 'printLabels.count' => ({required Object n}) => '${n} labels', + 'printLabels.save' => 'Save labels', + 'printLabels.saved' => 'Labels saved', + 'printLabels.cancelled' => 'Cancelled', 'cropCalendar.add' => 'Crop calendar', 'cropCalendar.title' => 'Crop calendar', 'cropCalendar.sow' => 'Sow', @@ -1717,6 +2898,7 @@ extension on Translations { 'cropCalendar.flowering' => 'Flowering', 'cropCalendar.fruiting' => 'Fruiting', 'cropCalendar.seedHarvest' => 'Seed harvest', + 'cropCalendar.editorHint' => 'Note the months typical for this variety in your area — these are your own notes.', 'cropCalendar.unset' => '—', 'needsReproduction.label' => 'To regrow this season', 'needsReproduction.hint' => 'Grow it out before the seed runs out', @@ -1793,6 +2975,232 @@ extension on Translations { 'unit.grams.plural' => 'grams', 'unit.count.singular' => 'seed', 'unit.count.plural' => 'seeds', + 'market.title' => 'Seeds near you', + 'market.subtitle' => 'What others are sharing nearby', + 'market.notSetUp' => 'Sharing isn\'t set up yet', + 'market.notSetUpBody' => 'Turn on sharing to see and give seeds to people near you. It stays rough — your zone, never your exact address.', + 'market.setUp' => 'Set up sharing', + 'market.cantReach' => 'Can\'t reach the community servers right now', + 'market.cantReachBody' => 'Try again, or check the servers under Advanced setup.', + 'market.retry' => 'Retry', + 'market.setArea' => 'Set your area', + 'market.setAreaBody' => 'Tell the market roughly where you are to see seeds nearby.', + 'market.searching' => 'Looking around your area…', + 'market.empty' => 'No seeds shared near you yet', + 'market.searchHint' => 'Search these seeds', + 'market.noMatches' => 'No shared seeds match your search', + 'market.near' => 'Near you', + 'market.contact' => 'Message', + 'market.mine' => 'You', + 'market.configTitle' => 'Sharing setup', + 'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.', + 'market.areaLabel' => 'Your area', + 'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.', + 'market.areaSet' => 'Your area is set — kept coarse, never your exact spot', + 'market.areaNotSet' => 'Area not set yet — use your location, or add a code under Advanced', + 'market.advanced' => 'Advanced', + 'market.areaCodeLabel' => 'Area code', + 'market.areaCodeHint' => 'A short code like sp3e9 — not a place name', + 'market.serversLabel' => 'Community servers', + 'market.serversHelp' => 'Choose which servers to use. Leave the defaults if unsure.', + 'market.serversAdvanced' => 'Add another server', + 'market.serverAddress' => 'Server address', + 'market.serverInvalid' => 'Enter a valid address (wss://…)', + 'market.save' => 'Save', + 'market.saved' => 'Saved', + 'market.wanted' => 'Wanted', + 'market.shareMine' => 'Share my seeds', + 'market.sharedCount' => ({required Object n}) => 'Shared ${n} seeds nearby', + 'market.nothingToShare' => 'Mark some seeds to give, swap or sell first', + 'market.useLocation' => 'Use my approximate location', + 'market.locationFailed' => 'Couldn\'t get your location — check that location is on and the permission is granted', + 'market.queued' => 'Saved — we\'ll share these when you\'re connected', + 'market.shareFailed' => 'Couldn\'t reach the community servers — your seeds weren\'t shared. Try again in a moment.', + 'market.rangeLabel' => 'How far to look', + 'market.rangeNear' => 'Very close', + 'market.rangeArea' => 'Around here', + 'market.rangeRegion' => 'My region', + 'market.sharedBy' => 'Shared by', + 'market.noProfile' => 'This person hasn\'t shared a profile yet', + 'market.copyId' => 'Copy code', + 'market.idCopied' => 'Code copied', + 'market.photo' => 'Photo', + 'profile.title' => 'Your profile', + 'profile.name' => 'Display name', + 'profile.nameHint' => 'How others see you', + 'profile.about' => 'About', + 'profile.aboutHint' => 'A short line — what you grow, where', + 'profile.g1' => 'Ğ1 address (optional)', + 'profile.g1Hint' => 'So people can pay you in Ğ1 — separate from your key', + 'profile.yourId' => 'Your identity', + 'profile.idHelp' => 'Share this so people can recognise you', + 'profile.copy' => 'Copy', + 'profile.copied' => 'Copied', + 'profile.save' => 'Save', + 'profile.saved' => 'Profile saved', + 'profile.identities' => 'Your identities', + 'profile.identitiesHelp' => 'Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.', + 'profile.identityLabel' => ({required Object n}) => 'Identity ${n}', + 'profile.current' => 'In use', + 'profile.newIdentity' => 'New identity', + 'profile.switchTitle' => 'Switch identity?', + 'profile.switchBody' => 'Messages and contacts are kept separate for each identity. You can switch back anytime.', + 'profile.switchAction' => 'Switch', + 'chatList.title' => 'Messages', + 'chatList.empty' => 'No conversations yet. Message someone from the market.', + 'chat.title' => 'Chat', + 'chat.hint' => 'Write a message…', + 'chat.send' => 'Send', + 'chat.empty' => 'No messages yet — say hello', + 'chat.offline' => 'Set up sharing to send messages', + 'chat.payG1' => 'Pay in Ğ1', + 'chat.g1Copied' => 'Ğ1 address copied — paste it in your wallet', + 'chat.today' => 'Today', + 'chat.yesterday' => 'Yesterday', + 'chat.sendError' => 'Couldn\'t send — check your connection', + 'chat.noLinks' => 'Links aren\'t allowed in messages', + 'trust.none' => 'No one vouches for them yet', + 'trust.count' => ({required Object n}) => 'Vouched for by ${n}', + 'trust.vouch' => 'I know this person', + 'trust.vouched' => 'You vouch for them', + 'trust.circle' => 'In your circle', + 'yourPeople.title' => 'Your people', + 'yourPeople.help' => 'People you\'ve met and vouch for, and people who vouch for you.', + 'yourPeople.youVouchFor' => 'You vouch for', + 'yourPeople.vouchesForYou' => 'They vouch for you', + 'yourPeople.youVouchForEmpty' => 'You don\'t vouch for anyone yet. When you meet someone, open your chat with them and tap "I know this person".', + 'yourPeople.vouchesForYouEmpty' => 'No one vouches for you yet', + 'yourPeople.revoke' => 'Stop vouching', + 'yourPeople.revokeConfirm' => 'Stop vouching for this person?', + 'yourPeople.offline' => 'You\'re offline — try again when you\'re connected', + 'ratings.rate' => 'Rate this person', + 'ratings.edit' => 'Edit your rating', + 'ratings.commentHint' => 'How did it go? (optional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} from people you know', + 'ratings.retract' => 'Remove your rating', + 'ratings.saved' => 'Rating saved', + 'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It\'s not a sale.', + 'plantare.add' => 'Add a commitment', + 'plantare.empty' => 'No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.', + 'plantare.iReturn' => 'I\'ll grow it out & return some', + 'plantare.owedToMe' => 'They\'ll return some to me', + 'plantare.direction' => 'Who reproduces & returns', + 'plantare.counterparty' => 'With whom?', + 'plantare.counterpartyHint' => 'A person or a collective (optional)', + 'plantare.owed' => 'What comes back?', + 'plantare.owedHint' => 'e.g. a handful next season (optional)', + 'plantare.note' => 'Note (optional)', + 'plantare.save' => 'Save', + 'plantare.markReturned' => 'Mark returned', + 'plantare.markForgiven' => 'Let it go', + 'plantare.reopen' => 'Reopen', + 'plantare.delete' => 'Remove', + 'plantare.statusReturned' => 'Returned', + 'plantare.statusForgiven' => 'Settled', + 'plantare.openSection' => 'Open', + 'plantare.settledSection' => 'Done', + 'plantare.removeConfirm' => 'Remove this commitment?', + 'plantare.returnBy' => ({required Object date}) => 'Return by ${date}', + 'plantare.overdue' => 'overdue', + 'plantare.dueByLabel' => 'Return by (optional)', + 'plantare.dueByHint' => 'A gentle reminder, never enforced', + 'plantare.pickDate' => 'Pick a date', + 'plantare.clearDate' => 'Clear date', + 'plantare.sectionTitle' => 'Commitments', + 'plantare.propose' => 'Propose a signed Plantaré', + 'plantare.proposeHelp' => 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.', + 'plantare.proposeTo' => ({required Object name}) => 'With ${name}', + 'plantare.sent' => 'Proposal sent — waiting for them to sign', + 'plantare.seedLabel' => 'Which seed?', + 'plantare.seedHint' => 'The variety this promise is about', + 'plantare.returnKindLabel' => 'What comes back?', + 'plantare.returnSimilar' => 'A similar amount of seed', + 'plantare.returnSimilarNote' => 'open-pollinated · non-GMO · organically grown', + 'plantare.returnWork' => 'Some hours of work', + _ => null, + } ?? switch (path) { + 'plantare.returnOther' => 'Something else', + 'plantare.workHoursLabel' => 'How many hours?', + 'plantare.proposalsSection' => 'Waiting for your reply', + 'plantare.incomingFrom' => ({required Object name}) => '${name} proposes a Plantaré', + 'plantare.accept' => 'Accept & sign', + 'plantare.declineAction' => 'Decline', + 'plantare.declineReasonHint' => 'Reason (optional)', + 'plantare.acceptedToast' => 'Signed — you both hold it now', + 'plantare.declinedToast' => 'Declined', + 'plantare.badgeAwaiting' => 'Awaiting signature', + 'plantare.badgeSigned' => 'Signed by both', + 'plantare.badgeDeclined' => 'Declined', + 'plantare.offline' => 'You\'re offline — it\'ll be sent when you reconnect', + 'handover.title' => 'Seeds changed hands', + 'handover.help' => 'A gift, a swap or a sale — note it down, with a return promise or without.', + 'handover.iGave' => 'I gave seeds', + 'handover.iReceived' => 'I received seeds', + 'handover.whichLot' => 'Which batch?', + 'handover.howMuch' => 'How much?', + 'handover.allOfIt' => 'All of it', + 'handover.partOfIt' => 'A part', + 'handover.paymentChip' => 'Money changed hands', + 'handover.promiseGave' => 'They\'ll return me seed', + 'handover.promiseReceived' => 'I\'ll return seed', + 'sale.title' => 'Sales', + 'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.', + 'sale.add' => 'Record a sale', + 'sale.empty' => 'No sales yet. Note here what you sell or buy.', + 'sale.iSold' => 'I sold', + 'sale.iBought' => 'I bought', + 'sale.direction' => 'Sold or bought?', + 'sale.counterparty' => 'With whom?', + 'sale.counterpartyHint' => 'A person or a collective (optional)', + 'sale.amount' => 'Amount', + 'sale.currency' => 'Currency', + 'sale.currencyHint' => '€, Ğ1, hours… (optional)', + 'sale.hours' => 'hours', + 'sale.note' => 'Note (optional)', + 'sale.save' => 'Save', + 'sale.delete' => 'Remove', + 'sale.removeConfirm' => 'Remove this sale?', + 'legal.title' => 'Privacy & rules', + 'legal.subtitle' => 'Your privacy, the market rules, and sharing seeds legally', + 'legal.privacyTitle' => 'Your privacy', + 'legal.privacyBody' => 'Tane works without an account, and everything you record stays on your device, encrypted. There are no ads, no trackers, and no Tane server.\n\nNothing is shared unless you choose to: when you publish an offer or your profile, or send a message, it travels through community-run servers. Offers only ever carry a rough zone — never your address.\n\nWhat you publish is public, and copies may remain even after you remove it — so share with care.', + 'legal.rulesTitle' => 'Rules of the road', + 'legal.rulesBody' => 'Tane is a tool, not a shop: exchanges are agreed directly between people, and nobody takes a cut. That also means you are responsible for what you offer and send.\n\nIn the market: be honest about your seeds, only offer what you may share, treat people well, and don\'t spam. You can block anyone, and report offers or people that break the rules — reports are acted on in the community servers.', + 'legal.seedsTitle' => 'About sharing seeds and seedlings', + 'legal.seedsBody' => 'Gifting and swapping seeds between amateurs is broadly recognized in most countries. Selling can be different: in many places, selling seed of varieties that aren\'t officially registered is restricted — check your local rules before asking a price.\n\nSending seeds to another country is often restricted too, and commercially protected varieties must not be propagated without permission. When in doubt, keep it local and keep it a gift.\n\nThe same spirit applies to seedlings and young plants, but live plants can carry stricter transport and plant-health rules than seeds — moving them across regions or borders may need extra checks.', + 'legal.readFull' => 'Read the full documents online', + 'marketGate.title' => 'Before you join the market', + 'marketGate.intro' => 'The market is a shared space between neighbours. By continuing, you agree to a few simple rules:', + 'marketGate.ruleHonest' => 'Be honest about the seeds you offer', + 'marketGate.ruleLegal' => 'Only share what you\'re allowed to share where you live', + 'marketGate.ruleRespect' => 'Treat people well — no spam, no abuse', + 'marketGate.publicNote' => 'What you publish here is public, and copies may remain even if you remove it later.', + 'marketGate.viewLegal' => 'Privacy & rules', + 'marketGate.accept' => 'I agree', + 'marketGate.decline' => 'Not now', + 'report.offer' => 'Report this offer', + 'report.person' => 'Report this person', + 'report.title' => 'Report', + 'report.prompt' => 'What\'s wrong?', + 'report.reasonSpam' => 'Spam or a scam', + 'report.reasonAbuse' => 'Abusive or disrespectful', + 'report.reasonIllegal' => 'Seeds that shouldn\'t be offered', + 'report.reasonOther' => 'Something else', + 'report.detailsHint' => 'Add details (optional)', + 'report.send' => 'Send report', + 'report.sentHidden' => 'Report sent — you won\'t see this anymore', + 'report.failed' => 'Couldn\'t send the report — check your connection', + 'report.alsoBlock' => 'Also block this person', + 'block.action' => 'Block this person', + 'block.confirmTitle' => 'Block this person?', + 'block.confirmBody' => 'You won\'t see their offers or messages anymore. You can unblock them later under Blocked people in the sharing setup.', + 'block.confirm' => 'Block', + 'block.blockedToast' => 'Blocked — their offers and messages are hidden', + 'block.manageTitle' => 'Blocked people', + 'block.manageEmpty' => 'You haven\'t blocked anyone', + 'block.unblock' => 'Unblock', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 6dd0555..f3a5c8a 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -39,7 +39,12 @@ class TranslationsEs extends Translations with BaseTranslations? meta}) => TranslationsEs(meta: meta ?? this.$meta); // Translations + @override late final _Translations$avatar$es avatar = _Translations$avatar$es._(_root); + @override late final _Translations$favorites$es favorites = _Translations$favorites$es._(_root); + @override late final _Translations$seedSaving$es seedSaving = _Translations$seedSaving$es._(_root); + @override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root); @override late final _Translations$app$es app = _Translations$app$es._(_root); + @override late final _Translations$bootstrap$es bootstrap = _Translations$bootstrap$es._(_root); @override late final _Translations$common$es common = _Translations$common$es._(_root); @override late final _Translations$home$es home = _Translations$home$es._(_root); @override late final _Translations$photo$es photo = _Translations$photo$es._(_root); @@ -62,12 +67,103 @@ class TranslationsEs extends Translations with BaseTranslations 'Tu foto o avatar'; + @override String get fromPhoto => 'Hacer o elegir una foto'; + @override String get illustration => 'O elige un dibujo'; + @override String get remove => 'Quitar'; +} + +// Path: favorites +class _Translations$favorites$es extends Translations$favorites$en { + _Translations$favorites$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoritos'; + @override String get empty => 'Aún no tienes favoritos. Guarda ofertas que te gusten del mercado.'; + @override String get save => 'Guardar en favoritos'; + @override String get remove => 'Quitar de favoritos'; + @override String get unavailable => 'Ya no está disponible'; +} + +// Path: seedSaving +class _Translations$seedSaving$es extends Translations$seedSaving$en { + _Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Conservar su semilla'; + @override String get subtitle => 'Lo que hace falta para mantener la variedad fiel'; + @override String get lifeCycle => 'Ciclo'; + @override String get cycleAnnual => 'Anual'; + @override String get cycleBiennial => 'Bienal — da semilla el 2.º año'; + @override String get cyclePerennial => 'Perenne'; + @override String get pollination => 'Polinización'; + @override String get pollSelf => 'Se autopoliniza'; + @override String get pollCross => 'Se cruza con otras'; + @override String get pollMixed => 'Se autopoliniza, pero se cruza a veces'; + @override String get byInsect => 'por insectos'; + @override String get byWind => 'por el viento'; + @override String get isolation => 'Sepárala'; + @override String isolationRange({required Object min, required Object max}) => '${min}–${max} m de otras variedades'; + @override String isolationSingle({required Object min}) => '${min} m de otras variedades'; + @override String get plants => 'Guarda de varias plantas'; + @override String plantsValue({required Object n}) => 'De al menos ${n} plantas'; + @override String get processing => 'Cómo limpiarla'; + @override String get procDry => 'Semilla seca (trillar)'; + @override String get procWet => 'Semilla húmeda (fermentar y lavar)'; + @override String get difficulty => 'Dificultad'; + @override String get diffEasy => 'Fácil'; + @override String get diffMedium => 'Media'; + @override String get diffHard => 'Difícil'; + @override String get advisory => 'Orientativo — adáptalo a tu clima y variedad.'; + @override String get sourcePrefix => 'Fuente'; +} + +// Path: calendar +class _Translations$calendar$es extends Translations$calendar$en { + _Translations$calendar$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Este mes'; + @override String get filterChip => 'Este mes'; + @override String get selfNote => 'Lo que has anotado en tus variedades.'; + @override String nothing({required Object month}) => 'Nada anotado para ${month}.'; } // Path: app @@ -77,7 +173,18 @@ class _Translations$app$es extends Translations$app$en { final TranslationsEs _root; // ignore: unused_field // Translations - @override String get title => 'Tanemaki'; + @override String get title => 'Tane'; +} + +// Path: bootstrap +class _Translations$bootstrap$es extends Translations$bootstrap$en { + _Translations$bootstrap$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get failed => 'Tane no pudo arrancar'; + @override String get retry => 'Reintentar'; } // Path: common @@ -93,6 +200,7 @@ class _Translations$common$es extends Translations$common$en { @override String get edit => 'Editar'; @override String get type => 'Tipo'; @override String get comingSoon => 'Pronto'; + @override String get offline => 'Sin conexión — el compartir está en pausa'; } // Path: home @@ -103,8 +211,8 @@ class _Translations$home$es extends Translations$home$en { // Translations @override String get tagline => 'Comparte y cultiva semillas locales'; - @override String get openMarket => 'Mercado abierto'; - @override String get openMarketSubtitle => 'Explora e intercambia'; + @override String get openMarket => 'Mercado'; + @override String get openMarketSubtitle => 'Descubre y comparte semillas cerca'; @override String get yourInventory => 'Tu inventario'; @override String get yourInventorySubtitle => 'Gestiona tus semillas'; } @@ -135,8 +243,11 @@ class _Translations$menu$es extends Translations$menu$en { @override String get market => 'Mercado'; @override String get profile => 'Tu perfil'; @override String get chat => 'Chat'; - @override String get wishlist => 'Lista de deseos'; + @override String get wishlist => 'Favoritos'; @override String get following => 'Siguiendo'; + @override String get plantares => 'Plantares'; + @override String get sales => 'Ventas'; + @override String get calendar => 'Calendario'; @override String get settings => 'Ajustes'; } @@ -152,9 +263,12 @@ class _Translations$settings$es extends Translations$settings$en { @override String get langEs => 'Español'; @override String get langEn => 'English'; @override String get langPt => 'Português'; + @override String get langFr => 'Français'; + @override String get langDe => 'Deutsch'; + @override String get langJa => '日本語'; @override String get about => 'Acerca de'; @override String get aboutText => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.'; - @override String get aboutOpen => 'Acerca de Tanemaki'; + @override String get aboutOpen => 'Acerca de Tane'; } // Path: backup @@ -165,6 +279,9 @@ class _Translations$backup$es extends Translations$backup$en { // Translations @override String get title => 'Copia de seguridad'; + @override String get autoBackupTitle => 'Copias automáticas'; + @override String autoBackupLast({required Object date, required Object days}) => 'Última copia el ${date} · cada ${days} días'; + @override String autoBackupNone({required Object days}) => 'Se guarda una copia automáticamente cada ${days} días'; @override String get exportJson => 'Guardar una copia de seguridad'; @override String get exportJsonSubtitle => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo'; @override String get importJson => 'Restaurar una copia'; @@ -182,14 +299,14 @@ class _Translations$backup$es extends Translations$backup$en { @override String get cancelled => 'Cancelado'; @override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas'; @override String importCsvDone({required Object count}) => 'Añadidas ${count} entradas'; - @override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki'; + @override String get importFailed => 'Este fichero no se pudo leer como una copia de Tane'; @override String get failed => 'Algo ha salido mal'; @override String get recoveryTitle => 'Tu código de recuperación'; @override String get recoverySubtitle => 'Imprímelo y guárdalo bien: abre tus copias en otro dispositivo'; @override String get recoveryIntro => 'Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.'; @override String get recoveryCopy => 'Copiar'; @override String get recoverySave => 'Guardar la hoja'; - @override String get recoverySheetTitle => 'Tanemaki — tu hoja de recuperación'; + @override String get recoverySheetTitle => 'Tane — tu hoja de recuperación'; @override String get recoveryPromptTitle => 'Escribe tu código de recuperación'; @override String get recoveryPromptBody => 'Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.'; @override String get recoveryWrongCode => 'Ese código no abre esta copia'; @@ -203,14 +320,17 @@ class _Translations$about$es extends Translations$about$en { // Translations @override String get title => 'Acerca de'; - @override String get kanji => '種まき'; + @override String get kanji => '種'; @override String get tagline => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.'; - @override String get intro => 'Tanemaki (種まき, «sembrar / esparcir semillas» en japonés; nombre corto Tane, 種 «semilla») ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.'; - @override String get heritage => 'El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tanemaki es el Plantare digital.'; + @override String get intro => 'Tane (種, «semilla» en japonés) ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. Su nombre viene de tanemaki (種まき), «sembrar / esparcir semillas». El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.'; + @override String get heritage => 'El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tane es el Plantare digital.'; @override String get version => 'Versión'; @override String get license => 'Licencia'; @override String get licenseValue => 'AGPL-3.0'; @override String get website => 'Sitio web'; + @override String get sourceCode => 'Código fuente'; + @override String get translate => 'Ayuda a traducir'; + @override String get translateSubtitle => 'Ayuda a traer Tane a tu idioma'; @override String get openSourceLicenses => 'Licencias de código abierto'; @override String get openSourceLicensesSubtitle => 'Bibliotecas de terceros y sus licencias'; @override String copyright({required Object years}) => '© ${years} Asociación Comunes, bajo AGPLv3'; @@ -226,7 +346,7 @@ class _Translations$intro$es extends Translations$intro$en { @override String get skip => 'Saltar'; @override String get next => 'Siguiente'; @override String get start => 'Empezar'; - @override String get menuEntry => 'Cómo funciona Tanemaki'; + @override String get menuEntry => 'Cómo funciona Tane'; @override late final _Translations$intro$slides$es slides = _Translations$intro$slides$es._(_root); } @@ -244,6 +364,8 @@ class _Translations$inventory$es extends Translations$inventory$en { @override String get clearFilters => 'Quitar filtros'; @override String get uncategorized => 'Sin categoría'; @override String get needsReproductionFilter => 'Por reproducir'; + @override String get loadError => 'No se pudo abrir tu banco de semillas. Quizá estaba ocupado: inténtalo de nuevo.'; + @override String get retry => 'Reintentar'; } // Path: draft @@ -469,6 +591,8 @@ class _Translations$share$es extends Translations$share$en { @override String get add => '¿La compartes?'; @override String get title => '¿La compartes?'; @override String get nudge => 'Tienes de sobra: podrías compartir un poco.'; + @override String get price => 'Precio'; + @override String get priceHint => 'Déjalo vacío para acordarlo luego'; @override String get private => 'Solo para mí'; @override String get gift => 'Para regalar'; @override String get exchange => 'Para intercambiar'; @@ -480,6 +604,30 @@ class _Translations$share$es extends Translations$share$en { @override String get cancelled => 'Cancelado'; } +// Path: printLabels +class _Translations$printLabels$es extends Translations$printLabels$en { + _Translations$printLabels$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get action => 'Imprimir etiquetas'; + @override String get title => 'Imprimir etiquetas'; + @override String get selectHint => 'Elige las semillas para imprimir sus etiquetas'; + @override String get selectAll => 'Seleccionar todo'; + @override String selected({required Object n}) => '${n} seleccionadas'; + @override String get none => 'Selecciona semillas primero'; + @override String get format => 'Tamaño de etiqueta'; + @override String get formatStickers => 'Pegatinas pequeñas'; + @override String get formatStickersHint => 'Muchas etiquetas pequeñas por hoja — para pegar en sobres'; + @override String get formatCards => 'Tarjetas grandes'; + @override String get formatCardsHint => 'Menos etiquetas, más grandes — para botes y cajas'; + @override String count({required Object n}) => '${n} etiquetas'; + @override String get save => 'Guardar etiquetas'; + @override String get saved => 'Etiquetas guardadas'; + @override String get cancelled => 'Cancelado'; +} + // Path: cropCalendar class _Translations$cropCalendar$es extends Translations$cropCalendar$en { _Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -494,6 +642,7 @@ class _Translations$cropCalendar$es extends Translations$cropCalendar$en { @override String get flowering => 'Floración'; @override String get fruiting => 'Fructificación'; @override String get seedHarvest => 'Cosecha de semilla'; + @override String get editorHint => 'Anota tú los meses típicos de esta variedad en tu zona — son tus propias notas.'; @override String get unset => '—'; } @@ -593,6 +742,365 @@ class _Translations$unit$es extends Translations$unit$en { @override late final _Translations$unit$count$es count = _Translations$unit$count$es._(_root); } +// Path: market +class _Translations$market$es extends Translations$market$en { + _Translations$market$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Semillas cerca de ti'; + @override String get subtitle => 'Lo que otras personas comparten cerca'; + @override String get notSetUp => 'Aún no has configurado el compartir'; + @override String get notSetUpBody => 'Activa el compartir para ver y dar semillas a gente cercana. Se mantiene aproximado — tu zona, nunca tu dirección exacta.'; + @override String get setUp => 'Configurar el compartir'; + @override String get cantReach => 'No se puede conectar con los servidores ahora mismo'; + @override String get cantReachBody => 'Inténtalo de nuevo, o revisa los servidores en la configuración avanzada.'; + @override String get retry => 'Reintentar'; + @override String get setArea => 'Indica tu zona'; + @override String get setAreaBody => 'Dile al mercado tu zona aproximada para ver semillas cerca.'; + @override String get searching => 'Buscando por tu zona…'; + @override String get empty => 'Aún no hay semillas compartidas cerca de ti'; + @override String get searchHint => 'Buscar entre estas semillas'; + @override String get noMatches => 'Ninguna semilla compartida coincide con tu búsqueda'; + @override String get near => 'Cerca de ti'; + @override String get contact => 'Mensaje'; + @override String get mine => 'Tú'; + @override String get configTitle => 'Configuración de compartir'; + @override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.'; + @override String get areaLabel => 'Tu zona'; + @override String get areaHelp => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.'; + @override String get areaSet => 'Tu zona está puesta — aproximada, nunca tu punto exacto'; + @override String get areaNotSet => 'Zona sin definir — usa tu ubicación, o añade un código en Avanzado'; + @override String get advanced => 'Avanzado'; + @override String get areaCodeLabel => 'Código de zona'; + @override String get areaCodeHint => 'Un código corto como sp3e9 — no un nombre de lugar'; + @override String get serversLabel => 'Servidores de la comunidad'; + @override String get serversHelp => 'Elige qué servidores usar. Déjalo así si no sabes.'; + @override String get serversAdvanced => 'Añadir otro servidor'; + @override String get serverAddress => 'Dirección del servidor'; + @override String get serverInvalid => 'Introduce una dirección válida (wss://…)'; + @override String get save => 'Guardar'; + @override String get saved => 'Guardado'; + @override String get wanted => 'Busco'; + @override String get shareMine => 'Compartir mis semillas'; + @override String sharedCount({required Object n}) => 'Compartidas ${n} semillas'; + @override String get nothingToShare => 'Marca antes algunas semillas para regalar, cambiar o vender'; + @override String get useLocation => 'Usar mi ubicación aproximada'; + @override String get locationFailed => 'No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido'; + @override String get queued => 'Guardado — las compartiremos cuando tengas conexión'; + @override String get shareFailed => 'No se pudo conectar con los servidores — tus semillas no se compartieron. Inténtalo de nuevo en un momento.'; + @override String get rangeLabel => 'Hasta dónde buscar'; + @override String get rangeNear => 'Muy cerca'; + @override String get rangeArea => 'Por mi zona'; + @override String get rangeRegion => 'Mi región'; + @override String get sharedBy => 'Compartido por'; + @override String get noProfile => 'Esta persona aún no ha compartido su perfil'; + @override String get copyId => 'Copiar código'; + @override String get idCopied => 'Código copiado'; + @override String get photo => 'Foto'; +} + +// Path: profile +class _Translations$profile$es extends Translations$profile$en { + _Translations$profile$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Tu perfil'; + @override String get name => 'Nombre'; + @override String get nameHint => 'Cómo te ven los demás'; + @override String get about => 'Sobre ti'; + @override String get aboutHint => 'Una línea — qué cultivas, dónde'; + @override String get g1 => 'Dirección Ğ1 (opcional)'; + @override String get g1Hint => 'Para que te paguen en Ğ1 — aparte de tu clave'; + @override String get yourId => 'Tu identidad'; + @override String get idHelp => 'Compártela para que te reconozcan'; + @override String get copy => 'Copiar'; + @override String get copied => 'Copiado'; + @override String get save => 'Guardar'; + @override String get saved => 'Perfil guardado'; + @override String get identities => 'Tus identidades'; + @override String get identitiesHelp => 'Ten identidades separadas — cada una con sus mensajes y contactos. Todas salen de tu única copia de seguridad, así que cambiar no añade nada que recordar.'; + @override String identityLabel({required Object n}) => 'Identidad ${n}'; + @override String get current => 'En uso'; + @override String get newIdentity => 'Nueva identidad'; + @override String get switchTitle => '¿Cambiar de identidad?'; + @override String get switchBody => 'Los mensajes y contactos se guardan por separado para cada identidad. Puedes volver cuando quieras.'; + @override String get switchAction => 'Cambiar'; +} + +// Path: chatList +class _Translations$chatList$es extends Translations$chatList$en { + _Translations$chatList$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Mensajes'; + @override String get empty => 'Aún no hay conversaciones. Escribe a alguien desde el mercado.'; +} + +// Path: chat +class _Translations$chat$es extends Translations$chat$en { + _Translations$chat$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Chat'; + @override String get hint => 'Escribe un mensaje…'; + @override String get send => 'Enviar'; + @override String get empty => 'Aún no hay mensajes — saluda'; + @override String get offline => 'Configura el compartir para enviar mensajes'; + @override String get payG1 => 'Pagar en Ğ1'; + @override String get g1Copied => 'Dirección Ğ1 copiada — pégala en tu cartera'; + @override String get today => 'Hoy'; + @override String get yesterday => 'Ayer'; + @override String get sendError => 'No se pudo enviar — revisa tu conexión'; + @override String get noLinks => 'No se permiten enlaces en los mensajes'; +} + +// Path: trust +class _Translations$trust$es extends Translations$trust$en { + _Translations$trust$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get none => 'Nadie los avala aún'; + @override String count({required Object n}) => 'Avalada por ${n}'; + @override String get vouch => 'Conozco a esta persona'; + @override String get vouched => 'Avalas a esta persona'; + @override String get circle => 'En tu círculo'; +} + +// Path: yourPeople +class _Translations$yourPeople$es extends Translations$yourPeople$en { + _Translations$yourPeople$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Tu gente'; + @override String get help => 'Personas que conoces y avalas, y personas que te avalan.'; + @override String get youVouchFor => 'Tú avalas a'; + @override String get vouchesForYou => 'Te avalan'; + @override String get youVouchForEmpty => 'Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca "Conozco a esta persona".'; + @override String get vouchesForYouEmpty => 'Nadie te avala todavía'; + @override String get revoke => 'Dejar de avalar'; + @override String get revokeConfirm => '¿Dejar de avalar a esta persona?'; + @override String get offline => 'Sin conexión — inténtalo cuando estés en línea'; +} + +// Path: ratings +class _Translations$ratings$es extends Translations$ratings$en { + _Translations$ratings$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get rate => 'Valorar a esta persona'; + @override String get edit => 'Editar tu valoración'; + @override String get commentHint => '¿Qué tal fue? (opcional)'; + @override String fromYourCircle({required Object n}) => '${n} de gente que conoces'; + @override String get retract => 'Quitar tu valoración'; + @override String get saved => 'Valoración guardada'; +} + +// Path: notifications +class _Translations$notifications$es extends Translations$notifications$en { + _Translations$notifications$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Nuevo mensaje de ${name}'; +} + +// Path: plantare +class _Translations$plantare$es extends Translations$plantare$en { + _Translations$plantare$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.'; + @override String get add => 'Añadir compromiso'; + @override String get empty => 'Aún no hay compromisos. Cuando compartas o recibas semilla con el compromiso de reproducirla y devolver algo, anótalo aquí.'; + @override String get iReturn => 'La reproduzco y devuelvo yo'; + @override String get owedToMe => 'Me la devuelven a mí'; + @override String get direction => 'Quién reproduce y devuelve'; + @override String get counterparty => '¿Con quién?'; + @override String get counterpartyHint => 'Una persona o un colectivo (opcional)'; + @override String get owed => '¿Qué se devuelve?'; + @override String get owedHint => 'p. ej. un puñado la próxima temporada (opcional)'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get markReturned => 'Marcar devuelto'; + @override String get markForgiven => 'Dar por saldado'; + @override String get reopen => 'Reabrir'; + @override String get delete => 'Quitar'; + @override String get statusReturned => 'Devuelto'; + @override String get statusForgiven => 'Saldado'; + @override String get openSection => 'Pendientes'; + @override String get settledSection => 'Hechos'; + @override String get removeConfirm => '¿Quitar este compromiso?'; + @override String returnBy({required Object date}) => 'Devolver antes del ${date}'; + @override String get overdue => 'vencido'; + @override String get dueByLabel => 'Devolver antes de (opcional)'; + @override String get dueByHint => 'Un recordatorio suave, nunca obligatorio'; + @override String get pickDate => 'Elegir fecha'; + @override String get clearDate => 'Quitar fecha'; + @override String get sectionTitle => 'Compromisos'; + @override String get propose => 'Proponer un Plantaré firmado'; + @override String get proposeHelp => 'Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.'; + @override String proposeTo({required Object name}) => 'Con ${name}'; + @override String get sent => 'Propuesta enviada — esperando su firma'; + @override String get seedLabel => '¿Qué semilla?'; + @override String get seedHint => 'La variedad a la que se refiere la promesa'; + @override String get returnKindLabel => '¿Qué se devuelve?'; + @override String get returnSimilar => 'Una cantidad similar de semilla'; + @override String get returnSimilarNote => 'polinización abierta · no transgénica · cultivada en ecológico'; + @override String get returnWork => 'Unas horas de trabajo'; + @override String get returnOther => 'Otra cosa'; + @override String get workHoursLabel => '¿Cuántas horas?'; + @override String get proposalsSection => 'Esperando tu respuesta'; + @override String incomingFrom({required Object name}) => '${name} te propone un Plantaré'; + @override String get accept => 'Aceptar y firmar'; + @override String get declineAction => 'Rechazar'; + @override String get declineReasonHint => 'Motivo (opcional)'; + @override String get acceptedToast => 'Firmado — ahora lo guardáis los dos'; + @override String get declinedToast => 'Rechazado'; + @override String get badgeAwaiting => 'Falta firma'; + @override String get badgeSigned => 'Firmado por ambos'; + @override String get badgeDeclined => 'Rechazado'; + @override String get offline => 'Estás sin conexión — se enviará al reconectar'; +} + +// Path: handover +class _Translations$handover$es extends Translations$handover$en { + _Translations$handover$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Di o recibí semillas'; + @override String get help => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.'; + @override String get iGave => 'Di semillas'; + @override String get iReceived => 'Recibí semillas'; + @override String get whichLot => '¿De qué lote?'; + @override String get howMuch => '¿Cuánto?'; + @override String get allOfIt => 'Todo'; + @override String get partOfIt => 'Una parte'; + @override String get paymentChip => 'Hubo dinero por medio'; + @override String get promiseGave => 'Me devolverán semilla'; + @override String get promiseReceived => 'Devolveré semilla'; +} + +// Path: sale +class _Translations$sale$es extends Translations$sale$en { + _Translations$sale$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Ventas'; + @override String get help => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.'; + @override String get add => 'Registrar venta'; + @override String get empty => 'Aún no hay ventas. Anota aquí lo que vendes o compras.'; + @override String get iSold => 'Vendí'; + @override String get iBought => 'Compré'; + @override String get direction => '¿Vendes o compras?'; + @override String get counterparty => '¿Con quién?'; + @override String get counterpartyHint => 'Una persona o un colectivo (opcional)'; + @override String get amount => 'Importe'; + @override String get currency => 'Moneda'; + @override String get currencyHint => '€, Ğ1, horas… (opcional)'; + @override String get hours => 'horas'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get delete => 'Quitar'; + @override String get removeConfirm => '¿Quitar esta venta?'; +} + +// Path: legal +class _Translations$legal$es extends Translations$legal$en { + _Translations$legal$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Privacidad y normas'; + @override String get subtitle => 'Tu privacidad, las normas del mercado y la legalidad de las semillas'; + @override String get privacyTitle => 'Tu privacidad'; + @override String get privacyBody => 'Tane funciona sin cuenta, y todo lo que registras se queda en tu dispositivo, cifrado. No hay publicidad, ni rastreadores, ni servidor de Tane.\n\nNo se comparte nada salvo que tú quieras: cuando publicas una oferta o tu perfil, o envías un mensaje, viaja por servidores comunitarios. Las ofertas solo llevan una zona aproximada — nunca tu dirección.\n\nLo que publicas es público, y pueden quedar copias incluso después de retirarlo — así que comparte con cuidado.'; + @override String get rulesTitle => 'Las reglas del juego'; + @override String get rulesBody => 'Tane es una herramienta, no una tienda: los intercambios se acuerdan directamente entre personas y nadie se lleva comisión. Eso también significa que tú respondes de lo que ofreces y envías.\n\nEn el mercado: sé honesto con tus semillas, ofrece solo lo que puedas compartir, trata bien a la gente y no hagas spam. Puedes bloquear a cualquiera y denunciar ofertas o personas que incumplan las normas — las denuncias se atienden en los servidores comunitarios.'; + @override String get seedsTitle => 'Sobre compartir semillas y plantones'; + @override String get seedsBody => 'Regalar e intercambiar semillas entre aficionados está ampliamente reconocido en la mayoría de países. Vender puede ser distinto: en muchos sitios, vender semilla de variedades no registradas oficialmente está restringido — consulta tu normativa antes de pedir un precio.\n\nEnviar semillas a otro país también suele estar restringido, y las variedades protegidas comercialmente no pueden propagarse sin permiso. Ante la duda, mejor local y mejor regalo.\n\nLo mismo vale para plantones y plantas jóvenes, pero la planta viva puede tener reglas de transporte y fitosanitarias más estrictas que la semilla: moverla entre regiones o países puede requerir controles adicionales.'; + @override String get readFull => 'Leer los documentos completos en la web'; +} + +// Path: marketGate +class _Translations$marketGate$es extends Translations$marketGate$en { + _Translations$marketGate$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Antes de entrar al mercado'; + @override String get intro => 'El mercado es un espacio compartido entre vecinas y vecinos. Al continuar, aceptas unas pocas normas sencillas:'; + @override String get ruleHonest => 'Sé honesto con las semillas que ofreces'; + @override String get ruleLegal => 'Comparte solo lo que puedas compartir donde vives'; + @override String get ruleRespect => 'Trata bien a la gente — sin spam ni abusos'; + @override String get publicNote => 'Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.'; + @override String get viewLegal => 'Privacidad y normas'; + @override String get accept => 'Acepto'; + @override String get decline => 'Ahora no'; +} + +// Path: report +class _Translations$report$es extends Translations$report$en { + _Translations$report$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get offer => 'Denunciar esta oferta'; + @override String get person => 'Denunciar a esta persona'; + @override String get title => 'Denunciar'; + @override String get prompt => '¿Qué ocurre?'; + @override String get reasonSpam => 'Spam o un engaño'; + @override String get reasonAbuse => 'Abusivo o irrespetuoso'; + @override String get reasonIllegal => 'Semillas que no deberían ofrecerse'; + @override String get reasonOther => 'Otra cosa'; + @override String get detailsHint => 'Añade detalles (opcional)'; + @override String get send => 'Enviar denuncia'; + @override String get sentHidden => 'Denuncia enviada — ya no verás esto'; + @override String get failed => 'No se pudo enviar la denuncia — revisa tu conexión'; + @override String get alsoBlock => 'Bloquear también a esta persona'; +} + +// Path: block +class _Translations$block$es extends Translations$block$en { + _Translations$block$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get action => 'Bloquear a esta persona'; + @override String get confirmTitle => '¿Bloquear a esta persona?'; + @override String get confirmBody => 'Dejarás de ver sus ofertas y mensajes. Puedes desbloquearla más adelante en Personas bloqueadas, en la configuración de compartir.'; + @override String get confirm => 'Bloquear'; + @override String get blockedToast => 'Persona bloqueada — sus ofertas y mensajes quedan ocultos'; + @override String get manageTitle => 'Personas bloqueadas'; + @override String get manageEmpty => 'No has bloqueado a nadie'; + @override String get unblock => 'Desbloquear'; +} + // Path: intro.slides class _Translations$intro$slides$es extends Translations$intro$slides$en { _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -934,16 +1442,58 @@ class _Translations$intro$slides$plantare$es extends Translations$intro$slides$p extension on TranslationsEs { dynamic _flatMapFunction(String path) { return switch (path) { - 'app.title' => 'Tanemaki', + 'avatar.title' => 'Tu foto o avatar', + 'avatar.fromPhoto' => 'Hacer o elegir una foto', + 'avatar.illustration' => 'O elige un dibujo', + 'avatar.remove' => 'Quitar', + 'favorites.title' => 'Favoritos', + 'favorites.empty' => 'Aún no tienes favoritos. Guarda ofertas que te gusten del mercado.', + 'favorites.save' => 'Guardar en favoritos', + 'favorites.remove' => 'Quitar de favoritos', + 'favorites.unavailable' => 'Ya no está disponible', + 'seedSaving.title' => 'Conservar su semilla', + 'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel', + 'seedSaving.lifeCycle' => 'Ciclo', + 'seedSaving.cycleAnnual' => 'Anual', + 'seedSaving.cycleBiennial' => 'Bienal — da semilla el 2.º año', + 'seedSaving.cyclePerennial' => 'Perenne', + 'seedSaving.pollination' => 'Polinización', + 'seedSaving.pollSelf' => 'Se autopoliniza', + 'seedSaving.pollCross' => 'Se cruza con otras', + 'seedSaving.pollMixed' => 'Se autopoliniza, pero se cruza a veces', + 'seedSaving.byInsect' => 'por insectos', + 'seedSaving.byWind' => 'por el viento', + 'seedSaving.isolation' => 'Sepárala', + 'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m de otras variedades', + 'seedSaving.isolationSingle' => ({required Object min}) => '${min} m de otras variedades', + 'seedSaving.plants' => 'Guarda de varias plantas', + 'seedSaving.plantsValue' => ({required Object n}) => 'De al menos ${n} plantas', + 'seedSaving.processing' => 'Cómo limpiarla', + 'seedSaving.procDry' => 'Semilla seca (trillar)', + 'seedSaving.procWet' => 'Semilla húmeda (fermentar y lavar)', + 'seedSaving.difficulty' => 'Dificultad', + 'seedSaving.diffEasy' => 'Fácil', + 'seedSaving.diffMedium' => 'Media', + 'seedSaving.diffHard' => 'Difícil', + 'seedSaving.advisory' => 'Orientativo — adáptalo a tu clima y variedad.', + 'seedSaving.sourcePrefix' => 'Fuente', + 'calendar.title' => 'Este mes', + 'calendar.filterChip' => 'Este mes', + 'calendar.selfNote' => 'Lo que has anotado en tus variedades.', + 'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.', + 'app.title' => 'Tane', + 'bootstrap.failed' => 'Tane no pudo arrancar', + 'bootstrap.retry' => 'Reintentar', 'common.save' => 'Guardar', 'common.cancel' => 'Cancelar', 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', 'common.comingSoon' => 'Pronto', + 'common.offline' => 'Sin conexión — el compartir está en pausa', 'home.tagline' => 'Comparte y cultiva semillas locales', - 'home.openMarket' => 'Mercado abierto', - 'home.openMarketSubtitle' => 'Explora e intercambia', + 'home.openMarket' => 'Mercado', + 'home.openMarketSubtitle' => 'Descubre y comparte semillas cerca', 'home.yourInventory' => 'Tu inventario', 'home.yourInventorySubtitle' => 'Gestiona tus semillas', 'photo.camera' => 'Hacer una foto', @@ -956,18 +1506,27 @@ extension on TranslationsEs { 'menu.market' => 'Mercado', 'menu.profile' => 'Tu perfil', 'menu.chat' => 'Chat', - 'menu.wishlist' => 'Lista de deseos', + 'menu.wishlist' => 'Favoritos', 'menu.following' => 'Siguiendo', + 'menu.plantares' => 'Plantares', + 'menu.sales' => 'Ventas', + 'menu.calendar' => 'Calendario', 'menu.settings' => 'Ajustes', 'settings.language' => 'Idioma', 'settings.systemLanguage' => 'Idioma del sistema', 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langFr' => 'Français', + 'settings.langDe' => 'Deutsch', + 'settings.langJa' => '日本語', 'settings.about' => 'Acerca de', 'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.', - 'settings.aboutOpen' => 'Acerca de Tanemaki', + 'settings.aboutOpen' => 'Acerca de Tane', 'backup.title' => 'Copia de seguridad', + 'backup.autoBackupTitle' => 'Copias automáticas', + 'backup.autoBackupLast' => ({required Object date, required Object days}) => 'Última copia el ${date} · cada ${days} días', + 'backup.autoBackupNone' => ({required Object days}) => 'Se guarda una copia automáticamente cada ${days} días', 'backup.exportJson' => 'Guardar una copia de seguridad', 'backup.exportJsonSubtitle' => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo', 'backup.importJson' => 'Restaurar una copia', @@ -985,33 +1544,36 @@ extension on TranslationsEs { 'backup.cancelled' => 'Cancelado', 'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas', 'backup.importCsvDone' => ({required Object count}) => 'Añadidas ${count} entradas', - 'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki', + 'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tane', 'backup.failed' => 'Algo ha salido mal', 'backup.recoveryTitle' => 'Tu código de recuperación', 'backup.recoverySubtitle' => 'Imprímelo y guárdalo bien: abre tus copias en otro dispositivo', 'backup.recoveryIntro' => 'Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.', 'backup.recoveryCopy' => 'Copiar', 'backup.recoverySave' => 'Guardar la hoja', - 'backup.recoverySheetTitle' => 'Tanemaki — tu hoja de recuperación', + 'backup.recoverySheetTitle' => 'Tane — tu hoja de recuperación', 'backup.recoveryPromptTitle' => 'Escribe tu código de recuperación', 'backup.recoveryPromptBody' => 'Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.', 'backup.recoveryWrongCode' => 'Ese código no abre esta copia', 'about.title' => 'Acerca de', - 'about.kanji' => '種まき', + 'about.kanji' => '種', 'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.', - 'about.intro' => 'Tanemaki (種まき, «sembrar / esparcir semillas» en japonés; nombre corto Tane, 種 «semilla») ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.', - 'about.heritage' => 'El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tanemaki es el Plantare digital.', + 'about.intro' => 'Tane (種, «semilla» en japonés) ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. Su nombre viene de tanemaki (種まき), «sembrar / esparcir semillas». El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.', + 'about.heritage' => 'El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tane es el Plantare digital.', 'about.version' => 'Versión', 'about.license' => 'Licencia', 'about.licenseValue' => 'AGPL-3.0', 'about.website' => 'Sitio web', + 'about.sourceCode' => 'Código fuente', + 'about.translate' => 'Ayuda a traducir', + 'about.translateSubtitle' => 'Ayuda a traer Tane a tu idioma', 'about.openSourceLicenses' => 'Licencias de código abierto', 'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceros y sus licencias', 'about.copyright' => ({required Object years}) => '© ${years} Asociación Comunes, bajo AGPLv3', 'intro.skip' => 'Saltar', 'intro.next' => 'Siguiente', 'intro.start' => 'Empezar', - 'intro.menuEntry' => 'Cómo funciona Tanemaki', + 'intro.menuEntry' => 'Cómo funciona Tane', 'intro.slides.welcome.title' => 'La semilla que te trajo hasta aquí', 'intro.slides.welcome.body' => 'Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio.', 'intro.slides.inventory.title' => 'Tu banco de semillas, en el bolsillo', @@ -1029,6 +1591,8 @@ extension on TranslationsEs { 'inventory.clearFilters' => 'Quitar filtros', 'inventory.uncategorized' => 'Sin categoría', 'inventory.needsReproductionFilter' => 'Por reproducir', + 'inventory.loadError' => 'No se pudo abrir tu banco de semillas. Quizá estaba ocupado: inténtalo de nuevo.', + 'inventory.retry' => 'Reintentar', 'draft.capture' => 'Capturar fotos', 'draft.captured' => ({required Object n}) => '${n} capturadas por catalogar', 'draft.triageTitle' => 'Por catalogar', @@ -1135,6 +1699,8 @@ extension on TranslationsEs { 'share.add' => '¿La compartes?', 'share.title' => '¿La compartes?', 'share.nudge' => 'Tienes de sobra: podrías compartir un poco.', + 'share.price' => 'Precio', + 'share.priceHint' => 'Déjalo vacío para acordarlo luego', 'share.private' => 'Solo para mí', 'share.gift' => 'Para regalar', 'share.exchange' => 'Para intercambiar', @@ -1144,6 +1710,21 @@ extension on TranslationsEs { 'share.catalogTitle' => 'Lo que comparto', 'share.catalogSaved' => 'Catálogo guardado', 'share.cancelled' => 'Cancelado', + 'printLabels.action' => 'Imprimir etiquetas', + 'printLabels.title' => 'Imprimir etiquetas', + 'printLabels.selectHint' => 'Elige las semillas para imprimir sus etiquetas', + 'printLabels.selectAll' => 'Seleccionar todo', + 'printLabels.selected' => ({required Object n}) => '${n} seleccionadas', + 'printLabels.none' => 'Selecciona semillas primero', + 'printLabels.format' => 'Tamaño de etiqueta', + 'printLabels.formatStickers' => 'Pegatinas pequeñas', + 'printLabels.formatStickersHint' => 'Muchas etiquetas pequeñas por hoja — para pegar en sobres', + 'printLabels.formatCards' => 'Tarjetas grandes', + 'printLabels.formatCardsHint' => 'Menos etiquetas, más grandes — para botes y cajas', + 'printLabels.count' => ({required Object n}) => '${n} etiquetas', + 'printLabels.save' => 'Guardar etiquetas', + 'printLabels.saved' => 'Etiquetas guardadas', + 'printLabels.cancelled' => 'Cancelado', 'cropCalendar.add' => 'Calendario de cultivo', 'cropCalendar.title' => 'Calendario de cultivo', 'cropCalendar.sow' => 'Siembra', @@ -1151,6 +1732,7 @@ extension on TranslationsEs { 'cropCalendar.flowering' => 'Floración', 'cropCalendar.fruiting' => 'Fructificación', 'cropCalendar.seedHarvest' => 'Cosecha de semilla', + 'cropCalendar.editorHint' => 'Anota tú los meses típicos de esta variedad en tu zona — son tus propias notas.', 'cropCalendar.unset' => '—', 'needsReproduction.label' => 'Reproducir esta temporada', 'needsReproduction.hint' => 'Cultívala antes de que se acabe la semilla', @@ -1227,6 +1809,232 @@ extension on TranslationsEs { 'unit.grams.plural' => 'gramos', 'unit.count.singular' => 'semilla', 'unit.count.plural' => 'semillas', + 'market.title' => 'Semillas cerca de ti', + 'market.subtitle' => 'Lo que otras personas comparten cerca', + 'market.notSetUp' => 'Aún no has configurado el compartir', + 'market.notSetUpBody' => 'Activa el compartir para ver y dar semillas a gente cercana. Se mantiene aproximado — tu zona, nunca tu dirección exacta.', + 'market.setUp' => 'Configurar el compartir', + 'market.cantReach' => 'No se puede conectar con los servidores ahora mismo', + 'market.cantReachBody' => 'Inténtalo de nuevo, o revisa los servidores en la configuración avanzada.', + 'market.retry' => 'Reintentar', + 'market.setArea' => 'Indica tu zona', + 'market.setAreaBody' => 'Dile al mercado tu zona aproximada para ver semillas cerca.', + 'market.searching' => 'Buscando por tu zona…', + 'market.empty' => 'Aún no hay semillas compartidas cerca de ti', + 'market.searchHint' => 'Buscar entre estas semillas', + 'market.noMatches' => 'Ninguna semilla compartida coincide con tu búsqueda', + 'market.near' => 'Cerca de ti', + 'market.contact' => 'Mensaje', + 'market.mine' => 'Tú', + 'market.configTitle' => 'Configuración de compartir', + 'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.', + 'market.areaLabel' => 'Tu zona', + 'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.', + 'market.areaSet' => 'Tu zona está puesta — aproximada, nunca tu punto exacto', + 'market.areaNotSet' => 'Zona sin definir — usa tu ubicación, o añade un código en Avanzado', + 'market.advanced' => 'Avanzado', + 'market.areaCodeLabel' => 'Código de zona', + 'market.areaCodeHint' => 'Un código corto como sp3e9 — no un nombre de lugar', + 'market.serversLabel' => 'Servidores de la comunidad', + 'market.serversHelp' => 'Elige qué servidores usar. Déjalo así si no sabes.', + 'market.serversAdvanced' => 'Añadir otro servidor', + 'market.serverAddress' => 'Dirección del servidor', + 'market.serverInvalid' => 'Introduce una dirección válida (wss://…)', + 'market.save' => 'Guardar', + 'market.saved' => 'Guardado', + 'market.wanted' => 'Busco', + 'market.shareMine' => 'Compartir mis semillas', + 'market.sharedCount' => ({required Object n}) => 'Compartidas ${n} semillas', + 'market.nothingToShare' => 'Marca antes algunas semillas para regalar, cambiar o vender', + 'market.useLocation' => 'Usar mi ubicación aproximada', + 'market.locationFailed' => 'No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido', + 'market.queued' => 'Guardado — las compartiremos cuando tengas conexión', + 'market.shareFailed' => 'No se pudo conectar con los servidores — tus semillas no se compartieron. Inténtalo de nuevo en un momento.', + 'market.rangeLabel' => 'Hasta dónde buscar', + 'market.rangeNear' => 'Muy cerca', + 'market.rangeArea' => 'Por mi zona', + 'market.rangeRegion' => 'Mi región', + 'market.sharedBy' => 'Compartido por', + 'market.noProfile' => 'Esta persona aún no ha compartido su perfil', + 'market.copyId' => 'Copiar código', + 'market.idCopied' => 'Código copiado', + 'market.photo' => 'Foto', + 'profile.title' => 'Tu perfil', + 'profile.name' => 'Nombre', + 'profile.nameHint' => 'Cómo te ven los demás', + 'profile.about' => 'Sobre ti', + 'profile.aboutHint' => 'Una línea — qué cultivas, dónde', + 'profile.g1' => 'Dirección Ğ1 (opcional)', + 'profile.g1Hint' => 'Para que te paguen en Ğ1 — aparte de tu clave', + 'profile.yourId' => 'Tu identidad', + 'profile.idHelp' => 'Compártela para que te reconozcan', + 'profile.copy' => 'Copiar', + 'profile.copied' => 'Copiado', + 'profile.save' => 'Guardar', + 'profile.saved' => 'Perfil guardado', + 'profile.identities' => 'Tus identidades', + 'profile.identitiesHelp' => 'Ten identidades separadas — cada una con sus mensajes y contactos. Todas salen de tu única copia de seguridad, así que cambiar no añade nada que recordar.', + 'profile.identityLabel' => ({required Object n}) => 'Identidad ${n}', + 'profile.current' => 'En uso', + 'profile.newIdentity' => 'Nueva identidad', + 'profile.switchTitle' => '¿Cambiar de identidad?', + 'profile.switchBody' => 'Los mensajes y contactos se guardan por separado para cada identidad. Puedes volver cuando quieras.', + 'profile.switchAction' => 'Cambiar', + 'chatList.title' => 'Mensajes', + 'chatList.empty' => 'Aún no hay conversaciones. Escribe a alguien desde el mercado.', + 'chat.title' => 'Chat', + 'chat.hint' => 'Escribe un mensaje…', + 'chat.send' => 'Enviar', + 'chat.empty' => 'Aún no hay mensajes — saluda', + 'chat.offline' => 'Configura el compartir para enviar mensajes', + 'chat.payG1' => 'Pagar en Ğ1', + 'chat.g1Copied' => 'Dirección Ğ1 copiada — pégala en tu cartera', + 'chat.today' => 'Hoy', + 'chat.yesterday' => 'Ayer', + 'chat.sendError' => 'No se pudo enviar — revisa tu conexión', + 'chat.noLinks' => 'No se permiten enlaces en los mensajes', + 'trust.none' => 'Nadie los avala aún', + 'trust.count' => ({required Object n}) => 'Avalada por ${n}', + 'trust.vouch' => 'Conozco a esta persona', + 'trust.vouched' => 'Avalas a esta persona', + 'trust.circle' => 'En tu círculo', + 'yourPeople.title' => 'Tu gente', + 'yourPeople.help' => 'Personas que conoces y avalas, y personas que te avalan.', + 'yourPeople.youVouchFor' => 'Tú avalas a', + 'yourPeople.vouchesForYou' => 'Te avalan', + 'yourPeople.youVouchForEmpty' => 'Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca "Conozco a esta persona".', + 'yourPeople.vouchesForYouEmpty' => 'Nadie te avala todavía', + 'yourPeople.revoke' => 'Dejar de avalar', + 'yourPeople.revokeConfirm' => '¿Dejar de avalar a esta persona?', + 'yourPeople.offline' => 'Sin conexión — inténtalo cuando estés en línea', + 'ratings.rate' => 'Valorar a esta persona', + 'ratings.edit' => 'Editar tu valoración', + 'ratings.commentHint' => '¿Qué tal fue? (opcional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} de gente que conoces', + 'ratings.retract' => 'Quitar tu valoración', + 'ratings.saved' => 'Valoración guardada', + 'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.', + 'plantare.add' => 'Añadir compromiso', + 'plantare.empty' => 'Aún no hay compromisos. Cuando compartas o recibas semilla con el compromiso de reproducirla y devolver algo, anótalo aquí.', + 'plantare.iReturn' => 'La reproduzco y devuelvo yo', + 'plantare.owedToMe' => 'Me la devuelven a mí', + 'plantare.direction' => 'Quién reproduce y devuelve', + 'plantare.counterparty' => '¿Con quién?', + 'plantare.counterpartyHint' => 'Una persona o un colectivo (opcional)', + 'plantare.owed' => '¿Qué se devuelve?', + 'plantare.owedHint' => 'p. ej. un puñado la próxima temporada (opcional)', + 'plantare.note' => 'Nota (opcional)', + 'plantare.save' => 'Guardar', + 'plantare.markReturned' => 'Marcar devuelto', + 'plantare.markForgiven' => 'Dar por saldado', + 'plantare.reopen' => 'Reabrir', + 'plantare.delete' => 'Quitar', + 'plantare.statusReturned' => 'Devuelto', + 'plantare.statusForgiven' => 'Saldado', + 'plantare.openSection' => 'Pendientes', + 'plantare.settledSection' => 'Hechos', + 'plantare.removeConfirm' => '¿Quitar este compromiso?', + 'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}', + 'plantare.overdue' => 'vencido', + 'plantare.dueByLabel' => 'Devolver antes de (opcional)', + 'plantare.dueByHint' => 'Un recordatorio suave, nunca obligatorio', + 'plantare.pickDate' => 'Elegir fecha', + 'plantare.clearDate' => 'Quitar fecha', + 'plantare.sectionTitle' => 'Compromisos', + 'plantare.propose' => 'Proponer un Plantaré firmado', + 'plantare.proposeHelp' => 'Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.', + 'plantare.proposeTo' => ({required Object name}) => 'Con ${name}', + 'plantare.sent' => 'Propuesta enviada — esperando su firma', + 'plantare.seedLabel' => '¿Qué semilla?', + 'plantare.seedHint' => 'La variedad a la que se refiere la promesa', + 'plantare.returnKindLabel' => '¿Qué se devuelve?', + 'plantare.returnSimilar' => 'Una cantidad similar de semilla', + 'plantare.returnSimilarNote' => 'polinización abierta · no transgénica · cultivada en ecológico', + 'plantare.returnWork' => 'Unas horas de trabajo', + 'plantare.returnOther' => 'Otra cosa', + _ => null, + } ?? switch (path) { + 'plantare.workHoursLabel' => '¿Cuántas horas?', + 'plantare.proposalsSection' => 'Esperando tu respuesta', + 'plantare.incomingFrom' => ({required Object name}) => '${name} te propone un Plantaré', + 'plantare.accept' => 'Aceptar y firmar', + 'plantare.declineAction' => 'Rechazar', + 'plantare.declineReasonHint' => 'Motivo (opcional)', + 'plantare.acceptedToast' => 'Firmado — ahora lo guardáis los dos', + 'plantare.declinedToast' => 'Rechazado', + 'plantare.badgeAwaiting' => 'Falta firma', + 'plantare.badgeSigned' => 'Firmado por ambos', + 'plantare.badgeDeclined' => 'Rechazado', + 'plantare.offline' => 'Estás sin conexión — se enviará al reconectar', + 'handover.title' => 'Di o recibí semillas', + 'handover.help' => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.', + 'handover.iGave' => 'Di semillas', + 'handover.iReceived' => 'Recibí semillas', + 'handover.whichLot' => '¿De qué lote?', + 'handover.howMuch' => '¿Cuánto?', + 'handover.allOfIt' => 'Todo', + 'handover.partOfIt' => 'Una parte', + 'handover.paymentChip' => 'Hubo dinero por medio', + 'handover.promiseGave' => 'Me devolverán semilla', + 'handover.promiseReceived' => 'Devolveré semilla', + 'sale.title' => 'Ventas', + 'sale.help' => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.', + 'sale.add' => 'Registrar venta', + 'sale.empty' => 'Aún no hay ventas. Anota aquí lo que vendes o compras.', + 'sale.iSold' => 'Vendí', + 'sale.iBought' => 'Compré', + 'sale.direction' => '¿Vendes o compras?', + 'sale.counterparty' => '¿Con quién?', + 'sale.counterpartyHint' => 'Una persona o un colectivo (opcional)', + 'sale.amount' => 'Importe', + 'sale.currency' => 'Moneda', + 'sale.currencyHint' => '€, Ğ1, horas… (opcional)', + 'sale.hours' => 'horas', + 'sale.note' => 'Nota (opcional)', + 'sale.save' => 'Guardar', + 'sale.delete' => 'Quitar', + 'sale.removeConfirm' => '¿Quitar esta venta?', + 'legal.title' => 'Privacidad y normas', + 'legal.subtitle' => 'Tu privacidad, las normas del mercado y la legalidad de las semillas', + 'legal.privacyTitle' => 'Tu privacidad', + 'legal.privacyBody' => 'Tane funciona sin cuenta, y todo lo que registras se queda en tu dispositivo, cifrado. No hay publicidad, ni rastreadores, ni servidor de Tane.\n\nNo se comparte nada salvo que tú quieras: cuando publicas una oferta o tu perfil, o envías un mensaje, viaja por servidores comunitarios. Las ofertas solo llevan una zona aproximada — nunca tu dirección.\n\nLo que publicas es público, y pueden quedar copias incluso después de retirarlo — así que comparte con cuidado.', + 'legal.rulesTitle' => 'Las reglas del juego', + 'legal.rulesBody' => 'Tane es una herramienta, no una tienda: los intercambios se acuerdan directamente entre personas y nadie se lleva comisión. Eso también significa que tú respondes de lo que ofreces y envías.\n\nEn el mercado: sé honesto con tus semillas, ofrece solo lo que puedas compartir, trata bien a la gente y no hagas spam. Puedes bloquear a cualquiera y denunciar ofertas o personas que incumplan las normas — las denuncias se atienden en los servidores comunitarios.', + 'legal.seedsTitle' => 'Sobre compartir semillas y plantones', + 'legal.seedsBody' => 'Regalar e intercambiar semillas entre aficionados está ampliamente reconocido en la mayoría de países. Vender puede ser distinto: en muchos sitios, vender semilla de variedades no registradas oficialmente está restringido — consulta tu normativa antes de pedir un precio.\n\nEnviar semillas a otro país también suele estar restringido, y las variedades protegidas comercialmente no pueden propagarse sin permiso. Ante la duda, mejor local y mejor regalo.\n\nLo mismo vale para plantones y plantas jóvenes, pero la planta viva puede tener reglas de transporte y fitosanitarias más estrictas que la semilla: moverla entre regiones o países puede requerir controles adicionales.', + 'legal.readFull' => 'Leer los documentos completos en la web', + 'marketGate.title' => 'Antes de entrar al mercado', + 'marketGate.intro' => 'El mercado es un espacio compartido entre vecinas y vecinos. Al continuar, aceptas unas pocas normas sencillas:', + 'marketGate.ruleHonest' => 'Sé honesto con las semillas que ofreces', + 'marketGate.ruleLegal' => 'Comparte solo lo que puedas compartir donde vives', + 'marketGate.ruleRespect' => 'Trata bien a la gente — sin spam ni abusos', + 'marketGate.publicNote' => 'Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.', + 'marketGate.viewLegal' => 'Privacidad y normas', + 'marketGate.accept' => 'Acepto', + 'marketGate.decline' => 'Ahora no', + 'report.offer' => 'Denunciar esta oferta', + 'report.person' => 'Denunciar a esta persona', + 'report.title' => 'Denunciar', + 'report.prompt' => '¿Qué ocurre?', + 'report.reasonSpam' => 'Spam o un engaño', + 'report.reasonAbuse' => 'Abusivo o irrespetuoso', + 'report.reasonIllegal' => 'Semillas que no deberían ofrecerse', + 'report.reasonOther' => 'Otra cosa', + 'report.detailsHint' => 'Añade detalles (opcional)', + 'report.send' => 'Enviar denuncia', + 'report.sentHidden' => 'Denuncia enviada — ya no verás esto', + 'report.failed' => 'No se pudo enviar la denuncia — revisa tu conexión', + 'report.alsoBlock' => 'Bloquear también a esta persona', + 'block.action' => 'Bloquear a esta persona', + 'block.confirmTitle' => '¿Bloquear a esta persona?', + 'block.confirmBody' => 'Dejarás de ver sus ofertas y mensajes. Puedes desbloquearla más adelante en Personas bloqueadas, en la configuración de compartir.', + 'block.confirm' => 'Bloquear', + 'block.blockedToast' => 'Persona bloqueada — sus ofertas y mensajes quedan ocultos', + 'block.manageTitle' => 'Personas bloqueadas', + 'block.manageEmpty' => 'No has bloqueado a nadie', + 'block.unblock' => 'Desbloquear', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_fr.g.dart b/apps/app_seeds/lib/i18n/strings_fr.g.dart new file mode 100644 index 0000000..f28d9cc --- /dev/null +++ b/apps/app_seeds/lib/i18n/strings_fr.g.dart @@ -0,0 +1,1983 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import +// dart format off + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'strings.g.dart'; + +// Path: +class TranslationsFr extends Translations with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + TranslationsFr({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = meta ?? TranslationMetadata( + locale: AppLocale.fr, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); + + late final TranslationsFr _root = this; // ignore: unused_field + + @override + TranslationsFr $copyWith({TranslationMetadata? meta}) => TranslationsFr(meta: meta ?? this.$meta); + + // Translations + @override late final _Translations$avatar$fr avatar = _Translations$avatar$fr._(_root); + @override late final _Translations$favorites$fr favorites = _Translations$favorites$fr._(_root); + @override late final _Translations$seedSaving$fr seedSaving = _Translations$seedSaving$fr._(_root); + @override late final _Translations$calendar$fr calendar = _Translations$calendar$fr._(_root); + @override late final _Translations$app$fr app = _Translations$app$fr._(_root); + @override late final _Translations$bootstrap$fr bootstrap = _Translations$bootstrap$fr._(_root); + @override late final _Translations$common$fr common = _Translations$common$fr._(_root); + @override late final _Translations$home$fr home = _Translations$home$fr._(_root); + @override late final _Translations$photo$fr photo = _Translations$photo$fr._(_root); + @override late final _Translations$menu$fr menu = _Translations$menu$fr._(_root); + @override late final _Translations$settings$fr settings = _Translations$settings$fr._(_root); + @override late final _Translations$backup$fr backup = _Translations$backup$fr._(_root); + @override late final _Translations$about$fr about = _Translations$about$fr._(_root); + @override late final _Translations$intro$fr intro = _Translations$intro$fr._(_root); + @override late final _Translations$inventory$fr inventory = _Translations$inventory$fr._(_root); + @override late final _Translations$draft$fr draft = _Translations$draft$fr._(_root); + @override late final _Translations$quickAdd$fr quickAdd = _Translations$quickAdd$fr._(_root); + @override late final _Translations$detail$fr detail = _Translations$detail$fr._(_root); + @override late final _Translations$germination$fr germination = _Translations$germination$fr._(_root); + @override late final _Translations$viability$fr viability = _Translations$viability$fr._(_root); + @override late final _Translations$editVariety$fr editVariety = _Translations$editVariety$fr._(_root); + @override late final _Translations$addLot$fr addLot = _Translations$addLot$fr._(_root); + @override late final _Translations$harvest$fr harvest = _Translations$harvest$fr._(_root); + @override late final _Translations$lotType$fr lotType = _Translations$lotType$fr._(_root); + @override late final _Translations$presentation$fr presentation = _Translations$presentation$fr._(_root); + @override late final _Translations$provenance$fr provenance = _Translations$provenance$fr._(_root); + @override late final _Translations$abundance$fr abundance = _Translations$abundance$fr._(_root); + @override late final _Translations$share$fr share = _Translations$share$fr._(_root); + @override late final _Translations$printLabels$fr printLabels = _Translations$printLabels$fr._(_root); + @override late final _Translations$cropCalendar$fr cropCalendar = _Translations$cropCalendar$fr._(_root); + @override late final _Translations$needsReproduction$fr needsReproduction = _Translations$needsReproduction$fr._(_root); + @override late final _Translations$preservation$fr preservation = _Translations$preservation$fr._(_root); + @override late final _Translations$conditionCheck$fr conditionCheck = _Translations$conditionCheck$fr._(_root); + @override late final _Translations$desiccant$fr desiccant = _Translations$desiccant$fr._(_root); + @override late final _Translations$unit$fr unit = _Translations$unit$fr._(_root); + @override late final _Translations$market$fr market = _Translations$market$fr._(_root); + @override late final _Translations$profile$fr profile = _Translations$profile$fr._(_root); + @override late final _Translations$chatList$fr chatList = _Translations$chatList$fr._(_root); + @override late final _Translations$chat$fr chat = _Translations$chat$fr._(_root); + @override late final _Translations$trust$fr trust = _Translations$trust$fr._(_root); + @override late final _Translations$yourPeople$fr yourPeople = _Translations$yourPeople$fr._(_root); + @override late final _Translations$ratings$fr ratings = _Translations$ratings$fr._(_root); + @override late final _Translations$notifications$fr notifications = _Translations$notifications$fr._(_root); + @override late final _Translations$plantare$fr plantare = _Translations$plantare$fr._(_root); + @override late final _Translations$handover$fr handover = _Translations$handover$fr._(_root); + @override late final _Translations$sale$fr sale = _Translations$sale$fr._(_root); + @override late final _Translations$legal$fr legal = _Translations$legal$fr._(_root); + @override late final _Translations$marketGate$fr marketGate = _Translations$marketGate$fr._(_root); + @override late final _Translations$report$fr report = _Translations$report$fr._(_root); + @override late final _Translations$block$fr block = _Translations$block$fr._(_root); +} + +// Path: avatar +class _Translations$avatar$fr extends Translations$avatar$en { + _Translations$avatar$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Votre photo ou avatar'; + @override String get fromPhoto => 'Prendre ou choisir une photo'; + @override String get illustration => 'Ou choisir une illustration'; + @override String get remove => 'Supprimer'; +} + +// Path: favorites +class _Translations$favorites$fr extends Translations$favorites$en { + _Translations$favorites$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoris'; + @override String get empty => 'Pas encore de favoris. Enregistrez les offres que vous aimez du marché.'; + @override String get save => 'Ajouter aux favoris'; + @override String get remove => 'Retirer des favoris'; + @override String get unavailable => 'Plus disponible'; +} + +// Path: seedSaving +class _Translations$seedSaving$fr extends Translations$seedSaving$en { + _Translations$seedSaving$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Conserver sa semence'; + @override String get subtitle => 'Ce qu\'il faut pour garder la variété fidèle'; + @override String get lifeCycle => 'Cycle'; + @override String get cycleAnnual => 'Annuelle'; + @override String get cycleBiennial => 'Bisannuelle — graines la 2e année'; + @override String get cyclePerennial => 'Vivace'; + @override String get pollination => 'Pollinisation'; + @override String get pollSelf => 'Autoféconde'; + @override String get pollCross => 'Se croise avec d\'autres'; + @override String get pollMixed => 'S\'autoféconde, parfois se croise'; + @override String get byInsect => 'par les insectes'; + @override String get byWind => 'par le vent'; + @override String get isolation => 'À isoler'; + @override String isolationRange({required Object min, required Object max}) => '${min}–${max} m des autres variétés'; + @override String isolationSingle({required Object min}) => '${min} m des autres variétés'; + @override String get plants => 'Préserver plusieurs plantes'; + @override String plantsValue({required Object n}) => 'D\'au moins ${n} plantes'; + @override String get processing => 'Nettoyer la semence'; + @override String get procDry => 'Semence sèche (dépiquage)'; + @override String get procWet => 'Semence humide (fermentation et rinçage)'; + @override String get difficulty => 'Difficulté'; + @override String get diffEasy => 'Facile'; + @override String get diffMedium => 'Moyen'; + @override String get diffHard => 'Difficile'; + @override String get advisory => 'Guide général — adaptez-le à votre climat et à votre variété.'; + @override String get sourcePrefix => 'Source'; +} + +// Path: calendar +class _Translations$calendar$fr extends Translations$calendar$en { + _Translations$calendar$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Ce mois'; + @override String get filterChip => 'Ce mois'; + @override String get selfNote => 'Ce que vous avez noté dans vos variétés.'; + @override String nothing({required Object month}) => 'Rien noté pour ${month}.'; +} + +// Path: app +class _Translations$app$fr extends Translations$app$en { + _Translations$app$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Tane'; +} + +// Path: bootstrap +class _Translations$bootstrap$fr extends Translations$bootstrap$en { + _Translations$bootstrap$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get failed => 'Tane n\'a pas pu démarrer'; + @override String get retry => 'Réessayer'; +} + +// Path: common +class _Translations$common$fr extends Translations$common$en { + _Translations$common$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get save => 'Enregistrer'; + @override String get cancel => 'Annuler'; + @override String get delete => 'Supprimer'; + @override String get edit => 'Modifier'; + @override String get type => 'Type'; + @override String get comingSoon => 'À venir'; + @override String get offline => 'Vous êtes hors ligne — le partage est en pause'; +} + +// Path: home +class _Translations$home$fr extends Translations$home$en { + _Translations$home$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get tagline => 'Partagez et cultivez des graines locales'; + @override String get openMarket => 'Marché'; + @override String get openMarketSubtitle => 'Découvrez et partagez des graines à proximité'; + @override String get yourInventory => 'Votre inventaire'; + @override String get yourInventorySubtitle => 'Gérez vos graines'; +} + +// Path: photo +class _Translations$photo$fr extends Translations$photo$en { + _Translations$photo$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get camera => 'Prendre une photo'; + @override String get gallery => 'Choisir de la galerie'; + @override String get setAsCover => 'Mettre en couverture'; + @override String get isCover => 'Photo de couverture'; + @override String get deleteConfirm => 'Supprimer cette photo ?'; +} + +// Path: menu +class _Translations$menu$fr extends Translations$menu$en { + _Translations$menu$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get tagline => 'votre grainothèque'; + @override String get inventory => 'Inventaire'; + @override String get market => 'Marché'; + @override String get profile => 'Votre profil'; + @override String get chat => 'Messages'; + @override String get wishlist => 'Favoris'; + @override String get following => 'Abonnements'; + @override String get plantares => 'Plantares'; + @override String get sales => 'Ventes'; + @override String get calendar => 'Calendrier'; + @override String get settings => 'Paramètres'; +} + +// Path: settings +class _Translations$settings$fr extends Translations$settings$en { + _Translations$settings$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get language => 'Langue'; + @override String get systemLanguage => 'Langue du système'; + @override String get langEs => 'Español'; + @override String get langEn => 'English'; + @override String get langPt => 'Português'; + @override String get langAst => 'Asturianu'; + @override String get langFr => 'Français'; + @override String get langDe => 'Deutsch'; + @override String get langJa => '日本語'; + @override String get about => 'À propos'; + @override String get aboutText => 'Inventaire local-first et chiffré pour les semences traditionnelles. AGPL-3.0.'; + @override String get aboutOpen => 'À propos de Tane'; +} + +// Path: backup +class _Translations$backup$fr extends Translations$backup$en { + _Translations$backup$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Sauvegarde & restauration'; + @override String get autoBackupTitle => 'Sauvegardes automatiques'; + @override String autoBackupLast({required Object date, required Object days}) => 'Dernière copie ${date} · tous les ${days} jours'; + @override String autoBackupNone({required Object days}) => 'Une copie est conservée automatiquement tous les ${days} jours'; + @override String get exportJson => 'Enregistrer une sauvegarde'; + @override String get exportJsonSubtitle => 'Une copie complète à conserver précieusement, restaurer plus tard ou transférer sur un autre appareil'; + @override String get importJson => 'Restaurer une sauvegarde'; + @override String get importJsonSubtitle => 'Récupérez une copie enregistrée — rien ne sera dupliqué'; + @override String get exportCsv => 'Exporter vers un tableur'; + @override String get exportCsvSubtitle => 'Une liste simple pour Excel ou LibreOffice — sans photos'; + @override String get importCsv => 'Importer une liste'; + @override String get importCsvSubtitle => 'Ajoutez des entrées depuis un tableur'; + @override String get importConfirmTitle => 'Restaurer une sauvegarde ?'; + @override String get importConfirmBody => 'Les entrées fusionnent avec votre inventaire ; si une entrée existe des deux côtés, c\'est la version la plus récente qui gagne. Rien ne sera dupliqué.'; + @override String get importCsvConfirmTitle => 'Importer une liste ?'; + @override String get importCsvConfirmBody => 'Chaque ligne est ajoutée comme une nouvelle entrée. Cela ne fusionne ni ne remplace, donc importer le même fichier deux fois l\'ajoute deux fois.'; + @override String get importAction => 'Importer'; + @override String get exportSaved => 'Copie enregistrée'; + @override String get cancelled => 'Annulé'; + @override String importDone({required Object added, required Object updated}) => 'Importé : ${added} nouvelles, ${updated} actualisées'; + @override String importCsvDone({required Object count}) => 'Ajoutées ${count} entrées'; + @override String get importFailed => 'Ce fichier n\'a pas pu être lu comme une sauvegarde Tane'; + @override String get failed => 'Quelque chose s\'est mal passé'; + @override String get recoveryTitle => 'Votre code de récupération'; + @override String get recoverySubtitle => 'Imprimez-le et gardez-le en sécurité — il ouvre vos sauvegardes sur un nouvel appareil'; + @override String get recoveryIntro => 'Ce code ouvre vos sauvegardes et retrouve votre grainothèque sur n\'importe quel appareil. Conservez deux copies imprimées en lieux sûrs, comme votre meilleure graine. Quiconque l\'obtient peut lire vos sauvegardes, alors ne le partagez avec personne.'; + @override String get recoveryCopy => 'Copier'; + @override String get recoverySave => 'Enregistrer la feuille'; + @override String get recoverySheetTitle => 'Tane — votre feuille de récupération'; + @override String get recoveryPromptTitle => 'Entrez votre code de récupération'; + @override String get recoveryPromptBody => 'Cette sauvegarde a été enregistrée avec un autre code. Entrez le code de votre feuille de récupération pour l\'ouvrir.'; + @override String get recoveryWrongCode => 'Ce code n\'ouvre pas cette sauvegarde'; +} + +// Path: about +class _Translations$about$fr extends Translations$about$en { + _Translations$about$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'À propos'; + @override String get kanji => '種'; + @override String get tagline => 'Une app local-first et décentralisée pour gérer et partager des semences et plantules traditionnelles.'; + @override String get intro => 'Tane (種, « graine » en japonais) aide les gens et les collectifs à gérer un inventaire bienveillant de leur grainothèque, décider ce qu\'ils offrent et le partager localement — sans intermédiaire central qui pourrait contrôler, censurer ou être poursuivi pour cela. Son nom vient de tanemaki (種まき), « semer / disperser des graines ». L\'objectif est à la fois pratique et politique : soutenir les variétés de semences traditionnelles et résister au monopole des semenciers.'; + @override String get heritage => 'Le nom honore les anciennes traditions d\'entraide japonaises autour du riz — yui (travail communautaire partagé) et tanomoshi (caisses de secours mutuel) — qui ont inspiré le document Plantare, la « monnaie communautaire pour l\'échange de semences » (BAH-Semillero, 2009, CC-BY-SA). Tane est le Plantare numérique.'; + @override String get version => 'Version'; + @override String get license => 'Licence'; + @override String get licenseValue => 'AGPL-3.0'; + @override String get website => 'Site web'; + @override String get sourceCode => 'Code source'; + @override String get translate => 'Aider à traduire'; + @override String get translateSubtitle => 'Aidez à traduire Tane dans votre langue'; + @override String get openSourceLicenses => 'Licences open source'; + @override String get openSourceLicensesSubtitle => 'Bibliothèques tiers et leurs licences'; + @override String copyright({required Object years}) => '© ${years} Association Comunes, sous AGPLv3'; +} + +// Path: intro +class _Translations$intro$fr extends Translations$intro$en { + _Translations$intro$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get skip => 'Passer'; + @override String get next => 'Suivant'; + @override String get start => 'Commencer'; + @override String get menuEntry => 'Comment fonctionne Tane'; + @override late final _Translations$intro$slides$fr slides = _Translations$intro$slides$fr._(_root); +} + +// Path: inventory +class _Translations$inventory$fr extends Translations$inventory$en { + _Translations$inventory$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Inventaire'; + @override String get searchHint => 'Rechercher des graines'; + @override String get empty => 'Pas encore de graines. Appuyez sur + pour ajouter votre première.'; + @override String get noMatches => 'Aucune graine ne correspond à vos filtres.'; + @override String get clearFilters => 'Effacer les filtres'; + @override String get uncategorized => 'Non catégorisé'; + @override String get needsReproductionFilter => 'À multiplier'; + @override String get loadError => 'Impossible d\'ouvrir votre grainothèque. Elle était peut-être occupée — réessayez.'; + @override String get retry => 'Réessayer'; +} + +// Path: draft +class _Translations$draft$fr extends Translations$draft$en { + _Translations$draft$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get capture => 'Capturer des photos'; + @override String captured({required Object n}) => '${n} capturée(s) à cataloguer'; + @override String get triageTitle => 'À cataloguer'; + @override String triageCount({required Object n}) => '${n} à cataloguer'; + @override String get untitled => 'Sans titre'; + @override String get nameField => 'Nommez cette graine'; + @override String get nameHint => 'Qu\'est-ce que c\'est ?'; + @override String get suggestFromPhoto => 'Suggérer un nom depuis la photo'; + @override String get discard => 'Abandonner'; +} + +// Path: quickAdd +class _Translations$quickAdd$fr extends Translations$quickAdd$en { + _Translations$quickAdd$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Ajouter une graine'; + @override String get labelField => 'Nom'; + @override String get labelRequired => 'Donnez-lui un nom'; + @override String get addPhoto => 'Ajouter une photo'; + @override String get quantity => 'Combien ?'; + @override String get more => 'Ajouter plus…'; + @override String get save => 'Enregistrer'; + @override String get saveAndAddAnother => 'Enregistrer et ajouter une autre'; + @override String addedCount({required Object n}) => '${n} ajoutée(s)'; + @override String get cancel => 'Annuler'; +} + +// Path: detail +class _Translations$detail$fr extends Translations$detail$en { + _Translations$detail$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get notFound => 'Cette graine n\'est plus ici.'; + @override String get lots => 'Lots'; + @override String get noLots => 'Pas encore de lots.'; + @override String get names => 'Aussi connue sous le nom de'; + @override String get addName => 'Ajouter un nom'; + @override String get links => 'Liens'; + @override String get addLink => 'Ajouter un lien'; + @override String get linkUrl => 'URL'; + @override String get linkTitle => 'Titre (optionnel)'; + @override String get reference => 'En savoir plus'; + @override String get refGbif => 'GBIF'; + @override String get refWikipedia => 'Wikipedia'; + @override String get refWikispecies => 'Wikispecies'; + @override String get notes => 'Notes'; + @override String get addLot => 'Ajouter un lot'; + @override String get editLot => 'Modifier le lot'; + @override String get deleteConfirm => 'Supprimer cette graine ?'; + @override String year({required Object year}) => 'Année ${year}'; + @override String get noYear => 'Année inconnue'; +} + +// Path: germination +class _Translations$germination$fr extends Translations$germination$en { + _Translations$germination$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Germination'; + @override String get add => 'Ajouter un test'; + @override String get sampleSize => 'Échantillon'; + @override String get germinated => 'Germinées'; + @override String get none => 'Pas encore de tests de germination.'; + @override String result({required Object percent}) => '${percent}%'; +} + +// Path: viability +class _Translations$viability$fr extends Translations$viability$en { + _Translations$viability$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get expiringSoon => 'Utilisez-la ou multipliez-la cette saison'; + @override String expiringSoonYears({required Object years}) => 'Utilisez-la ou multipliez-la cette saison · se garde ~${years} an(s)'; + @override String get expired => 'Au-delà de la viabilité typique — à multiplier'; + @override String expiredYears({required Object years}) => 'Au-delà de la viabilité typique (~${years} an(s)) — à multiplier'; +} + +// Path: editVariety +class _Translations$editVariety$fr extends Translations$editVariety$en { + _Translations$editVariety$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Modifier la graine'; + @override String get name => 'Nom'; + @override String get category => 'Catégorie'; + @override String get notes => 'Notes'; + @override String get species => 'Espèce (du catalogue)'; + @override String get speciesHint => 'Rechercher une espèce…'; + @override String get speciesSuggested => 'Suggérée par le nom'; + @override String get organic => 'Biologique'; + @override String get organicHint => 'Cultivée de manière biologique (bio)'; +} + +// Path: addLot +class _Translations$addLot$fr extends Translations$addLot$en { + _Translations$addLot$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Ajouter un lot'; + @override String get year => 'Date de récolte'; + @override String get quantity => 'Combien ?'; + @override String get amount => 'Quantité'; +} + +// Path: harvest +class _Translations$harvest$fr extends Translations$harvest$en { + _Translations$harvest$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get pickTitle => 'Sélectionnez le mois / l\'année'; + @override String get anyMonth => 'N\'importe quel mois'; + @override String get noDate => 'Définir la date de récolte'; + @override List get monthNames => [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ]; +} + +// Path: lotType +class _Translations$lotType$fr extends Translations$lotType$en { + _Translations$lotType$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get seed => 'Graines'; + @override String get plant => 'Plante'; + @override String get seedling => 'Plantule'; + @override String get tree => 'Arbre / arbuste'; + @override String get bulb => 'Bulbe / tubercule'; + @override String get cutting => 'Bouture'; +} + +// Path: presentation +class _Translations$presentation$fr extends Translations$presentation$en { + _Translations$presentation$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Emballage'; + @override String get none => 'Non spécifié'; + @override String get pot => 'Pot'; + @override String get tray => 'Plateau'; + @override String get plug => 'Alvéole'; + @override String get bareRoot => 'Racines nues'; + @override String get rootBall => 'Motte'; +} + +// Path: provenance +class _Translations$provenance$fr extends Translations$provenance$en { + _Translations$provenance$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get section => 'D\'où elle vient'; + @override String get seedsFrom => 'Graines de'; + @override String get seedsFromHint => 'Qui les a cultivées ou données'; + @override String get place => 'Lieu'; + @override String get placeHint => 'D\'où elles viennent (avec région)'; + @override String get addSeedsFrom => 'Graines de'; + @override String get addPlace => 'Lieu'; +} + +// Path: abundance +class _Translations$abundance$fr extends Translations$abundance$en { + _Translations$abundance$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get add => 'Combien j\'en ai'; + @override String get title => 'Combien j\'en ai'; + @override String get none => 'Non défini'; + @override String get plentyToShare => 'De quoi partager généreusement'; + @override String get enoughToShare => 'Assez pour partager un peu'; + @override String get enoughForMe => 'Assez pour moi'; + @override String get runningLow => 'Ça commence à manquer'; +} + +// Path: share +class _Translations$share$fr extends Translations$share$en { + _Translations$share$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get add => 'La partagez-vous ?'; + @override String get title => 'La partagez-vous ?'; + @override String get nudge => 'Vous en avez beaucoup — vous pourriez en partager.'; + @override String get price => 'Prix'; + @override String get priceHint => 'Laissez vide pour en discuter plus tard'; + @override String get private => 'Juste pour moi'; + @override String get gift => 'À donner'; + @override String get exchange => 'À échanger'; + @override String get sell => 'À vendre'; + @override String get filterChip => 'Je partage'; + @override String get printCatalog => 'Imprimer ce que je partage'; + @override String get catalogTitle => 'Ce que je partage'; + @override String get catalogSaved => 'Catalogue enregistré'; + @override String get cancelled => 'Annulé'; +} + +// Path: printLabels +class _Translations$printLabels$fr extends Translations$printLabels$en { + _Translations$printLabels$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get action => 'Imprimer les étiquettes'; + @override String get title => 'Imprimer les étiquettes'; + @override String get selectHint => 'Choisissez les graines pour lesquelles imprimer les étiquettes'; + @override String get selectAll => 'Tout sélectionner'; + @override String selected({required Object n}) => '${n} sélectionnée(s)'; + @override String get none => 'Sélectionnez d\'abord les graines'; + @override String get format => 'Taille d\'étiquette'; + @override String get formatStickers => 'Petits autocollants'; + @override String get formatStickersHint => 'Beaucoup de petites étiquettes par page — à coller sur les sachets'; + @override String get formatCards => 'Grandes cartes'; + @override String get formatCardsHint => 'Moins d\'étiquettes, plus grandes — pour les pots et boîtes'; + @override String count({required Object n}) => '${n} étiquette(s)'; + @override String get save => 'Enregistrer les étiquettes'; + @override String get saved => 'Étiquettes enregistrées'; + @override String get cancelled => 'Annulé'; +} + +// Path: cropCalendar +class _Translations$cropCalendar$fr extends Translations$cropCalendar$en { + _Translations$cropCalendar$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get add => 'Calendrier de culture'; + @override String get title => 'Calendrier de culture'; + @override String get sow => 'Semis'; + @override String get transplant => 'Repiquage'; + @override String get flowering => 'Floraison'; + @override String get fruiting => 'Fructification'; + @override String get seedHarvest => 'Récolte de semences'; + @override String get editorHint => 'Notez les mois typiques pour cette variété dans votre région — ce sont vos propres notes.'; + @override String get unset => '—'; +} + +// Path: needsReproduction +class _Translations$needsReproduction$fr extends Translations$needsReproduction$en { + _Translations$needsReproduction$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get label => 'À multiplier cette saison'; + @override String get hint => 'Cultivez-la avant que la semence ne manque'; + @override String get badge => 'À multiplier'; +} + +// Path: preservation +class _Translations$preservation$fr extends Translations$preservation$en { + _Translations$preservation$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get add => 'Comment c\'est conservé'; + @override String get title => 'Comment c\'est conservé'; + @override String get none => 'Non spécifié'; + @override String get jarWithDesiccant => 'Pot avec dessiccant'; + @override String get glassJar => 'Pot en verre'; + @override String get paperEnvelope => 'Enveloppe en papier'; + @override String get paperBag => 'Sac en papier'; + @override String get plasticBag => 'Sac en plastique'; +} + +// Path: conditionCheck +class _Translations$conditionCheck$fr extends Translations$conditionCheck$en { + _Translations$conditionCheck$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get advanced => 'Conservation et détails de la grainothèque'; + @override String get title => 'Contrôles de conservation'; + @override String get add => 'Ajouter un contrôle'; + @override String get containers => 'Pots / récipients'; + @override String get desiccant => 'Dessiccant'; + @override String get none => 'Pas encore de contrôles.'; + @override String summary({required Object count, required Object state}) => '${count} pot(s) · ${state}'; +} + +// Path: desiccant +class _Translations$desiccant$fr extends Translations$desiccant$en { + _Translations$desiccant$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get none => 'Aucun'; + @override String get add => 'En ajouter'; + @override String get replace => 'À changer'; + @override String get dry => 'Bleu — sec'; + @override String get fresh => 'Fraîchement mis'; +} + +// Path: unit +class _Translations$unit$fr extends Translations$unit$en { + _Translations$unit$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get aFew => 'quelques'; + @override String get some => 'quelques'; + @override String get plenty => 'beaucoup'; + @override String get pinch => 'une pincée'; + @override late final _Translations$unit$handful$fr handful = _Translations$unit$handful$fr._(_root); + @override late final _Translations$unit$teaspoon$fr teaspoon = _Translations$unit$teaspoon$fr._(_root); + @override late final _Translations$unit$spoon$fr spoon = _Translations$unit$spoon$fr._(_root); + @override late final _Translations$unit$cup$fr cup = _Translations$unit$cup$fr._(_root); + @override late final _Translations$unit$jar$fr jar = _Translations$unit$jar$fr._(_root); + @override late final _Translations$unit$sack$fr sack = _Translations$unit$sack$fr._(_root); + @override late final _Translations$unit$packet$fr packet = _Translations$unit$packet$fr._(_root); + @override late final _Translations$unit$cob$fr cob = _Translations$unit$cob$fr._(_root); + @override late final _Translations$unit$pod$fr pod = _Translations$unit$pod$fr._(_root); + @override late final _Translations$unit$ear$fr ear = _Translations$unit$ear$fr._(_root); + @override late final _Translations$unit$head$fr head = _Translations$unit$head$fr._(_root); + @override late final _Translations$unit$fruit$fr fruit = _Translations$unit$fruit$fr._(_root); + @override late final _Translations$unit$bulb$fr bulb = _Translations$unit$bulb$fr._(_root); + @override late final _Translations$unit$tuber$fr tuber = _Translations$unit$tuber$fr._(_root); + @override late final _Translations$unit$seedHead$fr seedHead = _Translations$unit$seedHead$fr._(_root); + @override late final _Translations$unit$bunch$fr bunch = _Translations$unit$bunch$fr._(_root); + @override late final _Translations$unit$plant$fr plant = _Translations$unit$plant$fr._(_root); + @override late final _Translations$unit$pot$fr pot = _Translations$unit$pot$fr._(_root); + @override late final _Translations$unit$tray$fr tray = _Translations$unit$tray$fr._(_root); + @override late final _Translations$unit$seedling$fr seedling = _Translations$unit$seedling$fr._(_root); + @override late final _Translations$unit$tree$fr tree = _Translations$unit$tree$fr._(_root); + @override late final _Translations$unit$cutting$fr cutting = _Translations$unit$cutting$fr._(_root); + @override late final _Translations$unit$grams$fr grams = _Translations$unit$grams$fr._(_root); + @override late final _Translations$unit$count$fr count = _Translations$unit$count$fr._(_root); +} + +// Path: market +class _Translations$market$fr extends Translations$market$en { + _Translations$market$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Graines près de vous'; + @override String get subtitle => 'Ce que les autres partagent à proximité'; + @override String get notSetUp => 'Le partage n\'est pas encore configuré'; + @override String get notSetUpBody => 'Activez le partage pour voir et donner des graines aux gens à proximité. C\'est volontairement approximatif — votre zone, jamais votre adresse exacte.'; + @override String get setUp => 'Configurer le partage'; + @override String get cantReach => 'Impossible de se connecter aux serveurs communautaires en ce moment'; + @override String get cantReachBody => 'Réessayez, ou vérifiez les serveurs sous Configuration avancée.'; + @override String get retry => 'Réessayer'; + @override String get setArea => 'Définir votre zone'; + @override String get setAreaBody => 'Dites au marché approximativement où vous êtes pour voir les graines à proximité.'; + @override String get searching => 'Recherche autour de votre zone…'; + @override String get empty => 'Aucune graine n\'est encore partagée près de vous'; + @override String get searchHint => 'Rechercher parmi ces graines'; + @override String get noMatches => 'Aucune graine partagée ne correspond à votre recherche'; + @override String get near => 'Près de vous'; + @override String get contact => 'Message'; + @override String get mine => 'Vous'; + @override String get configTitle => 'Configuration du partage'; + @override String get setupIntro => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.'; + @override String get areaLabel => 'Votre zone'; + @override String get areaHelp => 'Gardée approximative volontairement — votre zone, jamais un point exact.'; + @override String get areaSet => 'Votre zone est définie — approximative, jamais votre point exact'; + @override String get areaNotSet => 'Zone non définie — utilisez votre position, ou ajoutez un code sous Avancé'; + @override String get advanced => 'Avancé'; + @override String get areaCodeLabel => 'Code de zone'; + @override String get areaCodeHint => 'Un code court comme sp3e9 — pas un nom de lieu'; + @override String get serversLabel => 'Serveurs communautaires'; + @override String get serversHelp => 'Choisissez les serveurs à utiliser. Laissez les valeurs par défaut si vous n\'êtes pas sûr.'; + @override String get serversAdvanced => 'Ajouter un autre serveur'; + @override String get serverAddress => 'Adresse du serveur'; + @override String get serverInvalid => 'Entrez une adresse valide (wss://…)'; + @override String get save => 'Enregistrer'; + @override String get saved => 'Enregistré'; + @override String get wanted => 'Recherché'; + @override String get shareMine => 'Partager mes graines'; + @override String sharedCount({required Object n}) => '${n} graines partagées'; + @override String get nothingToShare => 'Marquez d\'abord certaines graines à donner, échanger ou vendre'; + @override String get useLocation => 'Utiliser ma position approximative'; + @override String get locationFailed => 'Impossible d\'obtenir votre position — vérifiez que la localisation est activée et la permission accordée'; + @override String get queued => 'Enregistré — nous partagerons cela quand vous serez connecté'; + @override String get shareFailed => 'Impossible de se connecter aux serveurs communautaires — vos graines n\'ont pas été partagées. Réessayez dans un instant.'; + @override String get rangeLabel => 'Jusqu\'où chercher'; + @override String get rangeNear => 'Très proche'; + @override String get rangeArea => 'Autour d\'ici'; + @override String get rangeRegion => 'Ma région'; + @override String get sharedBy => 'Partagé par'; + @override String get noProfile => 'Cette personne n\'a pas encore partagé son profil'; + @override String get copyId => 'Copier le code'; + @override String get idCopied => 'Code copié'; + @override String get photo => 'Photo'; +} + +// Path: profile +class _Translations$profile$fr extends Translations$profile$en { + _Translations$profile$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Votre profil'; + @override String get name => 'Nom d\'affichage'; + @override String get nameHint => 'Comment les autres vous voient'; + @override String get about => 'À propos'; + @override String get aboutHint => 'Une courte ligne — ce que vous cultivez, où'; + @override String get g1 => 'Adresse Ğ1 (optionnel)'; + @override String get g1Hint => 'Pour que les gens puissent vous payer en Ğ1 — séparé de votre clé'; + @override String get yourId => 'Votre identité'; + @override String get idHelp => 'Partagez-la pour que les gens vous reconnaissent'; + @override String get copy => 'Copier'; + @override String get copied => 'Copié'; + @override String get save => 'Enregistrer'; + @override String get saved => 'Profil enregistré'; + @override String get identities => 'Vos identités'; + @override String get identitiesHelp => 'Gardez des identités séparées — chacune avec ses propres messages et contacts. Elles proviennent toutes de votre unique sauvegarde, donc changer n\'ajoute rien à retenir.'; + @override String identityLabel({required Object n}) => 'Identité ${n}'; + @override String get current => 'En cours d\'utilisation'; + @override String get newIdentity => 'Nouvelle identité'; + @override String get switchTitle => 'Changer d\'identité ?'; + @override String get switchBody => 'Les messages et contacts sont conservés séparément pour chaque identité. Vous pouvez revenir en arrière à tout moment.'; + @override String get switchAction => 'Changer'; +} + +// Path: chatList +class _Translations$chatList$fr extends Translations$chatList$en { + _Translations$chatList$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Messages'; + @override String get empty => 'Pas encore de conversations. Envoyez un message à quelqu\'un depuis le marché.'; +} + +// Path: chat +class _Translations$chat$fr extends Translations$chat$en { + _Translations$chat$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Messages'; + @override String get hint => 'Écrivez un message…'; + @override String get send => 'Envoyer'; + @override String get empty => 'Pas encore de messages — dites bonjour'; + @override String get offline => 'Configurez le partage pour envoyer des messages'; + @override String get payG1 => 'Payer en Ğ1'; + @override String get g1Copied => 'Adresse Ğ1 copiée — collez-la dans votre portefeuille'; + @override String get today => 'Aujourd\'hui'; + @override String get yesterday => 'Hier'; + @override String get sendError => 'Impossible d\'envoyer — vérifiez votre connexion'; + @override String get noLinks => 'Les liens ne sont pas autorisés dans les messages'; +} + +// Path: trust +class _Translations$trust$fr extends Translations$trust$en { + _Translations$trust$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get none => 'Personne ne les cautionne encore'; + @override String count({required Object n}) => 'Cautionnée par ${n}'; + @override String get vouch => 'Je connais cette personne'; + @override String get vouched => 'Vous la cautionnez'; + @override String get circle => 'Dans votre cercle'; +} + +// Path: yourPeople +class _Translations$yourPeople$fr extends Translations$yourPeople$en { + _Translations$yourPeople$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Vos contacts'; + @override String get help => 'Les gens que vous connaissez et cautionnez, et les gens qui vous cautionnent.'; + @override String get youVouchFor => 'Vous cautionnez'; + @override String get vouchesForYou => 'Ils vous cautionnent'; + @override String get youVouchForEmpty => 'Vous ne cautionnez personne pour l\'instant. Quand vous rencontrez quelqu\'un, ouvrez votre conversation avec lui et appuyez sur « Je connais cette personne ».'; + @override String get vouchesForYouEmpty => 'Personne ne vous cautionne pour l\'instant'; + @override String get revoke => 'Arrêter de cautionner'; + @override String get revokeConfirm => 'Arrêter de cautionner cette personne ?'; + @override String get offline => 'Vous êtes hors ligne — réessayez quand vous êtes connecté'; +} + +// Path: ratings +class _Translations$ratings$fr extends Translations$ratings$en { + _Translations$ratings$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get rate => 'Évaluer cette personne'; + @override String get edit => 'Modifier votre évaluation'; + @override String get commentHint => 'Comment ça s\'est passé ? (optionnel)'; + @override String fromYourCircle({required Object n}) => '${n} de gens que vous connaissez'; + @override String get retract => 'Retirer votre évaluation'; + @override String get saved => 'Évaluation enregistrée'; +} + +// Path: notifications +class _Translations$notifications$fr extends Translations$notifications$en { + _Translations$notifications$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Nouveau message de ${name}'; +} + +// Path: plantare +class _Translations$plantare$fr extends Translations$plantare$en { + _Translations$plantare$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Un Plantare est un engagement à multiplier une semence et en rendre une partie — comment une variété continue son voyage de main en main. Ce n\'est pas une vente.'; + @override String get add => 'Ajouter un engagement'; + @override String get empty => 'Pas encore d\'engagements. Quand vous partagez ou recevez une graine avec la promesse de la cultiver et d\'en renvoyer une partie, notez-le ici.'; + @override String get iReturn => 'Je la multiplie et la rends'; + @override String get owedToMe => 'Ils m\'en renvoient'; + @override String get direction => 'Qui multiplie et rend'; + @override String get counterparty => 'Avec qui ?'; + @override String get counterpartyHint => 'Une personne ou un collectif (optionnel)'; + @override String get owed => 'Qu\'est-ce qui revient ?'; + @override String get owedHint => 'p. ex. une poignée la saison prochaine (optionnel)'; + @override String get note => 'Note (optionnel)'; + @override String get save => 'Enregistrer'; + @override String get markReturned => 'Marquer comme rendu'; + @override String get markForgiven => 'Clore l\'engagement'; + @override String get reopen => 'Rouvrir'; + @override String get delete => 'Retirer'; + @override String get statusReturned => 'Rendu'; + @override String get statusForgiven => 'Clôturé'; + @override String get openSection => 'En attente'; + @override String get settledSection => 'Fermés'; + @override String get removeConfirm => 'Retirer cet engagement ?'; +} + +// Path: handover +class _Translations$handover$fr extends Translations$handover$en { + _Translations$handover$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Graines échangées'; + @override String get help => 'Un cadeau, un échange ou une vente — notez-le, avec une promesse de retour ou pas.'; + @override String get iGave => 'J\'ai donné des graines'; + @override String get iReceived => 'J\'ai reçu des graines'; + @override String get whichLot => 'Quel lot ?'; + @override String get howMuch => 'Combien ?'; + @override String get allOfIt => 'Tout'; + @override String get partOfIt => 'Une partie'; + @override String get paymentChip => 'Il y a eu un échange d\'argent'; + @override String get promiseGave => 'Ils me renverront de la graine'; + @override String get promiseReceived => 'Je rendrai de la graine'; +} + +// Path: sale +class _Translations$sale$fr extends Translations$sale$en { + _Translations$sale$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Ventes'; + @override String get help => 'Enregistrez les graines que vous avez vendues ou achetées — argent, Ğ1, ou n\'importe quelle monnaie. Un modèle distinct d\'un cadeau ou d\'un Plantare. Aucune commission n\'est jamais prélevée sur les graines.'; + @override String get add => 'Enregistrer une vente'; + @override String get empty => 'Pas encore de ventes. Notez ici ce que vous vendez ou achetez.'; + @override String get iSold => 'J\'ai vendu'; + @override String get iBought => 'J\'ai acheté'; + @override String get direction => 'Vendu ou acheté ?'; + @override String get counterparty => 'Avec qui ?'; + @override String get counterpartyHint => 'Une personne ou un collectif (optionnel)'; + @override String get amount => 'Montant'; + @override String get currency => 'Monnaie'; + @override String get currencyHint => '€, Ğ1, heures… (optionnel)'; + @override String get hours => 'heures'; + @override String get note => 'Note (optionnel)'; + @override String get save => 'Enregistrer'; + @override String get delete => 'Retirer'; + @override String get removeConfirm => 'Retirer cette vente ?'; +} + +// Path: legal +class _Translations$legal$fr extends Translations$legal$en { + _Translations$legal$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Confidentialité & règles'; + @override String get subtitle => 'Votre confidentialité, les règles du marché et le partage légal de semences'; + @override String get privacyTitle => 'Votre confidentialité'; + @override String get privacyBody => 'Tane fonctionne sans compte, et tout ce que vous enregistrez reste sur votre appareil, chiffré. Il n\'y a pas de publicités, pas de trackers, et pas de serveur Tane.\n\nRien n\'est partagé sauf si vous le choisissez : quand vous publiez une offre ou votre profil, ou envoyez un message, cela passe par des serveurs gérés par la communauté. Les offres ne portent qu\'une zone approximative — jamais votre adresse.\n\nCe que vous publiez est public, et des copies peuvent rester même après l\'avoir retiré — alors partagez avec prudence.'; + @override String get rulesTitle => 'Les règles du jeu'; + @override String get rulesBody => 'Tane est un outil, pas un magasin : les échanges sont convenus directement entre les gens, et personne ne prend de commission. Cela signifie aussi que vous êtes responsable de ce que vous offrez et envoyez.\n\nSur le marché : soyez honnête avec vos graines, n\'offrez que ce que vous pouvez partager, traitez les gens bien et ne faites pas de spam. Vous pouvez bloquer n\'importe qui et signaler les offres ou les gens qui enfreignent les règles — les signalements sont traités sur les serveurs communautaires.'; + @override String get seedsTitle => 'À propos du partage de semences et de plants'; + @override String get seedsBody => 'Donner et échanger des graines entre amateurs est largement reconnu dans la plupart des pays. La vente peut être différente : dans de nombreux endroits, vendre des graines de variétés non officiellement enregistrées est restreint — vérifiez votre législation locale avant de demander un prix.\n\nEnvoyer des graines dans un autre pays est souvent restreint aussi, et les variétés protégées commercialement ne peuvent pas être reproduites sans permission. En cas de doute, gardez-le local et faites-en un cadeau.\n\nCela vaut aussi pour les plants et jeunes plantes, mais une plante vivante peut être soumise à des règles de transport et phytosanitaires plus strictes que les graines : la déplacer entre régions ou pays peut nécessiter des contrôles supplémentaires.'; + @override String get readFull => 'Lire les documents complets en ligne'; +} + +// Path: marketGate +class _Translations$marketGate$fr extends Translations$marketGate$en { + _Translations$marketGate$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Avant de rejoindre le marché'; + @override String get intro => 'Le marché est un espace partagé entre voisins. En continuant, vous acceptez quelques règles simples :'; + @override String get ruleHonest => 'Soyez honnête avec les graines que vous offrez'; + @override String get ruleLegal => 'Partagez uniquement ce que vous êtes autorisé à partager où vous vivez'; + @override String get ruleRespect => 'Traitez les gens bien — pas de spam, pas d\'abus'; + @override String get publicNote => 'Ce que vous publiez ici est public, et des copies peuvent rester même si vous le retirez plus tard.'; + @override String get viewLegal => 'Confidentialité & règles'; + @override String get accept => 'J\'accepte'; + @override String get decline => 'Pas maintenant'; +} + +// Path: report +class _Translations$report$fr extends Translations$report$en { + _Translations$report$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get offer => 'Signaler cette offre'; + @override String get person => 'Signaler cette personne'; + @override String get title => 'Signaler'; + @override String get prompt => 'Qu\'est-ce qui ne va pas ?'; + @override String get reasonSpam => 'Spam ou escroquerie'; + @override String get reasonAbuse => 'Abusif ou irrespectueux'; + @override String get reasonIllegal => 'Graines qui ne devraient pas être offertes'; + @override String get reasonOther => 'Autre chose'; + @override String get detailsHint => 'Ajouter des détails (optionnel)'; + @override String get send => 'Envoyer le signalement'; + @override String get sentHidden => 'Signalement envoyé — vous ne verrez plus cela'; + @override String get failed => 'Impossible d\'envoyer le signalement — vérifiez votre connexion'; + @override String get alsoBlock => 'Bloquer aussi cette personne'; +} + +// Path: block +class _Translations$block$fr extends Translations$block$en { + _Translations$block$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get action => 'Bloquer cette personne'; + @override String get confirmTitle => 'Bloquer cette personne ?'; + @override String get confirmBody => 'Vous ne verrez plus ses offres ou messages. Vous pouvez la débloquer plus tard sous Personnes bloquées dans la configuration du partage.'; + @override String get confirm => 'Bloquer'; + @override String get blockedToast => 'Personne bloquée — ses offres et messages sont cachés'; + @override String get manageTitle => 'Personnes bloquées'; + @override String get manageEmpty => 'Vous n\'avez bloqué personne'; + @override String get unblock => 'Débloquer'; +} + +// Path: intro.slides +class _Translations$intro$slides$fr extends Translations$intro$slides$en { + _Translations$intro$slides$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override late final _Translations$intro$slides$welcome$fr welcome = _Translations$intro$slides$welcome$fr._(_root); + @override late final _Translations$intro$slides$inventory$fr inventory = _Translations$intro$slides$inventory$fr._(_root); + @override late final _Translations$intro$slides$privacy$fr privacy = _Translations$intro$slides$privacy$fr._(_root); + @override late final _Translations$intro$slides$share$fr share = _Translations$intro$slides$share$fr._(_root); + @override late final _Translations$intro$slides$plantare$fr plantare = _Translations$intro$slides$plantare$fr._(_root); +} + +// Path: unit.handful +class _Translations$unit$handful$fr extends Translations$unit$handful$en { + _Translations$unit$handful$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'poignée'; + @override String get plural => 'poignées'; +} + +// Path: unit.teaspoon +class _Translations$unit$teaspoon$fr extends Translations$unit$teaspoon$en { + _Translations$unit$teaspoon$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'cuillère à café'; + @override String get plural => 'cuillères à café'; +} + +// Path: unit.spoon +class _Translations$unit$spoon$fr extends Translations$unit$spoon$en { + _Translations$unit$spoon$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'cuillère'; + @override String get plural => 'cuillères'; +} + +// Path: unit.cup +class _Translations$unit$cup$fr extends Translations$unit$cup$en { + _Translations$unit$cup$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'tasse'; + @override String get plural => 'tasses'; +} + +// Path: unit.jar +class _Translations$unit$jar$fr extends Translations$unit$jar$en { + _Translations$unit$jar$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'pot'; + @override String get plural => 'pots'; +} + +// Path: unit.sack +class _Translations$unit$sack$fr extends Translations$unit$sack$en { + _Translations$unit$sack$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'sac'; + @override String get plural => 'sacs'; +} + +// Path: unit.packet +class _Translations$unit$packet$fr extends Translations$unit$packet$en { + _Translations$unit$packet$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'sachet'; + @override String get plural => 'sachets'; +} + +// Path: unit.cob +class _Translations$unit$cob$fr extends Translations$unit$cob$en { + _Translations$unit$cob$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'épi'; + @override String get plural => 'épis'; +} + +// Path: unit.pod +class _Translations$unit$pod$fr extends Translations$unit$pod$en { + _Translations$unit$pod$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'gousse'; + @override String get plural => 'gousses'; +} + +// Path: unit.ear +class _Translations$unit$ear$fr extends Translations$unit$ear$en { + _Translations$unit$ear$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'épi'; + @override String get plural => 'épis'; +} + +// Path: unit.head +class _Translations$unit$head$fr extends Translations$unit$head$en { + _Translations$unit$head$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'capitule'; + @override String get plural => 'capitules'; +} + +// Path: unit.fruit +class _Translations$unit$fruit$fr extends Translations$unit$fruit$en { + _Translations$unit$fruit$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'fruit'; + @override String get plural => 'fruits'; +} + +// Path: unit.bulb +class _Translations$unit$bulb$fr extends Translations$unit$bulb$en { + _Translations$unit$bulb$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'bulbe'; + @override String get plural => 'bulbes'; +} + +// Path: unit.tuber +class _Translations$unit$tuber$fr extends Translations$unit$tuber$en { + _Translations$unit$tuber$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'tubercule'; + @override String get plural => 'tubercules'; +} + +// Path: unit.seedHead +class _Translations$unit$seedHead$fr extends Translations$unit$seedHead$en { + _Translations$unit$seedHead$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'tête de graines'; + @override String get plural => 'têtes de graines'; +} + +// Path: unit.bunch +class _Translations$unit$bunch$fr extends Translations$unit$bunch$en { + _Translations$unit$bunch$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'botte'; + @override String get plural => 'bottes'; +} + +// Path: unit.plant +class _Translations$unit$plant$fr extends Translations$unit$plant$en { + _Translations$unit$plant$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'plante'; + @override String get plural => 'plantes'; +} + +// Path: unit.pot +class _Translations$unit$pot$fr extends Translations$unit$pot$en { + _Translations$unit$pot$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'pot'; + @override String get plural => 'pots'; +} + +// Path: unit.tray +class _Translations$unit$tray$fr extends Translations$unit$tray$en { + _Translations$unit$tray$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'plateau'; + @override String get plural => 'plateaux'; +} + +// Path: unit.seedling +class _Translations$unit$seedling$fr extends Translations$unit$seedling$en { + _Translations$unit$seedling$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'plantule'; + @override String get plural => 'plantules'; +} + +// Path: unit.tree +class _Translations$unit$tree$fr extends Translations$unit$tree$en { + _Translations$unit$tree$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'arbre'; + @override String get plural => 'arbres'; +} + +// Path: unit.cutting +class _Translations$unit$cutting$fr extends Translations$unit$cutting$en { + _Translations$unit$cutting$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'bouture'; + @override String get plural => 'boutures'; +} + +// Path: unit.grams +class _Translations$unit$grams$fr extends Translations$unit$grams$en { + _Translations$unit$grams$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'gramme'; + @override String get plural => 'grammes'; +} + +// Path: unit.count +class _Translations$unit$count$fr extends Translations$unit$count$en { + _Translations$unit$count$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get singular => 'graine'; + @override String get plural => 'graines'; +} + +// Path: intro.slides.welcome +class _Translations$intro$slides$welcome$fr extends Translations$intro$slides$welcome$en { + _Translations$intro$slides$welcome$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'La graine qui vous a menés ici'; + @override String get body => 'Chaque semence traditionnelle est une lettre écrite par des milliers de générations, passée de main en main. Nous sommes ce que nous sommes grâce à ce partage.'; +} + +// Path: intro.slides.inventory +class _Translations$intro$slides$inventory$fr extends Translations$intro$slides$inventory$en { + _Translations$intro$slides$inventory$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Votre grainothèque, dans votre poche'; + @override String get body => 'Notez ce que vous avez, de quelle année, combien et d\'où ça vient — avec le nom que vous utilisez. Une photo et un nom suffisent pour commencer.'; +} + +// Path: intro.slides.privacy +class _Translations$intro$slides$privacy$fr extends Translations$intro$slides$privacy$en { + _Translations$intro$slides$privacy$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Vôtre, et uniquement vôtre'; + @override String get body => 'Pas de compte, pas d\'internet, pas de trackers. Vos données vivent chiffrées sur votre appareil, et seul ce que vous choisissez est partagé.'; +} + +// Path: intro.slides.share +class _Translations$intro$slides$share$fr extends Translations$intro$slides$share$en { + _Translations$intro$slides$share$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Partager, comme cela s\'est toujours fait'; + @override String get body => 'Offrez ce que vous avez en excédent — cadeau, échange ou vente — et laissez quelqu\'un à proximité le trouver. Seule la distance approximative est affichée, jamais votre adresse ; vous fermez le marché en personne.'; +} + +// Path: intro.slides.plantare +class _Translations$intro$slides$plantare$fr extends Translations$intro$slides$plantare$en { + _Translations$intro$slides$plantare$fr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Semer c\'est multiplier'; + @override String get body => 'Avec un Plantare, celui qui reçoit la graine promet d\'en renvoyer plus tard. Et comme renvoyer signifie la cultiver, chaque prêt multiplie le bien commun.'; +} + +/// The flat map containing all translations for locale . +/// Only for edge cases! For simple maps, use the map function of this library. +/// +/// The Dart AOT compiler has issues with very large switch statements, +/// so the map is split into smaller functions (512 entries each). +extension on TranslationsFr { + dynamic _flatMapFunction(String path) { + return switch (path) { + 'avatar.title' => 'Votre photo ou avatar', + 'avatar.fromPhoto' => 'Prendre ou choisir une photo', + 'avatar.illustration' => 'Ou choisir une illustration', + 'avatar.remove' => 'Supprimer', + 'favorites.title' => 'Favoris', + 'favorites.empty' => 'Pas encore de favoris. Enregistrez les offres que vous aimez du marché.', + 'favorites.save' => 'Ajouter aux favoris', + 'favorites.remove' => 'Retirer des favoris', + 'favorites.unavailable' => 'Plus disponible', + 'seedSaving.title' => 'Conserver sa semence', + 'seedSaving.subtitle' => 'Ce qu\'il faut pour garder la variété fidèle', + 'seedSaving.lifeCycle' => 'Cycle', + 'seedSaving.cycleAnnual' => 'Annuelle', + 'seedSaving.cycleBiennial' => 'Bisannuelle — graines la 2e année', + 'seedSaving.cyclePerennial' => 'Vivace', + 'seedSaving.pollination' => 'Pollinisation', + 'seedSaving.pollSelf' => 'Autoféconde', + 'seedSaving.pollCross' => 'Se croise avec d\'autres', + 'seedSaving.pollMixed' => 'S\'autoféconde, parfois se croise', + 'seedSaving.byInsect' => 'par les insectes', + 'seedSaving.byWind' => 'par le vent', + 'seedSaving.isolation' => 'À isoler', + 'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m des autres variétés', + 'seedSaving.isolationSingle' => ({required Object min}) => '${min} m des autres variétés', + 'seedSaving.plants' => 'Préserver plusieurs plantes', + 'seedSaving.plantsValue' => ({required Object n}) => 'D\'au moins ${n} plantes', + 'seedSaving.processing' => 'Nettoyer la semence', + 'seedSaving.procDry' => 'Semence sèche (dépiquage)', + 'seedSaving.procWet' => 'Semence humide (fermentation et rinçage)', + 'seedSaving.difficulty' => 'Difficulté', + 'seedSaving.diffEasy' => 'Facile', + 'seedSaving.diffMedium' => 'Moyen', + 'seedSaving.diffHard' => 'Difficile', + 'seedSaving.advisory' => 'Guide général — adaptez-le à votre climat et à votre variété.', + 'seedSaving.sourcePrefix' => 'Source', + 'calendar.title' => 'Ce mois', + 'calendar.filterChip' => 'Ce mois', + 'calendar.selfNote' => 'Ce que vous avez noté dans vos variétés.', + 'calendar.nothing' => ({required Object month}) => 'Rien noté pour ${month}.', + 'app.title' => 'Tane', + 'bootstrap.failed' => 'Tane n\'a pas pu démarrer', + 'bootstrap.retry' => 'Réessayer', + 'common.save' => 'Enregistrer', + 'common.cancel' => 'Annuler', + 'common.delete' => 'Supprimer', + 'common.edit' => 'Modifier', + 'common.type' => 'Type', + 'common.comingSoon' => 'À venir', + 'common.offline' => 'Vous êtes hors ligne — le partage est en pause', + 'home.tagline' => 'Partagez et cultivez des graines locales', + 'home.openMarket' => 'Marché', + 'home.openMarketSubtitle' => 'Découvrez et partagez des graines à proximité', + 'home.yourInventory' => 'Votre inventaire', + 'home.yourInventorySubtitle' => 'Gérez vos graines', + 'photo.camera' => 'Prendre une photo', + 'photo.gallery' => 'Choisir de la galerie', + 'photo.setAsCover' => 'Mettre en couverture', + 'photo.isCover' => 'Photo de couverture', + 'photo.deleteConfirm' => 'Supprimer cette photo ?', + 'menu.tagline' => 'votre grainothèque', + 'menu.inventory' => 'Inventaire', + 'menu.market' => 'Marché', + 'menu.profile' => 'Votre profil', + 'menu.chat' => 'Messages', + 'menu.wishlist' => 'Favoris', + 'menu.following' => 'Abonnements', + 'menu.plantares' => 'Plantares', + 'menu.sales' => 'Ventes', + 'menu.calendar' => 'Calendrier', + 'menu.settings' => 'Paramètres', + 'settings.language' => 'Langue', + 'settings.systemLanguage' => 'Langue du système', + 'settings.langEs' => 'Español', + 'settings.langEn' => 'English', + 'settings.langPt' => 'Português', + 'settings.langAst' => 'Asturianu', + 'settings.langFr' => 'Français', + 'settings.langDe' => 'Deutsch', + 'settings.langJa' => '日本語', + 'settings.about' => 'À propos', + 'settings.aboutText' => 'Inventaire local-first et chiffré pour les semences traditionnelles. AGPL-3.0.', + 'settings.aboutOpen' => 'À propos de Tane', + 'backup.title' => 'Sauvegarde & restauration', + 'backup.autoBackupTitle' => 'Sauvegardes automatiques', + 'backup.autoBackupLast' => ({required Object date, required Object days}) => 'Dernière copie ${date} · tous les ${days} jours', + 'backup.autoBackupNone' => ({required Object days}) => 'Une copie est conservée automatiquement tous les ${days} jours', + 'backup.exportJson' => 'Enregistrer une sauvegarde', + 'backup.exportJsonSubtitle' => 'Une copie complète à conserver précieusement, restaurer plus tard ou transférer sur un autre appareil', + 'backup.importJson' => 'Restaurer une sauvegarde', + 'backup.importJsonSubtitle' => 'Récupérez une copie enregistrée — rien ne sera dupliqué', + 'backup.exportCsv' => 'Exporter vers un tableur', + 'backup.exportCsvSubtitle' => 'Une liste simple pour Excel ou LibreOffice — sans photos', + 'backup.importCsv' => 'Importer une liste', + 'backup.importCsvSubtitle' => 'Ajoutez des entrées depuis un tableur', + 'backup.importConfirmTitle' => 'Restaurer une sauvegarde ?', + 'backup.importConfirmBody' => 'Les entrées fusionnent avec votre inventaire ; si une entrée existe des deux côtés, c\'est la version la plus récente qui gagne. Rien ne sera dupliqué.', + 'backup.importCsvConfirmTitle' => 'Importer une liste ?', + 'backup.importCsvConfirmBody' => 'Chaque ligne est ajoutée comme une nouvelle entrée. Cela ne fusionne ni ne remplace, donc importer le même fichier deux fois l\'ajoute deux fois.', + 'backup.importAction' => 'Importer', + 'backup.exportSaved' => 'Copie enregistrée', + 'backup.cancelled' => 'Annulé', + 'backup.importDone' => ({required Object added, required Object updated}) => 'Importé : ${added} nouvelles, ${updated} actualisées', + 'backup.importCsvDone' => ({required Object count}) => 'Ajoutées ${count} entrées', + 'backup.importFailed' => 'Ce fichier n\'a pas pu être lu comme une sauvegarde Tane', + 'backup.failed' => 'Quelque chose s\'est mal passé', + 'backup.recoveryTitle' => 'Votre code de récupération', + 'backup.recoverySubtitle' => 'Imprimez-le et gardez-le en sécurité — il ouvre vos sauvegardes sur un nouvel appareil', + 'backup.recoveryIntro' => 'Ce code ouvre vos sauvegardes et retrouve votre grainothèque sur n\'importe quel appareil. Conservez deux copies imprimées en lieux sûrs, comme votre meilleure graine. Quiconque l\'obtient peut lire vos sauvegardes, alors ne le partagez avec personne.', + 'backup.recoveryCopy' => 'Copier', + 'backup.recoverySave' => 'Enregistrer la feuille', + 'backup.recoverySheetTitle' => 'Tane — votre feuille de récupération', + 'backup.recoveryPromptTitle' => 'Entrez votre code de récupération', + 'backup.recoveryPromptBody' => 'Cette sauvegarde a été enregistrée avec un autre code. Entrez le code de votre feuille de récupération pour l\'ouvrir.', + 'backup.recoveryWrongCode' => 'Ce code n\'ouvre pas cette sauvegarde', + 'about.title' => 'À propos', + 'about.kanji' => '種', + 'about.tagline' => 'Une app local-first et décentralisée pour gérer et partager des semences et plantules traditionnelles.', + 'about.intro' => 'Tane (種, « graine » en japonais) aide les gens et les collectifs à gérer un inventaire bienveillant de leur grainothèque, décider ce qu\'ils offrent et le partager localement — sans intermédiaire central qui pourrait contrôler, censurer ou être poursuivi pour cela. Son nom vient de tanemaki (種まき), « semer / disperser des graines ». L\'objectif est à la fois pratique et politique : soutenir les variétés de semences traditionnelles et résister au monopole des semenciers.', + 'about.heritage' => 'Le nom honore les anciennes traditions d\'entraide japonaises autour du riz — yui (travail communautaire partagé) et tanomoshi (caisses de secours mutuel) — qui ont inspiré le document Plantare, la « monnaie communautaire pour l\'échange de semences » (BAH-Semillero, 2009, CC-BY-SA). Tane est le Plantare numérique.', + 'about.version' => 'Version', + 'about.license' => 'Licence', + 'about.licenseValue' => 'AGPL-3.0', + 'about.website' => 'Site web', + 'about.sourceCode' => 'Code source', + 'about.translate' => 'Aider à traduire', + 'about.translateSubtitle' => 'Aidez à traduire Tane dans votre langue', + 'about.openSourceLicenses' => 'Licences open source', + 'about.openSourceLicensesSubtitle' => 'Bibliothèques tiers et leurs licences', + 'about.copyright' => ({required Object years}) => '© ${years} Association Comunes, sous AGPLv3', + 'intro.skip' => 'Passer', + 'intro.next' => 'Suivant', + 'intro.start' => 'Commencer', + 'intro.menuEntry' => 'Comment fonctionne Tane', + 'intro.slides.welcome.title' => 'La graine qui vous a menés ici', + 'intro.slides.welcome.body' => 'Chaque semence traditionnelle est une lettre écrite par des milliers de générations, passée de main en main. Nous sommes ce que nous sommes grâce à ce partage.', + 'intro.slides.inventory.title' => 'Votre grainothèque, dans votre poche', + 'intro.slides.inventory.body' => 'Notez ce que vous avez, de quelle année, combien et d\'où ça vient — avec le nom que vous utilisez. Une photo et un nom suffisent pour commencer.', + 'intro.slides.privacy.title' => 'Vôtre, et uniquement vôtre', + 'intro.slides.privacy.body' => 'Pas de compte, pas d\'internet, pas de trackers. Vos données vivent chiffrées sur votre appareil, et seul ce que vous choisissez est partagé.', + 'intro.slides.share.title' => 'Partager, comme cela s\'est toujours fait', + 'intro.slides.share.body' => 'Offrez ce que vous avez en excédent — cadeau, échange ou vente — et laissez quelqu\'un à proximité le trouver. Seule la distance approximative est affichée, jamais votre adresse ; vous fermez le marché en personne.', + 'intro.slides.plantare.title' => 'Semer c\'est multiplier', + 'intro.slides.plantare.body' => 'Avec un Plantare, celui qui reçoit la graine promet d\'en renvoyer plus tard. Et comme renvoyer signifie la cultiver, chaque prêt multiplie le bien commun.', + 'inventory.title' => 'Inventaire', + 'inventory.searchHint' => 'Rechercher des graines', + 'inventory.empty' => 'Pas encore de graines. Appuyez sur + pour ajouter votre première.', + 'inventory.noMatches' => 'Aucune graine ne correspond à vos filtres.', + 'inventory.clearFilters' => 'Effacer les filtres', + 'inventory.uncategorized' => 'Non catégorisé', + 'inventory.needsReproductionFilter' => 'À multiplier', + 'inventory.loadError' => 'Impossible d\'ouvrir votre grainothèque. Elle était peut-être occupée — réessayez.', + 'inventory.retry' => 'Réessayer', + 'draft.capture' => 'Capturer des photos', + 'draft.captured' => ({required Object n}) => '${n} capturée(s) à cataloguer', + 'draft.triageTitle' => 'À cataloguer', + 'draft.triageCount' => ({required Object n}) => '${n} à cataloguer', + 'draft.untitled' => 'Sans titre', + 'draft.nameField' => 'Nommez cette graine', + 'draft.nameHint' => 'Qu\'est-ce que c\'est ?', + 'draft.suggestFromPhoto' => 'Suggérer un nom depuis la photo', + 'draft.discard' => 'Abandonner', + 'quickAdd.title' => 'Ajouter une graine', + 'quickAdd.labelField' => 'Nom', + 'quickAdd.labelRequired' => 'Donnez-lui un nom', + 'quickAdd.addPhoto' => 'Ajouter une photo', + 'quickAdd.quantity' => 'Combien ?', + 'quickAdd.more' => 'Ajouter plus…', + 'quickAdd.save' => 'Enregistrer', + 'quickAdd.saveAndAddAnother' => 'Enregistrer et ajouter une autre', + 'quickAdd.addedCount' => ({required Object n}) => '${n} ajoutée(s)', + 'quickAdd.cancel' => 'Annuler', + 'detail.notFound' => 'Cette graine n\'est plus ici.', + 'detail.lots' => 'Lots', + 'detail.noLots' => 'Pas encore de lots.', + 'detail.names' => 'Aussi connue sous le nom de', + 'detail.addName' => 'Ajouter un nom', + 'detail.links' => 'Liens', + 'detail.addLink' => 'Ajouter un lien', + 'detail.linkUrl' => 'URL', + 'detail.linkTitle' => 'Titre (optionnel)', + 'detail.reference' => 'En savoir plus', + 'detail.refGbif' => 'GBIF', + 'detail.refWikipedia' => 'Wikipedia', + 'detail.refWikispecies' => 'Wikispecies', + 'detail.notes' => 'Notes', + 'detail.addLot' => 'Ajouter un lot', + 'detail.editLot' => 'Modifier le lot', + 'detail.deleteConfirm' => 'Supprimer cette graine ?', + 'detail.year' => ({required Object year}) => 'Année ${year}', + 'detail.noYear' => 'Année inconnue', + 'germination.title' => 'Germination', + 'germination.add' => 'Ajouter un test', + 'germination.sampleSize' => 'Échantillon', + 'germination.germinated' => 'Germinées', + 'germination.none' => 'Pas encore de tests de germination.', + 'germination.result' => ({required Object percent}) => '${percent}%', + 'viability.expiringSoon' => 'Utilisez-la ou multipliez-la cette saison', + 'viability.expiringSoonYears' => ({required Object years}) => 'Utilisez-la ou multipliez-la cette saison · se garde ~${years} an(s)', + 'viability.expired' => 'Au-delà de la viabilité typique — à multiplier', + 'viability.expiredYears' => ({required Object years}) => 'Au-delà de la viabilité typique (~${years} an(s)) — à multiplier', + 'editVariety.title' => 'Modifier la graine', + 'editVariety.name' => 'Nom', + 'editVariety.category' => 'Catégorie', + 'editVariety.notes' => 'Notes', + 'editVariety.species' => 'Espèce (du catalogue)', + 'editVariety.speciesHint' => 'Rechercher une espèce…', + 'editVariety.speciesSuggested' => 'Suggérée par le nom', + 'editVariety.organic' => 'Biologique', + 'editVariety.organicHint' => 'Cultivée de manière biologique (bio)', + 'addLot.title' => 'Ajouter un lot', + 'addLot.year' => 'Date de récolte', + 'addLot.quantity' => 'Combien ?', + 'addLot.amount' => 'Quantité', + 'harvest.pickTitle' => 'Sélectionnez le mois / l\'année', + 'harvest.anyMonth' => 'N\'importe quel mois', + 'harvest.noDate' => 'Définir la date de récolte', + 'harvest.monthNames.0' => 'janvier', + 'harvest.monthNames.1' => 'février', + 'harvest.monthNames.2' => 'mars', + 'harvest.monthNames.3' => 'avril', + 'harvest.monthNames.4' => 'mai', + 'harvest.monthNames.5' => 'juin', + 'harvest.monthNames.6' => 'juillet', + 'harvest.monthNames.7' => 'août', + 'harvest.monthNames.8' => 'septembre', + 'harvest.monthNames.9' => 'octobre', + 'harvest.monthNames.10' => 'novembre', + 'harvest.monthNames.11' => 'décembre', + 'lotType.seed' => 'Graines', + 'lotType.plant' => 'Plante', + 'lotType.seedling' => 'Plantule', + 'lotType.tree' => 'Arbre / arbuste', + 'lotType.bulb' => 'Bulbe / tubercule', + 'lotType.cutting' => 'Bouture', + 'presentation.title' => 'Emballage', + 'presentation.none' => 'Non spécifié', + 'presentation.pot' => 'Pot', + 'presentation.tray' => 'Plateau', + 'presentation.plug' => 'Alvéole', + 'presentation.bareRoot' => 'Racines nues', + 'presentation.rootBall' => 'Motte', + 'provenance.section' => 'D\'où elle vient', + 'provenance.seedsFrom' => 'Graines de', + 'provenance.seedsFromHint' => 'Qui les a cultivées ou données', + 'provenance.place' => 'Lieu', + 'provenance.placeHint' => 'D\'où elles viennent (avec région)', + 'provenance.addSeedsFrom' => 'Graines de', + 'provenance.addPlace' => 'Lieu', + 'abundance.add' => 'Combien j\'en ai', + 'abundance.title' => 'Combien j\'en ai', + 'abundance.none' => 'Non défini', + 'abundance.plentyToShare' => 'De quoi partager généreusement', + 'abundance.enoughToShare' => 'Assez pour partager un peu', + 'abundance.enoughForMe' => 'Assez pour moi', + 'abundance.runningLow' => 'Ça commence à manquer', + 'share.add' => 'La partagez-vous ?', + 'share.title' => 'La partagez-vous ?', + 'share.nudge' => 'Vous en avez beaucoup — vous pourriez en partager.', + 'share.price' => 'Prix', + 'share.priceHint' => 'Laissez vide pour en discuter plus tard', + 'share.private' => 'Juste pour moi', + 'share.gift' => 'À donner', + 'share.exchange' => 'À échanger', + 'share.sell' => 'À vendre', + 'share.filterChip' => 'Je partage', + 'share.printCatalog' => 'Imprimer ce que je partage', + 'share.catalogTitle' => 'Ce que je partage', + 'share.catalogSaved' => 'Catalogue enregistré', + 'share.cancelled' => 'Annulé', + 'printLabels.action' => 'Imprimer les étiquettes', + 'printLabels.title' => 'Imprimer les étiquettes', + 'printLabels.selectHint' => 'Choisissez les graines pour lesquelles imprimer les étiquettes', + 'printLabels.selectAll' => 'Tout sélectionner', + 'printLabels.selected' => ({required Object n}) => '${n} sélectionnée(s)', + 'printLabels.none' => 'Sélectionnez d\'abord les graines', + 'printLabels.format' => 'Taille d\'étiquette', + 'printLabels.formatStickers' => 'Petits autocollants', + 'printLabels.formatStickersHint' => 'Beaucoup de petites étiquettes par page — à coller sur les sachets', + 'printLabels.formatCards' => 'Grandes cartes', + 'printLabels.formatCardsHint' => 'Moins d\'étiquettes, plus grandes — pour les pots et boîtes', + 'printLabels.count' => ({required Object n}) => '${n} étiquette(s)', + 'printLabels.save' => 'Enregistrer les étiquettes', + 'printLabels.saved' => 'Étiquettes enregistrées', + 'printLabels.cancelled' => 'Annulé', + 'cropCalendar.add' => 'Calendrier de culture', + 'cropCalendar.title' => 'Calendrier de culture', + 'cropCalendar.sow' => 'Semis', + 'cropCalendar.transplant' => 'Repiquage', + 'cropCalendar.flowering' => 'Floraison', + 'cropCalendar.fruiting' => 'Fructification', + 'cropCalendar.seedHarvest' => 'Récolte de semences', + 'cropCalendar.editorHint' => 'Notez les mois typiques pour cette variété dans votre région — ce sont vos propres notes.', + 'cropCalendar.unset' => '—', + 'needsReproduction.label' => 'À multiplier cette saison', + 'needsReproduction.hint' => 'Cultivez-la avant que la semence ne manque', + 'needsReproduction.badge' => 'À multiplier', + 'preservation.add' => 'Comment c\'est conservé', + 'preservation.title' => 'Comment c\'est conservé', + 'preservation.none' => 'Non spécifié', + 'preservation.jarWithDesiccant' => 'Pot avec dessiccant', + 'preservation.glassJar' => 'Pot en verre', + 'preservation.paperEnvelope' => 'Enveloppe en papier', + 'preservation.paperBag' => 'Sac en papier', + 'preservation.plasticBag' => 'Sac en plastique', + 'conditionCheck.advanced' => 'Conservation et détails de la grainothèque', + 'conditionCheck.title' => 'Contrôles de conservation', + 'conditionCheck.add' => 'Ajouter un contrôle', + 'conditionCheck.containers' => 'Pots / récipients', + 'conditionCheck.desiccant' => 'Dessiccant', + 'conditionCheck.none' => 'Pas encore de contrôles.', + 'conditionCheck.summary' => ({required Object count, required Object state}) => '${count} pot(s) · ${state}', + 'desiccant.none' => 'Aucun', + 'desiccant.add' => 'En ajouter', + 'desiccant.replace' => 'À changer', + 'desiccant.dry' => 'Bleu — sec', + 'desiccant.fresh' => 'Fraîchement mis', + 'unit.aFew' => 'quelques', + 'unit.some' => 'quelques', + 'unit.plenty' => 'beaucoup', + 'unit.pinch' => 'une pincée', + 'unit.handful.singular' => 'poignée', + 'unit.handful.plural' => 'poignées', + 'unit.teaspoon.singular' => 'cuillère à café', + 'unit.teaspoon.plural' => 'cuillères à café', + 'unit.spoon.singular' => 'cuillère', + 'unit.spoon.plural' => 'cuillères', + 'unit.cup.singular' => 'tasse', + 'unit.cup.plural' => 'tasses', + 'unit.jar.singular' => 'pot', + 'unit.jar.plural' => 'pots', + 'unit.sack.singular' => 'sac', + 'unit.sack.plural' => 'sacs', + 'unit.packet.singular' => 'sachet', + 'unit.packet.plural' => 'sachets', + 'unit.cob.singular' => 'épi', + 'unit.cob.plural' => 'épis', + 'unit.pod.singular' => 'gousse', + 'unit.pod.plural' => 'gousses', + 'unit.ear.singular' => 'épi', + 'unit.ear.plural' => 'épis', + 'unit.head.singular' => 'capitule', + 'unit.head.plural' => 'capitules', + 'unit.fruit.singular' => 'fruit', + 'unit.fruit.plural' => 'fruits', + 'unit.bulb.singular' => 'bulbe', + 'unit.bulb.plural' => 'bulbes', + 'unit.tuber.singular' => 'tubercule', + 'unit.tuber.plural' => 'tubercules', + 'unit.seedHead.singular' => 'tête de graines', + 'unit.seedHead.plural' => 'têtes de graines', + 'unit.bunch.singular' => 'botte', + 'unit.bunch.plural' => 'bottes', + 'unit.plant.singular' => 'plante', + 'unit.plant.plural' => 'plantes', + 'unit.pot.singular' => 'pot', + 'unit.pot.plural' => 'pots', + 'unit.tray.singular' => 'plateau', + 'unit.tray.plural' => 'plateaux', + 'unit.seedling.singular' => 'plantule', + 'unit.seedling.plural' => 'plantules', + 'unit.tree.singular' => 'arbre', + 'unit.tree.plural' => 'arbres', + 'unit.cutting.singular' => 'bouture', + 'unit.cutting.plural' => 'boutures', + 'unit.grams.singular' => 'gramme', + 'unit.grams.plural' => 'grammes', + 'unit.count.singular' => 'graine', + 'unit.count.plural' => 'graines', + 'market.title' => 'Graines près de vous', + 'market.subtitle' => 'Ce que les autres partagent à proximité', + 'market.notSetUp' => 'Le partage n\'est pas encore configuré', + 'market.notSetUpBody' => 'Activez le partage pour voir et donner des graines aux gens à proximité. C\'est volontairement approximatif — votre zone, jamais votre adresse exacte.', + 'market.setUp' => 'Configurer le partage', + 'market.cantReach' => 'Impossible de se connecter aux serveurs communautaires en ce moment', + 'market.cantReachBody' => 'Réessayez, ou vérifiez les serveurs sous Configuration avancée.', + 'market.retry' => 'Réessayer', + 'market.setArea' => 'Définir votre zone', + 'market.setAreaBody' => 'Dites au marché approximativement où vous êtes pour voir les graines à proximité.', + 'market.searching' => 'Recherche autour de votre zone…', + 'market.empty' => 'Aucune graine n\'est encore partagée près de vous', + 'market.searchHint' => 'Rechercher parmi ces graines', + 'market.noMatches' => 'Aucune graine partagée ne correspond à votre recherche', + 'market.near' => 'Près de vous', + 'market.contact' => 'Message', + 'market.mine' => 'Vous', + 'market.configTitle' => 'Configuration du partage', + 'market.setupIntro' => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.', + 'market.areaLabel' => 'Votre zone', + 'market.areaHelp' => 'Gardée approximative volontairement — votre zone, jamais un point exact.', + 'market.areaSet' => 'Votre zone est définie — approximative, jamais votre point exact', + 'market.areaNotSet' => 'Zone non définie — utilisez votre position, ou ajoutez un code sous Avancé', + 'market.advanced' => 'Avancé', + 'market.areaCodeLabel' => 'Code de zone', + 'market.areaCodeHint' => 'Un code court comme sp3e9 — pas un nom de lieu', + 'market.serversLabel' => 'Serveurs communautaires', + 'market.serversHelp' => 'Choisissez les serveurs à utiliser. Laissez les valeurs par défaut si vous n\'êtes pas sûr.', + 'market.serversAdvanced' => 'Ajouter un autre serveur', + 'market.serverAddress' => 'Adresse du serveur', + 'market.serverInvalid' => 'Entrez une adresse valide (wss://…)', + 'market.save' => 'Enregistrer', + 'market.saved' => 'Enregistré', + 'market.wanted' => 'Recherché', + 'market.shareMine' => 'Partager mes graines', + 'market.sharedCount' => ({required Object n}) => '${n} graines partagées', + 'market.nothingToShare' => 'Marquez d\'abord certaines graines à donner, échanger ou vendre', + 'market.useLocation' => 'Utiliser ma position approximative', + 'market.locationFailed' => 'Impossible d\'obtenir votre position — vérifiez que la localisation est activée et la permission accordée', + 'market.queued' => 'Enregistré — nous partagerons cela quand vous serez connecté', + 'market.shareFailed' => 'Impossible de se connecter aux serveurs communautaires — vos graines n\'ont pas été partagées. Réessayez dans un instant.', + 'market.rangeLabel' => 'Jusqu\'où chercher', + 'market.rangeNear' => 'Très proche', + 'market.rangeArea' => 'Autour d\'ici', + 'market.rangeRegion' => 'Ma région', + 'market.sharedBy' => 'Partagé par', + 'market.noProfile' => 'Cette personne n\'a pas encore partagé son profil', + 'market.copyId' => 'Copier le code', + 'market.idCopied' => 'Code copié', + 'market.photo' => 'Photo', + 'profile.title' => 'Votre profil', + 'profile.name' => 'Nom d\'affichage', + 'profile.nameHint' => 'Comment les autres vous voient', + 'profile.about' => 'À propos', + 'profile.aboutHint' => 'Une courte ligne — ce que vous cultivez, où', + 'profile.g1' => 'Adresse Ğ1 (optionnel)', + 'profile.g1Hint' => 'Pour que les gens puissent vous payer en Ğ1 — séparé de votre clé', + 'profile.yourId' => 'Votre identité', + 'profile.idHelp' => 'Partagez-la pour que les gens vous reconnaissent', + 'profile.copy' => 'Copier', + 'profile.copied' => 'Copié', + 'profile.save' => 'Enregistrer', + 'profile.saved' => 'Profil enregistré', + 'profile.identities' => 'Vos identités', + 'profile.identitiesHelp' => 'Gardez des identités séparées — chacune avec ses propres messages et contacts. Elles proviennent toutes de votre unique sauvegarde, donc changer n\'ajoute rien à retenir.', + 'profile.identityLabel' => ({required Object n}) => 'Identité ${n}', + 'profile.current' => 'En cours d\'utilisation', + 'profile.newIdentity' => 'Nouvelle identité', + 'profile.switchTitle' => 'Changer d\'identité ?', + 'profile.switchBody' => 'Les messages et contacts sont conservés séparément pour chaque identité. Vous pouvez revenir en arrière à tout moment.', + 'profile.switchAction' => 'Changer', + 'chatList.title' => 'Messages', + 'chatList.empty' => 'Pas encore de conversations. Envoyez un message à quelqu\'un depuis le marché.', + 'chat.title' => 'Messages', + 'chat.hint' => 'Écrivez un message…', + 'chat.send' => 'Envoyer', + 'chat.empty' => 'Pas encore de messages — dites bonjour', + 'chat.offline' => 'Configurez le partage pour envoyer des messages', + 'chat.payG1' => 'Payer en Ğ1', + 'chat.g1Copied' => 'Adresse Ğ1 copiée — collez-la dans votre portefeuille', + 'chat.today' => 'Aujourd\'hui', + 'chat.yesterday' => 'Hier', + 'chat.sendError' => 'Impossible d\'envoyer — vérifiez votre connexion', + 'chat.noLinks' => 'Les liens ne sont pas autorisés dans les messages', + 'trust.none' => 'Personne ne les cautionne encore', + 'trust.count' => ({required Object n}) => 'Cautionnée par ${n}', + 'trust.vouch' => 'Je connais cette personne', + 'trust.vouched' => 'Vous la cautionnez', + 'trust.circle' => 'Dans votre cercle', + 'yourPeople.title' => 'Vos contacts', + 'yourPeople.help' => 'Les gens que vous connaissez et cautionnez, et les gens qui vous cautionnent.', + 'yourPeople.youVouchFor' => 'Vous cautionnez', + 'yourPeople.vouchesForYou' => 'Ils vous cautionnent', + 'yourPeople.youVouchForEmpty' => 'Vous ne cautionnez personne pour l\'instant. Quand vous rencontrez quelqu\'un, ouvrez votre conversation avec lui et appuyez sur « Je connais cette personne ».', + 'yourPeople.vouchesForYouEmpty' => 'Personne ne vous cautionne pour l\'instant', + 'yourPeople.revoke' => 'Arrêter de cautionner', + 'yourPeople.revokeConfirm' => 'Arrêter de cautionner cette personne ?', + 'yourPeople.offline' => 'Vous êtes hors ligne — réessayez quand vous êtes connecté', + 'ratings.rate' => 'Évaluer cette personne', + 'ratings.edit' => 'Modifier votre évaluation', + 'ratings.commentHint' => 'Comment ça s\'est passé ? (optionnel)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} de gens que vous connaissez', + 'ratings.retract' => 'Retirer votre évaluation', + 'ratings.saved' => 'Évaluation enregistrée', + 'notifications.newMessageFrom' => ({required Object name}) => 'Nouveau message de ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Un Plantare est un engagement à multiplier une semence et en rendre une partie — comment une variété continue son voyage de main en main. Ce n\'est pas une vente.', + 'plantare.add' => 'Ajouter un engagement', + 'plantare.empty' => 'Pas encore d\'engagements. Quand vous partagez ou recevez une graine avec la promesse de la cultiver et d\'en renvoyer une partie, notez-le ici.', + 'plantare.iReturn' => 'Je la multiplie et la rends', + 'plantare.owedToMe' => 'Ils m\'en renvoient', + 'plantare.direction' => 'Qui multiplie et rend', + 'plantare.counterparty' => 'Avec qui ?', + 'plantare.counterpartyHint' => 'Une personne ou un collectif (optionnel)', + 'plantare.owed' => 'Qu\'est-ce qui revient ?', + 'plantare.owedHint' => 'p. ex. une poignée la saison prochaine (optionnel)', + 'plantare.note' => 'Note (optionnel)', + 'plantare.save' => 'Enregistrer', + 'plantare.markReturned' => 'Marquer comme rendu', + 'plantare.markForgiven' => 'Clore l\'engagement', + 'plantare.reopen' => 'Rouvrir', + 'plantare.delete' => 'Retirer', + 'plantare.statusReturned' => 'Rendu', + 'plantare.statusForgiven' => 'Clôturé', + 'plantare.openSection' => 'En attente', + 'plantare.settledSection' => 'Fermés', + 'plantare.removeConfirm' => 'Retirer cet engagement ?', + 'handover.title' => 'Graines échangées', + 'handover.help' => 'Un cadeau, un échange ou une vente — notez-le, avec une promesse de retour ou pas.', + 'handover.iGave' => 'J\'ai donné des graines', + 'handover.iReceived' => 'J\'ai reçu des graines', + 'handover.whichLot' => 'Quel lot ?', + 'handover.howMuch' => 'Combien ?', + 'handover.allOfIt' => 'Tout', + 'handover.partOfIt' => 'Une partie', + 'handover.paymentChip' => 'Il y a eu un échange d\'argent', + 'handover.promiseGave' => 'Ils me renverront de la graine', + 'handover.promiseReceived' => 'Je rendrai de la graine', + 'sale.title' => 'Ventes', + 'sale.help' => 'Enregistrez les graines que vous avez vendues ou achetées — argent, Ğ1, ou n\'importe quelle monnaie. Un modèle distinct d\'un cadeau ou d\'un Plantare. Aucune commission n\'est jamais prélevée sur les graines.', + 'sale.add' => 'Enregistrer une vente', + 'sale.empty' => 'Pas encore de ventes. Notez ici ce que vous vendez ou achetez.', + 'sale.iSold' => 'J\'ai vendu', + 'sale.iBought' => 'J\'ai acheté', + _ => null, + } ?? switch (path) { + 'sale.direction' => 'Vendu ou acheté ?', + 'sale.counterparty' => 'Avec qui ?', + 'sale.counterpartyHint' => 'Une personne ou un collectif (optionnel)', + 'sale.amount' => 'Montant', + 'sale.currency' => 'Monnaie', + 'sale.currencyHint' => '€, Ğ1, heures… (optionnel)', + 'sale.hours' => 'heures', + 'sale.note' => 'Note (optionnel)', + 'sale.save' => 'Enregistrer', + 'sale.delete' => 'Retirer', + 'sale.removeConfirm' => 'Retirer cette vente ?', + 'legal.title' => 'Confidentialité & règles', + 'legal.subtitle' => 'Votre confidentialité, les règles du marché et le partage légal de semences', + 'legal.privacyTitle' => 'Votre confidentialité', + 'legal.privacyBody' => 'Tane fonctionne sans compte, et tout ce que vous enregistrez reste sur votre appareil, chiffré. Il n\'y a pas de publicités, pas de trackers, et pas de serveur Tane.\n\nRien n\'est partagé sauf si vous le choisissez : quand vous publiez une offre ou votre profil, ou envoyez un message, cela passe par des serveurs gérés par la communauté. Les offres ne portent qu\'une zone approximative — jamais votre adresse.\n\nCe que vous publiez est public, et des copies peuvent rester même après l\'avoir retiré — alors partagez avec prudence.', + 'legal.rulesTitle' => 'Les règles du jeu', + 'legal.rulesBody' => 'Tane est un outil, pas un magasin : les échanges sont convenus directement entre les gens, et personne ne prend de commission. Cela signifie aussi que vous êtes responsable de ce que vous offrez et envoyez.\n\nSur le marché : soyez honnête avec vos graines, n\'offrez que ce que vous pouvez partager, traitez les gens bien et ne faites pas de spam. Vous pouvez bloquer n\'importe qui et signaler les offres ou les gens qui enfreignent les règles — les signalements sont traités sur les serveurs communautaires.', + 'legal.seedsTitle' => 'À propos du partage de semences et de plants', + 'legal.seedsBody' => 'Donner et échanger des graines entre amateurs est largement reconnu dans la plupart des pays. La vente peut être différente : dans de nombreux endroits, vendre des graines de variétés non officiellement enregistrées est restreint — vérifiez votre législation locale avant de demander un prix.\n\nEnvoyer des graines dans un autre pays est souvent restreint aussi, et les variétés protégées commercialement ne peuvent pas être reproduites sans permission. En cas de doute, gardez-le local et faites-en un cadeau.\n\nCela vaut aussi pour les plants et jeunes plantes, mais une plante vivante peut être soumise à des règles de transport et phytosanitaires plus strictes que les graines : la déplacer entre régions ou pays peut nécessiter des contrôles supplémentaires.', + 'legal.readFull' => 'Lire les documents complets en ligne', + 'marketGate.title' => 'Avant de rejoindre le marché', + 'marketGate.intro' => 'Le marché est un espace partagé entre voisins. En continuant, vous acceptez quelques règles simples :', + 'marketGate.ruleHonest' => 'Soyez honnête avec les graines que vous offrez', + 'marketGate.ruleLegal' => 'Partagez uniquement ce que vous êtes autorisé à partager où vous vivez', + 'marketGate.ruleRespect' => 'Traitez les gens bien — pas de spam, pas d\'abus', + 'marketGate.publicNote' => 'Ce que vous publiez ici est public, et des copies peuvent rester même si vous le retirez plus tard.', + 'marketGate.viewLegal' => 'Confidentialité & règles', + 'marketGate.accept' => 'J\'accepte', + 'marketGate.decline' => 'Pas maintenant', + 'report.offer' => 'Signaler cette offre', + 'report.person' => 'Signaler cette personne', + 'report.title' => 'Signaler', + 'report.prompt' => 'Qu\'est-ce qui ne va pas ?', + 'report.reasonSpam' => 'Spam ou escroquerie', + 'report.reasonAbuse' => 'Abusif ou irrespectueux', + 'report.reasonIllegal' => 'Graines qui ne devraient pas être offertes', + 'report.reasonOther' => 'Autre chose', + 'report.detailsHint' => 'Ajouter des détails (optionnel)', + 'report.send' => 'Envoyer le signalement', + 'report.sentHidden' => 'Signalement envoyé — vous ne verrez plus cela', + 'report.failed' => 'Impossible d\'envoyer le signalement — vérifiez votre connexion', + 'report.alsoBlock' => 'Bloquer aussi cette personne', + 'block.action' => 'Bloquer cette personne', + 'block.confirmTitle' => 'Bloquer cette personne ?', + 'block.confirmBody' => 'Vous ne verrez plus ses offres ou messages. Vous pouvez la débloquer plus tard sous Personnes bloquées dans la configuration du partage.', + 'block.confirm' => 'Bloquer', + 'block.blockedToast' => 'Personne bloquée — ses offres et messages sont cachés', + 'block.manageTitle' => 'Personnes bloquées', + 'block.manageEmpty' => 'Vous n\'avez bloqué personne', + 'block.unblock' => 'Débloquer', + _ => null, + }; + } +} diff --git a/apps/app_seeds/lib/i18n/strings_ja.g.dart b/apps/app_seeds/lib/i18n/strings_ja.g.dart new file mode 100644 index 0000000..01cfd2e --- /dev/null +++ b/apps/app_seeds/lib/i18n/strings_ja.g.dart @@ -0,0 +1,173 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import +// dart format off + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'strings.g.dart'; + +// Path: +class TranslationsJa extends Translations with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + TranslationsJa({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = meta ?? TranslationMetadata( + locale: AppLocale.ja, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); + + late final TranslationsJa _root = this; // ignore: unused_field + + @override + TranslationsJa $copyWith({TranslationMetadata? meta}) => TranslationsJa(meta: meta ?? this.$meta); + + // Translations + @override late final _Translations$app$ja app = _Translations$app$ja._(_root); + @override late final _Translations$common$ja common = _Translations$common$ja._(_root); + @override late final _Translations$menu$ja menu = _Translations$menu$ja._(_root); + @override late final _Translations$settings$ja settings = _Translations$settings$ja._(_root); + @override late final _Translations$home$ja home = _Translations$home$ja._(_root); +} + +// Path: app +class _Translations$app$ja extends Translations$app$en { + _Translations$app$ja._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get title => 'Tane'; +} + +// Path: common +class _Translations$common$ja extends Translations$common$en { + _Translations$common$ja._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get save => '保存'; + @override String get cancel => 'キャンセル'; + @override String get delete => '削除'; + @override String get edit => '編集'; + @override String get type => '種類'; + @override String get comingSoon => '近日公開'; + @override String get offline => 'オフラインです — 共有を一時停止しています'; +} + +// Path: menu +class _Translations$menu$ja extends Translations$menu$en { + _Translations$menu$ja._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get tagline => 'あなたの種子バンク'; + @override String get inventory => '在庫'; + @override String get market => 'マーケット'; + @override String get profile => 'プロフィール'; + @override String get chat => 'チャット'; + @override String get wishlist => 'お気に入り'; + @override String get following => 'フォロー中'; + @override String get calendar => 'カレンダー'; + @override String get settings => '設定'; +} + +// Path: settings +class _Translations$settings$ja extends Translations$settings$en { + _Translations$settings$ja._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get language => '言語'; + @override String get systemLanguage => 'システムの言語'; + @override String get langEs => 'Español'; + @override String get langEn => 'English'; + @override String get langPt => 'Português'; + @override String get langAst => 'Asturianu'; + @override String get langFr => 'Français'; + @override String get langDe => 'Deutsch'; + @override String get langJa => '日本語'; + @override String get about => 'このアプリについて'; + @override String get aboutText => '在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。'; + @override String get aboutOpen => 'Tane について'; +} + +// Path: home +class _Translations$home$ja extends Translations$home$en { + _Translations$home$ja._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get tagline => '地域の種を分かち合い、育てよう'; + @override String get openMarket => 'マーケット'; + @override String get openMarketSubtitle => '近くの種を見つけて分かち合う'; + @override String get yourInventory => 'あなたの在庫'; + @override String get yourInventorySubtitle => '種を管理する'; +} + +/// The flat map containing all translations for locale . +/// Only for edge cases! For simple maps, use the map function of this library. +/// +/// The Dart AOT compiler has issues with very large switch statements, +/// so the map is split into smaller functions (512 entries each). +extension on TranslationsJa { + dynamic _flatMapFunction(String path) { + return switch (path) { + 'app.title' => 'Tane', + 'common.save' => '保存', + 'common.cancel' => 'キャンセル', + 'common.delete' => '削除', + 'common.edit' => '編集', + 'common.type' => '種類', + 'common.comingSoon' => '近日公開', + 'common.offline' => 'オフラインです — 共有を一時停止しています', + 'menu.tagline' => 'あなたの種子バンク', + 'menu.inventory' => '在庫', + 'menu.market' => 'マーケット', + 'menu.profile' => 'プロフィール', + 'menu.chat' => 'チャット', + 'menu.wishlist' => 'お気に入り', + 'menu.following' => 'フォロー中', + 'menu.calendar' => 'カレンダー', + 'menu.settings' => '設定', + 'settings.language' => '言語', + 'settings.systemLanguage' => 'システムの言語', + 'settings.langEs' => 'Español', + 'settings.langEn' => 'English', + 'settings.langPt' => 'Português', + 'settings.langAst' => 'Asturianu', + 'settings.langFr' => 'Français', + 'settings.langDe' => 'Deutsch', + 'settings.langJa' => '日本語', + 'settings.about' => 'このアプリについて', + 'settings.aboutText' => '在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。', + 'settings.aboutOpen' => 'Tane について', + 'home.tagline' => '地域の種を分かち合い、育てよう', + 'home.openMarket' => 'マーケット', + 'home.openMarketSubtitle' => '近くの種を見つけて分かち合う', + 'home.yourInventory' => 'あなたの在庫', + 'home.yourInventorySubtitle' => '種を管理する', + _ => null, + }; + } +} diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 4c35485..dad41df 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -39,7 +39,12 @@ class TranslationsPt extends Translations with BaseTranslations? meta}) => TranslationsPt(meta: meta ?? this.$meta); // Translations + @override late final _Translations$avatar$pt avatar = _Translations$avatar$pt._(_root); + @override late final _Translations$favorites$pt favorites = _Translations$favorites$pt._(_root); + @override late final _Translations$seedSaving$pt seedSaving = _Translations$seedSaving$pt._(_root); + @override late final _Translations$calendar$pt calendar = _Translations$calendar$pt._(_root); @override late final _Translations$app$pt app = _Translations$app$pt._(_root); + @override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root); @override late final _Translations$common$pt common = _Translations$common$pt._(_root); @override late final _Translations$home$pt home = _Translations$home$pt._(_root); @override late final _Translations$photo$pt photo = _Translations$photo$pt._(_root); @@ -62,12 +67,103 @@ class TranslationsPt extends Translations with BaseTranslations 'A tua foto ou avatar'; + @override String get fromPhoto => 'Tirar ou escolher uma foto'; + @override String get illustration => 'Ou escolhe um desenho'; + @override String get remove => 'Remover'; +} + +// Path: favorites +class _Translations$favorites$pt extends Translations$favorites$en { + _Translations$favorites$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoritos'; + @override String get empty => 'Ainda não tens favoritos. Guarda ofertas que gostes do mercado.'; + @override String get save => 'Guardar nos favoritos'; + @override String get remove => 'Remover dos favoritos'; + @override String get unavailable => 'Já não está disponível'; +} + +// Path: seedSaving +class _Translations$seedSaving$pt extends Translations$seedSaving$en { + _Translations$seedSaving$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Guardar a sua semente'; + @override String get subtitle => 'O que é preciso para manter a variedade fiel'; + @override String get lifeCycle => 'Ciclo'; + @override String get cycleAnnual => 'Anual'; + @override String get cycleBiennial => 'Bienal — dá semente no 2.º ano'; + @override String get cyclePerennial => 'Perene'; + @override String get pollination => 'Polinização'; + @override String get pollSelf => 'Autopoliniza-se'; + @override String get pollCross => 'Cruza-se com outras'; + @override String get pollMixed => 'Autopoliniza-se, mas às vezes cruza'; + @override String get byInsect => 'por insetos'; + @override String get byWind => 'pelo vento'; + @override String get isolation => 'Separe-a'; + @override String isolationRange({required Object min, required Object max}) => '${min}–${max} m de outras variedades'; + @override String isolationSingle({required Object min}) => '${min} m de outras variedades'; + @override String get plants => 'Guarde de várias plantas'; + @override String plantsValue({required Object n}) => 'De pelo menos ${n} plantas'; + @override String get processing => 'Como limpá-la'; + @override String get procDry => 'Semente seca (debulhar)'; + @override String get procWet => 'Semente húmida (fermentar e lavar)'; + @override String get difficulty => 'Dificuldade'; + @override String get diffEasy => 'Fácil'; + @override String get diffMedium => 'Média'; + @override String get diffHard => 'Difícil'; + @override String get advisory => 'Orientativo — adapte-o ao seu clima e variedade.'; + @override String get sourcePrefix => 'Fonte'; +} + +// Path: calendar +class _Translations$calendar$pt extends Translations$calendar$en { + _Translations$calendar$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Este mês'; + @override String get filterChip => 'Este mês'; + @override String get selfNote => 'O que anotaste nas tuas variedades.'; + @override String nothing({required Object month}) => 'Nada anotado para ${month}.'; } // Path: app @@ -77,7 +173,18 @@ class _Translations$app$pt extends Translations$app$en { final TranslationsPt _root; // ignore: unused_field // Translations - @override String get title => 'Tanemaki'; + @override String get title => 'Tane'; +} + +// Path: bootstrap +class _Translations$bootstrap$pt extends Translations$bootstrap$en { + _Translations$bootstrap$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get failed => 'O Tane não conseguiu iniciar'; + @override String get retry => 'Tentar de novo'; } // Path: common @@ -93,6 +200,7 @@ class _Translations$common$pt extends Translations$common$en { @override String get edit => 'Editar'; @override String get type => 'Tipo'; @override String get comingSoon => 'Em breve'; + @override String get offline => 'Sem ligação — a partilha está em pausa'; } // Path: home @@ -103,8 +211,8 @@ class _Translations$home$pt extends Translations$home$en { // Translations @override String get tagline => 'Partilha e cultiva sementes locais'; - @override String get openMarket => 'Mercado aberto'; - @override String get openMarketSubtitle => 'Descobre e troca'; + @override String get openMarket => 'Mercado'; + @override String get openMarketSubtitle => 'Descobre e partilha sementes por perto'; @override String get yourInventory => 'O teu inventário'; @override String get yourInventorySubtitle => 'Gere as tuas sementes'; } @@ -135,8 +243,11 @@ class _Translations$menu$pt extends Translations$menu$en { @override String get market => 'Mercado'; @override String get profile => 'O teu perfil'; @override String get chat => 'Conversas'; - @override String get wishlist => 'Lista de desejos'; + @override String get wishlist => 'Favoritos'; @override String get following => 'A seguir'; + @override String get plantares => 'Plantares'; + @override String get sales => 'Vendas'; + @override String get calendar => 'Calendário'; @override String get settings => 'Definições'; } @@ -152,9 +263,13 @@ class _Translations$settings$pt extends Translations$settings$en { @override String get langEs => 'Español'; @override String get langEn => 'English'; @override String get langPt => 'Português'; + @override String get langAst => 'Asturianu'; + @override String get langFr => 'Français'; + @override String get langDe => 'Deutsch'; + @override String get langJa => '日本語'; @override String get about => 'Acerca de'; @override String get aboutText => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.'; - @override String get aboutOpen => 'Acerca do Tanemaki'; + @override String get aboutOpen => 'Acerca do Tane'; } // Path: backup @@ -165,6 +280,9 @@ class _Translations$backup$pt extends Translations$backup$en { // Translations @override String get title => 'Cópia de segurança'; + @override String get autoBackupTitle => 'Cópias automáticas'; + @override String autoBackupLast({required Object date, required Object days}) => 'Última cópia em ${date} · a cada ${days} dias'; + @override String autoBackupNone({required Object days}) => 'Uma cópia é guardada automaticamente a cada ${days} dias'; @override String get exportJson => 'Guardar uma cópia de segurança'; @override String get exportJsonSubtitle => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho'; @override String get importJson => 'Restaurar uma cópia'; @@ -182,14 +300,14 @@ class _Translations$backup$pt extends Translations$backup$en { @override String get cancelled => 'Cancelado'; @override String importDone({required Object added, required Object updated}) => 'Importado: ${added} novas, ${updated} atualizadas'; @override String importCsvDone({required Object count}) => '${count} entradas acrescentadas'; - @override String get importFailed => 'Este ficheiro não pôde ser lido como uma cópia do Tanemaki'; + @override String get importFailed => 'Este ficheiro não pôde ser lido como uma cópia do Tane'; @override String get failed => 'Algo correu mal'; @override String get recoveryTitle => 'O teu código de recuperação'; @override String get recoverySubtitle => 'Imprime-o e guarda-o bem: abre as tuas cópias noutro aparelho'; @override String get recoveryIntro => 'Este código abre as tuas cópias guardadas e recupera o teu banco em qualquer aparelho. Guarda duas cópias em papel em sítios seguros, como a tua melhor semente. Quem o tiver pode ler as tuas cópias, por isso não o partilhes com ninguém.'; @override String get recoveryCopy => 'Copiar'; @override String get recoverySave => 'Guardar a folha'; - @override String get recoverySheetTitle => 'Tanemaki — a tua folha de recuperação'; + @override String get recoverySheetTitle => 'Tane — a tua folha de recuperação'; @override String get recoveryPromptTitle => 'Escreve o teu código de recuperação'; @override String get recoveryPromptBody => 'Esta cópia foi guardada com outro código. Escreve o código da tua folha de recuperação para a abrir.'; @override String get recoveryWrongCode => 'Esse código não abre esta cópia'; @@ -203,14 +321,17 @@ class _Translations$about$pt extends Translations$about$en { // Translations @override String get title => 'Acerca de'; - @override String get kanji => '種まき'; + @override String get kanji => '種'; @override String get tagline => 'Uma aplicação local e descentralizada para gerir e partilhar sementes e plântulas tradicionais.'; - @override String get intro => 'O Tanemaki (種まき, "semear / espalhar sementes" em japonês; nome curto Tane, 種 "semente") ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e partilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.'; - @override String get heritage => 'O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário partilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a "moeda comunitária de troca de sementes" (BAH-Semillero, 2009, CC-BY-SA). O Tanemaki é o Plantare digital.'; + @override String get intro => 'O Tane (種, "semente" em japonês) ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e partilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O seu nome vem de tanemaki (種まき), "semear / espalhar sementes". O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.'; + @override String get heritage => 'O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário partilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a "moeda comunitária de troca de sementes" (BAH-Semillero, 2009, CC-BY-SA). O Tane é o Plantare digital.'; @override String get version => 'Versão'; @override String get license => 'Licença'; @override String get licenseValue => 'AGPL-3.0'; @override String get website => 'Sítio web'; + @override String get sourceCode => 'Código-fonte'; + @override String get translate => 'Ajuda a traduzir'; + @override String get translateSubtitle => 'Ajuda a trazer o Tane para o teu idioma'; @override String get openSourceLicenses => 'Licenças de código aberto'; @override String get openSourceLicensesSubtitle => 'Bibliotecas de terceiros e as suas licenças'; @override String copyright({required Object years}) => '© ${years} Associação Comunes, sob AGPLv3'; @@ -226,7 +347,7 @@ class _Translations$intro$pt extends Translations$intro$en { @override String get skip => 'Saltar'; @override String get next => 'Seguinte'; @override String get start => 'Começar'; - @override String get menuEntry => 'Como funciona o Tanemaki'; + @override String get menuEntry => 'Como funciona o Tane'; @override late final _Translations$intro$slides$pt slides = _Translations$intro$slides$pt._(_root); } @@ -244,6 +365,8 @@ class _Translations$inventory$pt extends Translations$inventory$en { @override String get clearFilters => 'Limpar filtros'; @override String get uncategorized => 'Sem categoria'; @override String get needsReproductionFilter => 'Para reproduzir'; + @override String get loadError => 'Não foi possível abrir o teu banco de sementes. Talvez estivesse ocupado — tenta de novo.'; + @override String get retry => 'Tentar de novo'; } // Path: draft @@ -465,6 +588,8 @@ class _Translations$share$pt extends Translations$share$en { @override String get add => 'Partilhas esta?'; @override String get title => 'Partilhas esta?'; @override String get nudge => 'Tens de sobra: podias partilhar um pouco.'; + @override String get price => 'Preço'; + @override String get priceHint => 'Deixa vazio para combinar depois'; @override String get private => 'Só para mim'; @override String get gift => 'Para dar'; @override String get exchange => 'Para trocar'; @@ -476,6 +601,30 @@ class _Translations$share$pt extends Translations$share$en { @override String get cancelled => 'Cancelado'; } +// Path: printLabels +class _Translations$printLabels$pt extends Translations$printLabels$en { + _Translations$printLabels$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get action => 'Imprimir etiquetas'; + @override String get title => 'Imprimir etiquetas'; + @override String get selectHint => 'Escolhe as sementes para imprimir as etiquetas'; + @override String get selectAll => 'Selecionar tudo'; + @override String selected({required Object n}) => '${n} selecionadas'; + @override String get none => 'Seleciona sementes primeiro'; + @override String get format => 'Tamanho da etiqueta'; + @override String get formatStickers => 'Autocolantes pequenos'; + @override String get formatStickersHint => 'Muitas etiquetas pequenas por folha — para colar em envelopes'; + @override String get formatCards => 'Cartões grandes'; + @override String get formatCardsHint => 'Menos etiquetas, maiores — para frascos e caixas'; + @override String count({required Object n}) => '${n} etiquetas'; + @override String get save => 'Guardar etiquetas'; + @override String get saved => 'Etiquetas guardadas'; + @override String get cancelled => 'Cancelado'; +} + // Path: cropCalendar class _Translations$cropCalendar$pt extends Translations$cropCalendar$en { _Translations$cropCalendar$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -490,6 +639,7 @@ class _Translations$cropCalendar$pt extends Translations$cropCalendar$en { @override String get flowering => 'Floração'; @override String get fruiting => 'Frutificação'; @override String get seedHarvest => 'Colheita de semente'; + @override String get editorHint => 'Anota tu os meses típicos desta variedade na tua zona — são as tuas notas.'; @override String get unset => '—'; } @@ -589,6 +739,342 @@ class _Translations$unit$pt extends Translations$unit$en { @override late final _Translations$unit$count$pt count = _Translations$unit$count$pt._(_root); } +// Path: market +class _Translations$market$pt extends Translations$market$en { + _Translations$market$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Sementes perto de ti'; + @override String get subtitle => 'O que outras pessoas partilham por perto'; + @override String get notSetUp => 'Ainda não configuraste a partilha'; + @override String get notSetUpBody => 'Ativa a partilha para ver e dar sementes a pessoas por perto. Mantém-se aproximado — a tua zona, nunca a tua morada exata.'; + @override String get setUp => 'Configurar a partilha'; + @override String get cantReach => 'Não é possível ligar aos servidores neste momento'; + @override String get cantReachBody => 'Tenta de novo, ou verifica os servidores na configuração avançada.'; + @override String get retry => 'Tentar de novo'; + @override String get setArea => 'Indica a tua zona'; + @override String get setAreaBody => 'Diz ao mercado a tua zona aproximada para ver sementes por perto.'; + @override String get searching => 'A procurar pela tua zona…'; + @override String get empty => 'Ainda não há sementes partilhadas perto de ti'; + @override String get searchHint => 'Procurar nestas sementes'; + @override String get noMatches => 'Nenhuma semente partilhada corresponde à procura'; + @override String get near => 'Perto de ti'; + @override String get contact => 'Mensagem'; + @override String get mine => 'Tu'; + @override String get configTitle => 'Configuração de partilha'; + @override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.'; + @override String get areaLabel => 'A tua zona'; + @override String get areaHelp => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.'; + @override String get areaSet => 'A tua zona está definida — aproximada, nunca o teu ponto exato'; + @override String get areaNotSet => 'Zona por definir — usa a tua localização, ou adiciona um código em Avançado'; + @override String get advanced => 'Avançado'; + @override String get areaCodeLabel => 'Código de zona'; + @override String get areaCodeHint => 'Um código curto como sp3e9 — não um nome de lugar'; + @override String get serversLabel => 'Servidores da comunidade'; + @override String get serversHelp => 'Escolhe que servidores usar. Deixa assim se não souberes.'; + @override String get serversAdvanced => 'Adicionar outro servidor'; + @override String get serverAddress => 'Endereço do servidor'; + @override String get serverInvalid => 'Introduz um endereço válido (wss://…)'; + @override String get save => 'Guardar'; + @override String get saved => 'Guardado'; + @override String get wanted => 'Procuro'; + @override String get shareMine => 'Partilhar as minhas sementes'; + @override String sharedCount({required Object n}) => 'Partilhadas ${n} sementes'; + @override String get nothingToShare => 'Marca primeiro algumas sementes para dar, trocar ou vender'; + @override String get useLocation => 'Usar a minha localização aproximada'; + @override String get locationFailed => 'Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida'; + @override String get queued => 'Guardado — vamos partilhá-las quando tiveres ligação'; + @override String get shareFailed => 'Não foi possível contactar os servidores — as tuas sementes não foram partilhadas. Tenta de novo daqui a pouco.'; + @override String get rangeLabel => 'Até onde procurar'; + @override String get rangeNear => 'Muito perto'; + @override String get rangeArea => 'Pela minha zona'; + @override String get rangeRegion => 'A minha região'; + @override String get sharedBy => 'Partilhado por'; + @override String get noProfile => 'Esta pessoa ainda não partilhou o seu perfil'; + @override String get copyId => 'Copiar código'; + @override String get idCopied => 'Código copiado'; + @override String get photo => 'Foto'; +} + +// Path: profile +class _Translations$profile$pt extends Translations$profile$en { + _Translations$profile$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'O teu perfil'; + @override String get name => 'Nome'; + @override String get nameHint => 'Como os outros te veem'; + @override String get about => 'Sobre ti'; + @override String get aboutHint => 'Uma linha — o que cultivas, onde'; + @override String get g1 => 'Endereço Ğ1 (opcional)'; + @override String get g1Hint => 'Para que te paguem em Ğ1 — separado da tua chave'; + @override String get yourId => 'A tua identidade'; + @override String get idHelp => 'Partilha-a para que te reconheçam'; + @override String get copy => 'Copiar'; + @override String get copied => 'Copiado'; + @override String get save => 'Guardar'; + @override String get saved => 'Perfil guardado'; + @override String get identities => 'As tuas identidades'; + @override String get identitiesHelp => 'Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da tua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.'; + @override String identityLabel({required Object n}) => 'Identidade ${n}'; + @override String get current => 'Em uso'; + @override String get newIdentity => 'Nova identidade'; + @override String get switchTitle => 'Mudar de identidade?'; + @override String get switchBody => 'As mensagens e contactos são guardados separadamente para cada identidade. Podes voltar quando quiseres.'; + @override String get switchAction => 'Mudar'; +} + +// Path: chatList +class _Translations$chatList$pt extends Translations$chatList$en { + _Translations$chatList$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Mensagens'; + @override String get empty => 'Ainda não há conversas. Escreve a alguém a partir do mercado.'; +} + +// Path: chat +class _Translations$chat$pt extends Translations$chat$en { + _Translations$chat$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Conversa'; + @override String get hint => 'Escreve uma mensagem…'; + @override String get send => 'Enviar'; + @override String get empty => 'Ainda não há mensagens — diz olá'; + @override String get offline => 'Configura a partilha para enviar mensagens'; + @override String get payG1 => 'Pagar em Ğ1'; + @override String get g1Copied => 'Endereço Ğ1 copiado — cola-o na tua carteira'; + @override String get today => 'Hoje'; + @override String get yesterday => 'Ontem'; + @override String get sendError => 'Não foi possível enviar — verifica a tua ligação'; + @override String get noLinks => 'Não são permitidos links nas mensagens'; +} + +// Path: trust +class _Translations$trust$pt extends Translations$trust$en { + _Translations$trust$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get none => 'Ainda ninguém os avaliza'; + @override String count({required Object n}) => 'Avalizada por ${n}'; + @override String get vouch => 'Conheço esta pessoa'; + @override String get vouched => 'Avalizas esta pessoa'; + @override String get circle => 'No teu círculo'; +} + +// Path: yourPeople +class _Translations$yourPeople$pt extends Translations$yourPeople$en { + _Translations$yourPeople$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'A tua gente'; + @override String get help => 'Pessoas que conheces e avalizas, e pessoas que te avalizam.'; + @override String get youVouchFor => 'Tu avalizas'; + @override String get vouchesForYou => 'Avalizam-te'; + @override String get youVouchForEmpty => 'Ainda não avalizas ninguém. Quando conheceres alguém, abre a vossa conversa e toca em "Conheço esta pessoa".'; + @override String get vouchesForYouEmpty => 'Ainda ninguém te avaliza'; + @override String get revoke => 'Deixar de avalizar'; + @override String get revokeConfirm => 'Deixar de avalizar esta pessoa?'; + @override String get offline => 'Sem ligação — tenta novamente quando estiveres em linha'; +} + +// Path: ratings +class _Translations$ratings$pt extends Translations$ratings$en { + _Translations$ratings$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get rate => 'Avaliar esta pessoa'; + @override String get edit => 'Editar a tua avaliação'; + @override String get commentHint => 'Como correu? (opcional)'; + @override String fromYourCircle({required Object n}) => '${n} de pessoas que conheces'; + @override String get retract => 'Remover a tua avaliação'; + @override String get saved => 'Avaliação guardada'; +} + +// Path: notifications +class _Translations$notifications$pt extends Translations$notifications$en { + _Translations$notifications$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Nova mensagem de ${name}'; +} + +// Path: plantare +class _Translations$plantare$pt extends Translations$plantare$en { + _Translations$plantare$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.'; + @override String get add => 'Adicionar compromisso'; + @override String get empty => 'Ainda não há compromissos. Quando partilhares ou receberes semente com o compromisso de a reproduzir e devolver algo, anota-o aqui.'; + @override String get iReturn => 'Reproduzo e devolvo eu'; + @override String get owedToMe => 'Devolvem-me a mim'; + @override String get direction => 'Quem reproduz e devolve'; + @override String get counterparty => 'Com quem?'; + @override String get counterpartyHint => 'Uma pessoa ou um coletivo (opcional)'; + @override String get owed => 'O que é devolvido?'; + @override String get owedHint => 'p. ex. um punhado na próxima temporada (opcional)'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get markReturned => 'Marcar devolvido'; + @override String get markForgiven => 'Dar por saldado'; + @override String get reopen => 'Reabrir'; + @override String get delete => 'Remover'; + @override String get statusReturned => 'Devolvido'; + @override String get statusForgiven => 'Saldado'; + @override String get openSection => 'Pendentes'; + @override String get settledSection => 'Feitos'; + @override String get removeConfirm => 'Remover este compromisso?'; + @override String returnBy({required Object date}) => 'Devolver até ${date}'; + @override String get overdue => 'vencido'; + @override String get dueByLabel => 'Devolver até (opcional)'; + @override String get dueByHint => 'Um lembrete gentil, nunca imposto'; + @override String get pickDate => 'Escolher data'; + @override String get clearDate => 'Limpar data'; + @override String get sectionTitle => 'Compromissos'; +} + +// Path: handover +class _Translations$handover$pt extends Translations$handover$en { + _Translations$handover$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Dei ou recebi sementes'; + @override String get help => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.'; + @override String get iGave => 'Dei sementes'; + @override String get iReceived => 'Recebi sementes'; + @override String get whichLot => 'De que lote?'; + @override String get howMuch => 'Quanto?'; + @override String get allOfIt => 'Tudo'; + @override String get partOfIt => 'Uma parte'; + @override String get paymentChip => 'Houve dinheiro pelo meio'; + @override String get promiseGave => 'Vão devolver-me semente'; + @override String get promiseReceived => 'Vou devolver semente'; +} + +// Path: sale +class _Translations$sale$pt extends Translations$sale$en { + _Translations$sale$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Vendas'; + @override String get help => 'Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.'; + @override String get add => 'Registar venda'; + @override String get empty => 'Ainda não há vendas. Anota aqui o que vendes ou compras.'; + @override String get iSold => 'Vendi'; + @override String get iBought => 'Comprei'; + @override String get direction => 'Vendes ou compras?'; + @override String get counterparty => 'Com quem?'; + @override String get counterpartyHint => 'Uma pessoa ou um coletivo (opcional)'; + @override String get amount => 'Montante'; + @override String get currency => 'Moeda'; + @override String get currencyHint => '€, Ğ1, horas… (opcional)'; + @override String get hours => 'horas'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get delete => 'Remover'; + @override String get removeConfirm => 'Remover esta venda?'; +} + +// Path: legal +class _Translations$legal$pt extends Translations$legal$en { + _Translations$legal$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Privacidade e regras'; + @override String get subtitle => 'A tua privacidade, as regras do mercado e a legalidade das sementes'; + @override String get privacyTitle => 'A tua privacidade'; + @override String get privacyBody => 'O Tane funciona sem conta, e tudo o que registas fica no teu dispositivo, cifrado. Não há publicidade, nem rastreadores, nem servidor do Tane.\n\nNada é partilhado a não ser que tu queiras: quando publicas uma oferta ou o teu perfil, ou envias uma mensagem, viaja por servidores comunitários. As ofertas levam apenas uma zona aproximada — nunca a tua morada.\n\nO que publicas é público, e podem ficar cópias mesmo depois de o retirares — por isso partilha com cuidado.'; + @override String get rulesTitle => 'As regras do jogo'; + @override String get rulesBody => 'O Tane é uma ferramenta, não uma loja: as trocas são acordadas diretamente entre pessoas e ninguém fica com comissão. Isso também significa que és responsável pelo que ofereces e envias.\n\nNo mercado: sê honesto com as tuas sementes, oferece apenas o que podes partilhar, trata bem as pessoas e não faças spam. Podes bloquear qualquer pessoa e denunciar ofertas ou pessoas que quebrem as regras — as denúncias são tratadas nos servidores comunitários.'; + @override String get seedsTitle => 'Sobre partilhar sementes e mudas'; + @override String get seedsBody => 'Oferecer e trocar sementes entre amadores é amplamente reconhecido na maioria dos países. Vender pode ser diferente: em muitos lugares, vender sementes de variedades não registadas oficialmente é restrito — consulta as regras locais antes de pedir um preço.\n\nEnviar sementes para outro país também costuma ser restrito, e as variedades protegidas comercialmente não podem ser propagadas sem autorização. Na dúvida, melhor local e melhor oferta.\n\nO mesmo se aplica a mudas e plantas jovens, mas a planta viva pode ter regras de transporte e fitossanitárias mais rígidas do que a semente: movê-la entre regiões ou países pode exigir controlos adicionais.'; + @override String get readFull => 'Ler os documentos completos na web'; +} + +// Path: marketGate +class _Translations$marketGate$pt extends Translations$marketGate$en { + _Translations$marketGate$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Antes de entrares no mercado'; + @override String get intro => 'O mercado é um espaço partilhado entre vizinhas e vizinhos. Ao continuar, aceitas umas poucas regras simples:'; + @override String get ruleHonest => 'Sê honesto com as sementes que ofereces'; + @override String get ruleLegal => 'Partilha apenas o que podes partilhar onde vives'; + @override String get ruleRespect => 'Trata bem as pessoas — sem spam nem abusos'; + @override String get publicNote => 'O que publicares aqui é público, e podem ficar cópias mesmo que o retires depois.'; + @override String get viewLegal => 'Privacidade e regras'; + @override String get accept => 'Aceito'; + @override String get decline => 'Agora não'; +} + +// Path: report +class _Translations$report$pt extends Translations$report$en { + _Translations$report$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get offer => 'Denunciar esta oferta'; + @override String get person => 'Denunciar esta pessoa'; + @override String get title => 'Denunciar'; + @override String get prompt => 'O que se passa?'; + @override String get reasonSpam => 'Spam ou uma burla'; + @override String get reasonAbuse => 'Abusivo ou desrespeitoso'; + @override String get reasonIllegal => 'Sementes que não deviam ser oferecidas'; + @override String get reasonOther => 'Outra coisa'; + @override String get detailsHint => 'Acrescenta detalhes (opcional)'; + @override String get send => 'Enviar denúncia'; + @override String get sentHidden => 'Denúncia enviada — já não voltas a ver isto'; + @override String get failed => 'Não foi possível enviar a denúncia — verifica a tua ligação'; + @override String get alsoBlock => 'Bloquear também esta pessoa'; +} + +// Path: block +class _Translations$block$pt extends Translations$block$en { + _Translations$block$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get action => 'Bloquear esta pessoa'; + @override String get confirmTitle => 'Bloquear esta pessoa?'; + @override String get confirmBody => 'Deixas de ver as suas ofertas e mensagens. Podes desbloqueá-la mais tarde em Pessoas bloqueadas, na configuração de partilha.'; + @override String get confirm => 'Bloquear'; + @override String get blockedToast => 'Pessoa bloqueada — as suas ofertas e mensagens ficam ocultas'; + @override String get manageTitle => 'Pessoas bloqueadas'; + @override String get manageEmpty => 'Não bloqueaste ninguém'; + @override String get unblock => 'Desbloquear'; +} + // Path: intro.slides class _Translations$intro$slides$pt extends Translations$intro$slides$en { _Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -930,16 +1416,58 @@ class _Translations$intro$slides$plantare$pt extends Translations$intro$slides$p extension on TranslationsPt { dynamic _flatMapFunction(String path) { return switch (path) { - 'app.title' => 'Tanemaki', + 'avatar.title' => 'A tua foto ou avatar', + 'avatar.fromPhoto' => 'Tirar ou escolher uma foto', + 'avatar.illustration' => 'Ou escolhe um desenho', + 'avatar.remove' => 'Remover', + 'favorites.title' => 'Favoritos', + 'favorites.empty' => 'Ainda não tens favoritos. Guarda ofertas que gostes do mercado.', + 'favorites.save' => 'Guardar nos favoritos', + 'favorites.remove' => 'Remover dos favoritos', + 'favorites.unavailable' => 'Já não está disponível', + 'seedSaving.title' => 'Guardar a sua semente', + 'seedSaving.subtitle' => 'O que é preciso para manter a variedade fiel', + 'seedSaving.lifeCycle' => 'Ciclo', + 'seedSaving.cycleAnnual' => 'Anual', + 'seedSaving.cycleBiennial' => 'Bienal — dá semente no 2.º ano', + 'seedSaving.cyclePerennial' => 'Perene', + 'seedSaving.pollination' => 'Polinização', + 'seedSaving.pollSelf' => 'Autopoliniza-se', + 'seedSaving.pollCross' => 'Cruza-se com outras', + 'seedSaving.pollMixed' => 'Autopoliniza-se, mas às vezes cruza', + 'seedSaving.byInsect' => 'por insetos', + 'seedSaving.byWind' => 'pelo vento', + 'seedSaving.isolation' => 'Separe-a', + 'seedSaving.isolationRange' => ({required Object min, required Object max}) => '${min}–${max} m de outras variedades', + 'seedSaving.isolationSingle' => ({required Object min}) => '${min} m de outras variedades', + 'seedSaving.plants' => 'Guarde de várias plantas', + 'seedSaving.plantsValue' => ({required Object n}) => 'De pelo menos ${n} plantas', + 'seedSaving.processing' => 'Como limpá-la', + 'seedSaving.procDry' => 'Semente seca (debulhar)', + 'seedSaving.procWet' => 'Semente húmida (fermentar e lavar)', + 'seedSaving.difficulty' => 'Dificuldade', + 'seedSaving.diffEasy' => 'Fácil', + 'seedSaving.diffMedium' => 'Média', + 'seedSaving.diffHard' => 'Difícil', + 'seedSaving.advisory' => 'Orientativo — adapte-o ao seu clima e variedade.', + 'seedSaving.sourcePrefix' => 'Fonte', + 'calendar.title' => 'Este mês', + 'calendar.filterChip' => 'Este mês', + 'calendar.selfNote' => 'O que anotaste nas tuas variedades.', + 'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.', + 'app.title' => 'Tane', + 'bootstrap.failed' => 'O Tane não conseguiu iniciar', + 'bootstrap.retry' => 'Tentar de novo', 'common.save' => 'Guardar', 'common.cancel' => 'Cancelar', 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', 'common.comingSoon' => 'Em breve', + 'common.offline' => 'Sem ligação — a partilha está em pausa', 'home.tagline' => 'Partilha e cultiva sementes locais', - 'home.openMarket' => 'Mercado aberto', - 'home.openMarketSubtitle' => 'Descobre e troca', + 'home.openMarket' => 'Mercado', + 'home.openMarketSubtitle' => 'Descobre e partilha sementes por perto', 'home.yourInventory' => 'O teu inventário', 'home.yourInventorySubtitle' => 'Gere as tuas sementes', 'photo.camera' => 'Tirar uma foto', @@ -952,18 +1480,28 @@ extension on TranslationsPt { 'menu.market' => 'Mercado', 'menu.profile' => 'O teu perfil', 'menu.chat' => 'Conversas', - 'menu.wishlist' => 'Lista de desejos', + 'menu.wishlist' => 'Favoritos', 'menu.following' => 'A seguir', + 'menu.plantares' => 'Plantares', + 'menu.sales' => 'Vendas', + 'menu.calendar' => 'Calendário', 'menu.settings' => 'Definições', 'settings.language' => 'Idioma', 'settings.systemLanguage' => 'Idioma do sistema', 'settings.langEs' => 'Español', 'settings.langEn' => 'English', 'settings.langPt' => 'Português', + 'settings.langAst' => 'Asturianu', + 'settings.langFr' => 'Français', + 'settings.langDe' => 'Deutsch', + 'settings.langJa' => '日本語', 'settings.about' => 'Acerca de', 'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.', - 'settings.aboutOpen' => 'Acerca do Tanemaki', + 'settings.aboutOpen' => 'Acerca do Tane', 'backup.title' => 'Cópia de segurança', + 'backup.autoBackupTitle' => 'Cópias automáticas', + 'backup.autoBackupLast' => ({required Object date, required Object days}) => 'Última cópia em ${date} · a cada ${days} dias', + 'backup.autoBackupNone' => ({required Object days}) => 'Uma cópia é guardada automaticamente a cada ${days} dias', 'backup.exportJson' => 'Guardar uma cópia de segurança', 'backup.exportJsonSubtitle' => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho', 'backup.importJson' => 'Restaurar uma cópia', @@ -981,33 +1519,36 @@ extension on TranslationsPt { 'backup.cancelled' => 'Cancelado', 'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} novas, ${updated} atualizadas', 'backup.importCsvDone' => ({required Object count}) => '${count} entradas acrescentadas', - 'backup.importFailed' => 'Este ficheiro não pôde ser lido como uma cópia do Tanemaki', + 'backup.importFailed' => 'Este ficheiro não pôde ser lido como uma cópia do Tane', 'backup.failed' => 'Algo correu mal', 'backup.recoveryTitle' => 'O teu código de recuperação', 'backup.recoverySubtitle' => 'Imprime-o e guarda-o bem: abre as tuas cópias noutro aparelho', 'backup.recoveryIntro' => 'Este código abre as tuas cópias guardadas e recupera o teu banco em qualquer aparelho. Guarda duas cópias em papel em sítios seguros, como a tua melhor semente. Quem o tiver pode ler as tuas cópias, por isso não o partilhes com ninguém.', 'backup.recoveryCopy' => 'Copiar', 'backup.recoverySave' => 'Guardar a folha', - 'backup.recoverySheetTitle' => 'Tanemaki — a tua folha de recuperação', + 'backup.recoverySheetTitle' => 'Tane — a tua folha de recuperação', 'backup.recoveryPromptTitle' => 'Escreve o teu código de recuperação', 'backup.recoveryPromptBody' => 'Esta cópia foi guardada com outro código. Escreve o código da tua folha de recuperação para a abrir.', 'backup.recoveryWrongCode' => 'Esse código não abre esta cópia', 'about.title' => 'Acerca de', - 'about.kanji' => '種まき', + 'about.kanji' => '種', 'about.tagline' => 'Uma aplicação local e descentralizada para gerir e partilhar sementes e plântulas tradicionais.', - 'about.intro' => 'O Tanemaki (種まき, "semear / espalhar sementes" em japonês; nome curto Tane, 種 "semente") ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e partilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.', - 'about.heritage' => 'O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário partilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a "moeda comunitária de troca de sementes" (BAH-Semillero, 2009, CC-BY-SA). O Tanemaki é o Plantare digital.', + 'about.intro' => 'O Tane (種, "semente" em japonês) ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e partilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O seu nome vem de tanemaki (種まき), "semear / espalhar sementes". O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.', + 'about.heritage' => 'O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário partilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a "moeda comunitária de troca de sementes" (BAH-Semillero, 2009, CC-BY-SA). O Tane é o Plantare digital.', 'about.version' => 'Versão', 'about.license' => 'Licença', 'about.licenseValue' => 'AGPL-3.0', 'about.website' => 'Sítio web', + 'about.sourceCode' => 'Código-fonte', + 'about.translate' => 'Ajuda a traduzir', + 'about.translateSubtitle' => 'Ajuda a trazer o Tane para o teu idioma', 'about.openSourceLicenses' => 'Licenças de código aberto', 'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceiros e as suas licenças', 'about.copyright' => ({required Object years}) => '© ${years} Associação Comunes, sob AGPLv3', 'intro.skip' => 'Saltar', 'intro.next' => 'Seguinte', 'intro.start' => 'Começar', - 'intro.menuEntry' => 'Como funciona o Tanemaki', + 'intro.menuEntry' => 'Como funciona o Tane', 'intro.slides.welcome.title' => 'A semente que te trouxe até aqui', 'intro.slides.welcome.body' => 'Cada semente tradicional é uma carta escrita por milhares de gerações, passada de mão em mão. Somos o que somos graças a essa partilha.', 'intro.slides.inventory.title' => 'O teu banco de sementes, no bolso', @@ -1025,6 +1566,8 @@ extension on TranslationsPt { 'inventory.clearFilters' => 'Limpar filtros', 'inventory.uncategorized' => 'Sem categoria', 'inventory.needsReproductionFilter' => 'Para reproduzir', + 'inventory.loadError' => 'Não foi possível abrir o teu banco de sementes. Talvez estivesse ocupado — tenta de novo.', + 'inventory.retry' => 'Tentar de novo', 'draft.capture' => 'Capturar fotos', 'draft.captured' => ({required Object n}) => '${n} capturadas para catalogar', 'draft.triageTitle' => 'Para catalogar', @@ -1127,6 +1670,8 @@ extension on TranslationsPt { 'share.add' => 'Partilhas esta?', 'share.title' => 'Partilhas esta?', 'share.nudge' => 'Tens de sobra: podias partilhar um pouco.', + 'share.price' => 'Preço', + 'share.priceHint' => 'Deixa vazio para combinar depois', 'share.private' => 'Só para mim', 'share.gift' => 'Para dar', 'share.exchange' => 'Para trocar', @@ -1136,6 +1681,21 @@ extension on TranslationsPt { 'share.catalogTitle' => 'O que partilho', 'share.catalogSaved' => 'Catálogo guardado', 'share.cancelled' => 'Cancelado', + 'printLabels.action' => 'Imprimir etiquetas', + 'printLabels.title' => 'Imprimir etiquetas', + 'printLabels.selectHint' => 'Escolhe as sementes para imprimir as etiquetas', + 'printLabels.selectAll' => 'Selecionar tudo', + 'printLabels.selected' => ({required Object n}) => '${n} selecionadas', + 'printLabels.none' => 'Seleciona sementes primeiro', + 'printLabels.format' => 'Tamanho da etiqueta', + 'printLabels.formatStickers' => 'Autocolantes pequenos', + 'printLabels.formatStickersHint' => 'Muitas etiquetas pequenas por folha — para colar em envelopes', + 'printLabels.formatCards' => 'Cartões grandes', + 'printLabels.formatCardsHint' => 'Menos etiquetas, maiores — para frascos e caixas', + 'printLabels.count' => ({required Object n}) => '${n} etiquetas', + 'printLabels.save' => 'Guardar etiquetas', + 'printLabels.saved' => 'Etiquetas guardadas', + 'printLabels.cancelled' => 'Cancelado', 'cropCalendar.add' => 'Calendário de cultivo', 'cropCalendar.title' => 'Calendário de cultivo', 'cropCalendar.sow' => 'Sementeira', @@ -1143,6 +1703,7 @@ extension on TranslationsPt { 'cropCalendar.flowering' => 'Floração', 'cropCalendar.fruiting' => 'Frutificação', 'cropCalendar.seedHarvest' => 'Colheita de semente', + 'cropCalendar.editorHint' => 'Anota tu os meses típicos desta variedade na tua zona — são as tuas notas.', 'cropCalendar.unset' => '—', 'needsReproduction.label' => 'Reproduzir nesta época', 'needsReproduction.hint' => 'Cultiva-a antes que a semente acabe', @@ -1219,6 +1780,209 @@ extension on TranslationsPt { 'unit.grams.plural' => 'gramas', 'unit.count.singular' => 'semente', 'unit.count.plural' => 'sementes', + 'market.title' => 'Sementes perto de ti', + 'market.subtitle' => 'O que outras pessoas partilham por perto', + 'market.notSetUp' => 'Ainda não configuraste a partilha', + 'market.notSetUpBody' => 'Ativa a partilha para ver e dar sementes a pessoas por perto. Mantém-se aproximado — a tua zona, nunca a tua morada exata.', + 'market.setUp' => 'Configurar a partilha', + 'market.cantReach' => 'Não é possível ligar aos servidores neste momento', + 'market.cantReachBody' => 'Tenta de novo, ou verifica os servidores na configuração avançada.', + 'market.retry' => 'Tentar de novo', + 'market.setArea' => 'Indica a tua zona', + 'market.setAreaBody' => 'Diz ao mercado a tua zona aproximada para ver sementes por perto.', + 'market.searching' => 'A procurar pela tua zona…', + 'market.empty' => 'Ainda não há sementes partilhadas perto de ti', + 'market.searchHint' => 'Procurar nestas sementes', + 'market.noMatches' => 'Nenhuma semente partilhada corresponde à procura', + 'market.near' => 'Perto de ti', + 'market.contact' => 'Mensagem', + 'market.mine' => 'Tu', + 'market.configTitle' => 'Configuração de partilha', + 'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.', + 'market.areaLabel' => 'A tua zona', + 'market.areaHelp' => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.', + 'market.areaSet' => 'A tua zona está definida — aproximada, nunca o teu ponto exato', + 'market.areaNotSet' => 'Zona por definir — usa a tua localização, ou adiciona um código em Avançado', + 'market.advanced' => 'Avançado', + 'market.areaCodeLabel' => 'Código de zona', + 'market.areaCodeHint' => 'Um código curto como sp3e9 — não um nome de lugar', + 'market.serversLabel' => 'Servidores da comunidade', + 'market.serversHelp' => 'Escolhe que servidores usar. Deixa assim se não souberes.', + 'market.serversAdvanced' => 'Adicionar outro servidor', + 'market.serverAddress' => 'Endereço do servidor', + 'market.serverInvalid' => 'Introduz um endereço válido (wss://…)', + 'market.save' => 'Guardar', + 'market.saved' => 'Guardado', + 'market.wanted' => 'Procuro', + 'market.shareMine' => 'Partilhar as minhas sementes', + 'market.sharedCount' => ({required Object n}) => 'Partilhadas ${n} sementes', + 'market.nothingToShare' => 'Marca primeiro algumas sementes para dar, trocar ou vender', + 'market.useLocation' => 'Usar a minha localização aproximada', + 'market.locationFailed' => 'Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida', + 'market.queued' => 'Guardado — vamos partilhá-las quando tiveres ligação', + 'market.shareFailed' => 'Não foi possível contactar os servidores — as tuas sementes não foram partilhadas. Tenta de novo daqui a pouco.', + 'market.rangeLabel' => 'Até onde procurar', + 'market.rangeNear' => 'Muito perto', + 'market.rangeArea' => 'Pela minha zona', + 'market.rangeRegion' => 'A minha região', + 'market.sharedBy' => 'Partilhado por', + 'market.noProfile' => 'Esta pessoa ainda não partilhou o seu perfil', + 'market.copyId' => 'Copiar código', + 'market.idCopied' => 'Código copiado', + 'market.photo' => 'Foto', + 'profile.title' => 'O teu perfil', + 'profile.name' => 'Nome', + 'profile.nameHint' => 'Como os outros te veem', + 'profile.about' => 'Sobre ti', + 'profile.aboutHint' => 'Uma linha — o que cultivas, onde', + 'profile.g1' => 'Endereço Ğ1 (opcional)', + 'profile.g1Hint' => 'Para que te paguem em Ğ1 — separado da tua chave', + 'profile.yourId' => 'A tua identidade', + 'profile.idHelp' => 'Partilha-a para que te reconheçam', + 'profile.copy' => 'Copiar', + 'profile.copied' => 'Copiado', + 'profile.save' => 'Guardar', + 'profile.saved' => 'Perfil guardado', + 'profile.identities' => 'As tuas identidades', + 'profile.identitiesHelp' => 'Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da tua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.', + 'profile.identityLabel' => ({required Object n}) => 'Identidade ${n}', + 'profile.current' => 'Em uso', + 'profile.newIdentity' => 'Nova identidade', + 'profile.switchTitle' => 'Mudar de identidade?', + 'profile.switchBody' => 'As mensagens e contactos são guardados separadamente para cada identidade. Podes voltar quando quiseres.', + 'profile.switchAction' => 'Mudar', + 'chatList.title' => 'Mensagens', + 'chatList.empty' => 'Ainda não há conversas. Escreve a alguém a partir do mercado.', + 'chat.title' => 'Conversa', + 'chat.hint' => 'Escreve uma mensagem…', + 'chat.send' => 'Enviar', + 'chat.empty' => 'Ainda não há mensagens — diz olá', + 'chat.offline' => 'Configura a partilha para enviar mensagens', + 'chat.payG1' => 'Pagar em Ğ1', + 'chat.g1Copied' => 'Endereço Ğ1 copiado — cola-o na tua carteira', + 'chat.today' => 'Hoje', + 'chat.yesterday' => 'Ontem', + 'chat.sendError' => 'Não foi possível enviar — verifica a tua ligação', + 'chat.noLinks' => 'Não são permitidos links nas mensagens', + 'trust.none' => 'Ainda ninguém os avaliza', + 'trust.count' => ({required Object n}) => 'Avalizada por ${n}', + 'trust.vouch' => 'Conheço esta pessoa', + 'trust.vouched' => 'Avalizas esta pessoa', + 'trust.circle' => 'No teu círculo', + 'yourPeople.title' => 'A tua gente', + 'yourPeople.help' => 'Pessoas que conheces e avalizas, e pessoas que te avalizam.', + 'yourPeople.youVouchFor' => 'Tu avalizas', + 'yourPeople.vouchesForYou' => 'Avalizam-te', + 'yourPeople.youVouchForEmpty' => 'Ainda não avalizas ninguém. Quando conheceres alguém, abre a vossa conversa e toca em "Conheço esta pessoa".', + 'yourPeople.vouchesForYouEmpty' => 'Ainda ninguém te avaliza', + 'yourPeople.revoke' => 'Deixar de avalizar', + 'yourPeople.revokeConfirm' => 'Deixar de avalizar esta pessoa?', + 'yourPeople.offline' => 'Sem ligação — tenta novamente quando estiveres em linha', + 'ratings.rate' => 'Avaliar esta pessoa', + 'ratings.edit' => 'Editar a tua avaliação', + 'ratings.commentHint' => 'Como correu? (opcional)', + 'ratings.fromYourCircle' => ({required Object n}) => '${n} de pessoas que conheces', + 'ratings.retract' => 'Remover a tua avaliação', + 'ratings.saved' => 'Avaliação guardada', + 'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.', + 'plantare.add' => 'Adicionar compromisso', + 'plantare.empty' => 'Ainda não há compromissos. Quando partilhares ou receberes semente com o compromisso de a reproduzir e devolver algo, anota-o aqui.', + 'plantare.iReturn' => 'Reproduzo e devolvo eu', + 'plantare.owedToMe' => 'Devolvem-me a mim', + 'plantare.direction' => 'Quem reproduz e devolve', + 'plantare.counterparty' => 'Com quem?', + 'plantare.counterpartyHint' => 'Uma pessoa ou um coletivo (opcional)', + 'plantare.owed' => 'O que é devolvido?', + 'plantare.owedHint' => 'p. ex. um punhado na próxima temporada (opcional)', + 'plantare.note' => 'Nota (opcional)', + 'plantare.save' => 'Guardar', + 'plantare.markReturned' => 'Marcar devolvido', + 'plantare.markForgiven' => 'Dar por saldado', + 'plantare.reopen' => 'Reabrir', + 'plantare.delete' => 'Remover', + 'plantare.statusReturned' => 'Devolvido', + 'plantare.statusForgiven' => 'Saldado', + 'plantare.openSection' => 'Pendentes', + 'plantare.settledSection' => 'Feitos', + 'plantare.removeConfirm' => 'Remover este compromisso?', + 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', + 'plantare.overdue' => 'vencido', + 'plantare.dueByLabel' => 'Devolver até (opcional)', + 'plantare.dueByHint' => 'Um lembrete gentil, nunca imposto', + 'plantare.pickDate' => 'Escolher data', + 'plantare.clearDate' => 'Limpar data', + 'plantare.sectionTitle' => 'Compromissos', + 'handover.title' => 'Dei ou recebi sementes', + 'handover.help' => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.', + 'handover.iGave' => 'Dei sementes', + 'handover.iReceived' => 'Recebi sementes', + 'handover.whichLot' => 'De que lote?', + 'handover.howMuch' => 'Quanto?', + 'handover.allOfIt' => 'Tudo', + 'handover.partOfIt' => 'Uma parte', + 'handover.paymentChip' => 'Houve dinheiro pelo meio', + 'handover.promiseGave' => 'Vão devolver-me semente', + 'handover.promiseReceived' => 'Vou devolver semente', + 'sale.title' => 'Vendas', + 'sale.help' => 'Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.', + 'sale.add' => 'Registar venda', + _ => null, + } ?? switch (path) { + 'sale.empty' => 'Ainda não há vendas. Anota aqui o que vendes ou compras.', + 'sale.iSold' => 'Vendi', + 'sale.iBought' => 'Comprei', + 'sale.direction' => 'Vendes ou compras?', + 'sale.counterparty' => 'Com quem?', + 'sale.counterpartyHint' => 'Uma pessoa ou um coletivo (opcional)', + 'sale.amount' => 'Montante', + 'sale.currency' => 'Moeda', + 'sale.currencyHint' => '€, Ğ1, horas… (opcional)', + 'sale.hours' => 'horas', + 'sale.note' => 'Nota (opcional)', + 'sale.save' => 'Guardar', + 'sale.delete' => 'Remover', + 'sale.removeConfirm' => 'Remover esta venda?', + 'legal.title' => 'Privacidade e regras', + 'legal.subtitle' => 'A tua privacidade, as regras do mercado e a legalidade das sementes', + 'legal.privacyTitle' => 'A tua privacidade', + 'legal.privacyBody' => 'O Tane funciona sem conta, e tudo o que registas fica no teu dispositivo, cifrado. Não há publicidade, nem rastreadores, nem servidor do Tane.\n\nNada é partilhado a não ser que tu queiras: quando publicas uma oferta ou o teu perfil, ou envias uma mensagem, viaja por servidores comunitários. As ofertas levam apenas uma zona aproximada — nunca a tua morada.\n\nO que publicas é público, e podem ficar cópias mesmo depois de o retirares — por isso partilha com cuidado.', + 'legal.rulesTitle' => 'As regras do jogo', + 'legal.rulesBody' => 'O Tane é uma ferramenta, não uma loja: as trocas são acordadas diretamente entre pessoas e ninguém fica com comissão. Isso também significa que és responsável pelo que ofereces e envias.\n\nNo mercado: sê honesto com as tuas sementes, oferece apenas o que podes partilhar, trata bem as pessoas e não faças spam. Podes bloquear qualquer pessoa e denunciar ofertas ou pessoas que quebrem as regras — as denúncias são tratadas nos servidores comunitários.', + 'legal.seedsTitle' => 'Sobre partilhar sementes e mudas', + 'legal.seedsBody' => 'Oferecer e trocar sementes entre amadores é amplamente reconhecido na maioria dos países. Vender pode ser diferente: em muitos lugares, vender sementes de variedades não registadas oficialmente é restrito — consulta as regras locais antes de pedir um preço.\n\nEnviar sementes para outro país também costuma ser restrito, e as variedades protegidas comercialmente não podem ser propagadas sem autorização. Na dúvida, melhor local e melhor oferta.\n\nO mesmo se aplica a mudas e plantas jovens, mas a planta viva pode ter regras de transporte e fitossanitárias mais rígidas do que a semente: movê-la entre regiões ou países pode exigir controlos adicionais.', + 'legal.readFull' => 'Ler os documentos completos na web', + 'marketGate.title' => 'Antes de entrares no mercado', + 'marketGate.intro' => 'O mercado é um espaço partilhado entre vizinhas e vizinhos. Ao continuar, aceitas umas poucas regras simples:', + 'marketGate.ruleHonest' => 'Sê honesto com as sementes que ofereces', + 'marketGate.ruleLegal' => 'Partilha apenas o que podes partilhar onde vives', + 'marketGate.ruleRespect' => 'Trata bem as pessoas — sem spam nem abusos', + 'marketGate.publicNote' => 'O que publicares aqui é público, e podem ficar cópias mesmo que o retires depois.', + 'marketGate.viewLegal' => 'Privacidade e regras', + 'marketGate.accept' => 'Aceito', + 'marketGate.decline' => 'Agora não', + 'report.offer' => 'Denunciar esta oferta', + 'report.person' => 'Denunciar esta pessoa', + 'report.title' => 'Denunciar', + 'report.prompt' => 'O que se passa?', + 'report.reasonSpam' => 'Spam ou uma burla', + 'report.reasonAbuse' => 'Abusivo ou desrespeitoso', + 'report.reasonIllegal' => 'Sementes que não deviam ser oferecidas', + 'report.reasonOther' => 'Outra coisa', + 'report.detailsHint' => 'Acrescenta detalhes (opcional)', + 'report.send' => 'Enviar denúncia', + 'report.sentHidden' => 'Denúncia enviada — já não voltas a ver isto', + 'report.failed' => 'Não foi possível enviar a denúncia — verifica a tua ligação', + 'report.alsoBlock' => 'Bloquear também esta pessoa', + 'block.action' => 'Bloquear esta pessoa', + 'block.confirmTitle' => 'Bloquear esta pessoa?', + 'block.confirmBody' => 'Deixas de ver as suas ofertas e mensagens. Podes desbloqueá-la mais tarde em Pessoas bloqueadas, na configuração de partilha.', + 'block.confirm' => 'Bloquear', + 'block.blockedToast' => 'Pessoa bloqueada — as suas ofertas e mensagens ficam ocultas', + 'block.manageTitle' => 'Pessoas bloqueadas', + 'block.manageEmpty' => 'Não bloqueaste ninguém', + 'block.unblock' => 'Desbloquear', _ => null, }; } diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index 3239bd7..0a4c2db 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -1,25 +1,22 @@ import 'package:flutter/widgets.dart'; -import 'app.dart'; -import 'data/species_repository.dart'; -import 'data/variety_repository.dart'; -import 'di/injector.dart'; +import 'bootstrap.dart'; import 'i18n/strings.g.dart'; -import 'services/onboarding_store.dart'; +import 'ui/restart_widget.dart'; -Future main() async { +void main() { WidgetsFlutterBinding.ensureInitialized(); + // Start from the device language, then let an explicit in-app choice win — + // it's the only reliable way to reach languages the OS picker doesn't offer + // (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI. LocaleSettings.useDeviceLocaleSync(); - await configureDependencies(); - final onboarding = getIt(); + // Paint immediately: [Bootstrap] shows a splash and runs the heavy init off + // the first frame. Wrapped in [RestartWidget] so switching the social identity + // (which re-registers the social singletons) can rebuild the whole tree — + // Bootstrap re-reads its dependencies from the locator on each rebuild. runApp( - TranslationProvider( - child: TaneApp( - repository: getIt(), - species: getIt(), - onboarding: onboarding, - showIntro: !await onboarding.introSeen(), - ), + RestartWidget( + builder: () => TranslationProvider(child: const Bootstrap()), ), ); } diff --git a/apps/app_seeds/lib/services/auto_backup_service.dart b/apps/app_seeds/lib/services/auto_backup_service.dart new file mode 100644 index 0000000..b8e1746 --- /dev/null +++ b/apps/app_seeds/lib/services/auto_backup_service.dart @@ -0,0 +1,97 @@ +import 'dart:io'; + +import 'auto_backup_store.dart'; +import 'export_import_service.dart'; + +/// Makes a silent, encrypted safety copy of the inventory on a schedule — a net +/// against in-app data loss or DB corruption, with zero friction. Triggered +/// from the app lifecycle (see [AutoBackupGate]), it writes into the app's +/// private storage; the manual "save a copy" stays the way to move one off the +/// device. +/// +/// Copies are **sealed** (same [ExportImportService.buildSealedBackup] bytes as +/// a manual backup): they reveal nothing at rest and open with the recovery +/// code. The newest few are kept and older ones rotated out. +class AutoBackupService { + AutoBackupService({ + required ExportImportService exporter, + required AutoBackupStore store, + required Future Function() directory, + DateTime Function() now = DateTime.now, + }) : _exporter = exporter, + _store = store, + _directory = directory, + _now = now; + + final ExportImportService _exporter; + final AutoBackupStore _store; + final Future Function() _directory; + final DateTime Function() _now; + + /// Filename prefix so rotation only ever touches automatic copies, never a + /// backup the user saved by hand. + static const _prefix = 'tane-auto-'; + + /// How many automatic copies to keep. A rotating window (not a single file) + /// so a corrupt copy can't overwrite the only backup. + static const _keep = 3; + + /// How often an automatic copy is made. The single source of truth for both + /// the schedule ([runIfDue]'s default) and the reassurance copy in Settings, + /// so the two can never drift apart. + static const Duration backupInterval = Duration(days: 7); + + /// When the last automatic copy was made, or null if none has run yet. For a + /// reassurance line in Settings. + Future lastBackupAt() => _store.lastBackupAt(); + + /// Makes a fresh copy when at least [interval] has passed since the last one. + /// Returns true when it wrote a copy, false when not due (or on any failure — + /// a backup must never crash startup or the move to the background). + Future runIfDue({Duration interval = backupInterval}) async { + try { + final last = await _store.lastBackupAt(); + if (last != null && _now().difference(last) < interval) return false; + } on Object { + return false; + } + return backupNow(); + } + + /// Makes a copy right now, ignoring the schedule — for an explicit "back up + /// now" tap. Returns true on success, false on any failure (never throws). + Future backupNow() async { + try { + final now = _now(); + final dir = await _directory(); + await dir.create(recursive: true); + final sealed = await _exporter.buildSealedBackup(); + final date = now.toIso8601String().substring(0, 10); + final file = File('${dir.path}/$_prefix$date.tane'); + await file.writeAsBytes(sealed, flush: true); + + await _rotate(dir); + await _store.markBackupAt(now); + return true; + } on Object { + // Silent net: a failed copy is logged nowhere sensitive and never thrown. + return false; + } + } + + /// Keeps the newest [_keep] automatic copies; deletes the rest. Filenames end + /// in an ISO date, so a lexical sort is chronological. + Future _rotate(Directory dir) async { + final copies = + (await dir.list().toList()) + .whereType() + .where((f) => f.uri.pathSegments.last.startsWith(_prefix)) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + final excess = copies.length - _keep; + if (excess <= 0) return; + for (final stale in copies.take(excess)) { + await stale.delete(); + } + } +} diff --git a/apps/app_seeds/lib/services/auto_backup_store.dart b/apps/app_seeds/lib/services/auto_backup_store.dart new file mode 100644 index 0000000..69bba8c --- /dev/null +++ b/apps/app_seeds/lib/services/auto_backup_store.dart @@ -0,0 +1,24 @@ +import '../security/secret_store.dart'; + +/// Remembers when the last automatic backup ran, so the app only makes a fresh +/// copy once the interval has elapsed. Backed by the OS keystore (via +/// [SecretStore]) to honour the "no plaintext at rest" rule — mirrors +/// [OnboardingStore]; there is no shared_preferences here. +class AutoBackupStore { + AutoBackupStore(this._store); + + final SecretStore _store; + + static const _lastKey = 'tane.auto_backup.last'; + + /// When the last automatic backup succeeded, or null if none has run yet. + Future lastBackupAt() async { + final raw = await _store.read(_lastKey); + if (raw == null) return null; + return DateTime.tryParse(raw); + } + + /// Records that an automatic backup succeeded at [when]. + Future markBackupAt(DateTime when) => + _store.write(_lastKey, when.toIso8601String()); +} diff --git a/apps/app_seeds/lib/services/coarse_location.dart b/apps/app_seeds/lib/services/coarse_location.dart new file mode 100644 index 0000000..aa576ec --- /dev/null +++ b/apps/app_seeds/lib/services/coarse_location.dart @@ -0,0 +1,50 @@ +import 'package:geolocator/geolocator.dart'; + +/// Supplies the device's *approximate* location (or null when unavailable or +/// denied). Behind an interface so the UI is fakeable in tests and the concrete +/// platform plugin stays at the composition root. The caller immediately reduces +/// the result to a low-precision geohash — a precise fix never leaves the device. +abstract interface class CoarseLocationProvider { + Future<({double lat, double lon})?> currentCoarseLatLon(); +} + +/// [geolocator]-backed implementation. Requests low accuracy only, and returns +/// null on any denial/error rather than throwing, so the UI can degrade quietly. +class GeolocatorCoarseLocation implements CoarseLocationProvider { + const GeolocatorCoarseLocation(); + + @override + Future<({double lat, double lon})?> currentCoarseLatLon() async { + try { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + // Denied (or turned off): return null quietly — the UI shows an actionable + // message. We don't yank the user into system settings. + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return null; + } + if (!await Geolocator.isLocationServiceEnabled()) return null; + + // A fresh coarse fix can take a while (or never come indoors); fall back + // to the last known position so the button stays responsive. + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 12), + ), + ); + } catch (_) { + position = await Geolocator.getLastKnownPosition(); + } + if (position == null) return null; + return (lat: position.latitude, lon: position.longitude); + } catch (_) { + return null; // plugin unsupported (desktop), etc. + } + } +} diff --git a/apps/app_seeds/lib/services/device_id_store.dart b/apps/app_seeds/lib/services/device_id_store.dart new file mode 100644 index 0000000..9c2e7f4 --- /dev/null +++ b/apps/app_seeds/lib/services/device_id_store.dart @@ -0,0 +1,34 @@ +import 'dart:math'; + +import '../security/secret_store.dart'; + +/// A stable per-INSTALL device id, kept in the keystore. Distinct from the +/// seed-derived CRDT node id (`rootSeedHex.substring(0,16)`), which is IDENTICAL +/// across a user's devices — so it can't tell them apart. Device-to-device sync +/// needs each device to own a separate record, hence a random per-install id. +class DeviceIdStore { + DeviceIdStore(this._store, {Random? random}) : _random = random ?? Random.secure(); + + final SecretStore _store; + final Random _random; + + static const _key = 'tane.device.id'; + + Future? _cached; + + /// This install's device id, generated and persisted on first use. + Future deviceId() => _cached ??= _readOrCreate(); + + Future _readOrCreate() async { + final existing = await _store.read(_key); + if (existing != null && existing.isNotEmpty) return existing; + final id = _randomHex(8); // 64 bits — plenty to avoid device collisions + await _store.write(_key, id); + return id; + } + + String _randomHex(int bytes) => [ + for (var i = 0; i < bytes; i++) + _random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ].join(); +} diff --git a/apps/app_seeds/lib/services/discovery_area.dart b/apps/app_seeds/lib/services/discovery_area.dart new file mode 100644 index 0000000..bb8d246 --- /dev/null +++ b/apps/app_seeds/lib/services/discovery_area.dart @@ -0,0 +1,11 @@ +/// The geohash prefix to search offers by, given the user's coarse [area] and +/// the chosen [precision] (a prefix length; see +/// `SocialSettings.searchPrecision`). +/// +/// Publishing emits a NIP-52 prefix ladder from the (already coarse) area, so a +/// SHORTER search prefix still matches finer offers. Searching at the full area +/// length only matches the exact ±2.4 km cell — which is why two neighbours in +/// adjacent cells used to find nothing. We recorten the area to [precision] (but +/// never beyond its own length) so "your zone" is genuinely a zone, not a point. +String searchPrefix(String area, int precision) => + area.substring(0, area.length < precision ? area.length : precision); diff --git a/apps/app_seeds/lib/services/export_import_service.dart b/apps/app_seeds/lib/services/export_import_service.dart index 0981682..94f0efa 100644 --- a/apps/app_seeds/lib/services/export_import_service.dart +++ b/apps/app_seeds/lib/services/export_import_service.dart @@ -9,6 +9,7 @@ import '../data/export_import/inventory_snapshot.dart'; import '../data/variety_repository.dart'; import '../security/secure_key_store.dart'; import 'file_service.dart'; +import 'inventory_snapshot_io.dart'; /// A picked backup file awaiting restore — kept in memory so asking the user /// for a recovery code never forces a second file dialog. @@ -27,7 +28,7 @@ class PendingBackup { /// Backups are **sealed** (encrypted under a key derived from the root seed; /// backup-and-recovery.md): a copy that lands on someone's cloud folder /// reveals nothing. The printed recovery code opens them on any device. -class ExportImportService { +class ExportImportService implements InventorySnapshotIO { ExportImportService({ required VarietyRepository repository, required FileService files, @@ -47,17 +48,37 @@ class ExportImportService { static const _csvCodec = InventoryCsvCodec(); /// The backup file extension. The content is the sealed interchange JSON. - static const backupExtension = 'tanemaki'; + static const backupExtension = 'tane'; + + /// Raw (unsealed) interchange snapshot for device-to-device sync: includes + /// tombstones (so deletions replicate) and omits photo bytes (kept device- + /// local). The sync transport (not this) does the encryption. + @override + Future> buildSnapshot() async => + utf8.encode(_jsonCodec.encode(await _repository.exportForSync())); + + /// Merges a raw interchange snapshot from another device (LWW, idempotent). + @override + Future applySnapshot(List bytes) async { + await _repository.importInventory(_jsonCodec.decode(utf8.decode(bytes))); + } + + /// Builds the full inventory as a sealed (encrypted) backup — the same bytes + /// [exportBackup] writes to a file. Shared with the automatic backup so both + /// paths seal identically. + Future buildSealedBackup() async { + final snapshot = await _repository.exportInventory(); + final plain = utf8.encode(_jsonCodec.encode(snapshot)); + return BackupBox().seal( + plain, + rootSeed: _hexToBytes(await _keys.rootSeedHex()), + ); + } /// Exports the full inventory as a sealed backup file. Returns true when /// saved, false when the user cancelled the save dialog. Future exportBackup() async { - final snapshot = await _repository.exportInventory(); - final plain = utf8.encode(_jsonCodec.encode(snapshot)); - final sealed = await BackupBox().seal( - plain, - rootSeed: _hexToBytes(await _keys.rootSeedHex()), - ); + final sealed = await buildSealedBackup(); final path = await _files.saveFile( suggestedName: _fileName(backupExtension), bytes: sealed, @@ -129,7 +150,7 @@ class ExportImportService { String _fileName(String extension) { final date = _now().toIso8601String().substring(0, 10); - return 'tanemaki-backup-$date.$extension'; + return 'tane-backup-$date.$extension'; } static List _hexToBytes(String hex) => [ diff --git a/apps/app_seeds/lib/services/inbox_service.dart b/apps/app_seeds/lib/services/inbox_service.dart new file mode 100644 index 0000000..f629810 --- /dev/null +++ b/apps/app_seeds/lib/services/inbox_service.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/foundation.dart'; + +import '../i18n/strings.g.dart'; +import 'message_store.dart'; +import 'notification_service.dart'; +import 'profile_cache.dart'; +import 'social_connection.dart'; +import 'social_service.dart' show SocialSession; +import 'social_settings.dart'; +import 'unread_service.dart'; + +/// App-wide inbox listener for private messages (NIP-17). +/// +/// Without this, a message only arrived while its specific chat screen was open +/// — so a first message from a new peer was invisible. This keeps one inbox +/// subscription over the SHARED [SocialConnection] for the whole app while +/// online: it persists every incoming message to the [MessageStore], updates +/// unread counts, fires an OS notification, and fires [changes] so the inbox +/// list refreshes. It re-subscribes automatically each time the connection +/// reconnects (and stops when it drops), because it listens to the connection's +/// session stream rather than owning a socket itself. +/// +/// Foreground only — background/push delivery is a later concern. +class InboxService { + InboxService({ + required SocialConnection connection, + required String selfPubkey, + required MessageStore store, + ProfileCache? profileCache, + UnreadService? unread, + NotificationService? notifications, + SocialSettings? settings, + }) : _connection = connection, + _selfPubkey = selfPubkey, + _store = store, + _profileCache = profileCache, + _unread = unread, + _notifications = notifications, + _settings = settings; + + final SocialConnection _connection; + final String _selfPubkey; + final MessageStore _store; + final ProfileCache? _profileCache; + final UnreadService? _unread; + final NotificationService? _notifications; + + /// Holds the local blocklist; messages from blocked peers are dropped before + /// they are stored or notified. Null in tests → nothing filtered. + final SocialSettings? _settings; + + final _changes = StreamController.broadcast(); + StreamSubscription? _sessionsSub; + StreamSubscription? _inboxSub; + SocialSession? _session; + bool _started = false; + + /// Fires (no payload) after a NEW message is persisted — the inbox list + /// listens to reload. Broadcast, so several screens can listen. + Stream get changes => _changes.stream; + + /// Begins listening. Subscribe to the connection's sessions BEFORE the + /// connection starts connecting, so the first session is caught too. + void start() { + if (_started) return; + _started = true; + _sessionsSub = _connection.sessions.listen(_onSession); + // In case the connection is already up (a screen connected first). + final current = _connection.current; + if (current != null) _onSession(current); + } + + /// (Re)binds the inbox subscription to [session] — the connection handing us a + /// fresh session (reconnect) or null (dropped). + void _onSession(SocialSession? session) { + if (identical(session, _session) && _inboxSub != null) return; + unawaited(_inboxSub?.cancel()); + _inboxSub = null; + _session = session; + if (session != null) { + _inboxSub = session.messages.inbox().listen( + ingest, + onError: (_) {}, // drop handled by the connection's reconnect + ); + } + } + + /// Persists one incoming [message] (deduped) and, when new, fires [changes], + /// updates unread, notifies, and best-effort caches the sender's name. A + /// testable seam (no relay). + @visibleForTesting + Future ingest(PrivateMessage message) async { + final settings = _settings; + if (settings != null && await settings.isBlocked(message.fromPubkey)) { + return; // blocked peer — dropped before storage, unread or notification + } + final stored = await _store.append(message.fromPubkey, message); + if (!stored) return; // a re-delivered duplicate + if (!_changes.isClosed) _changes.add(null); + + final peer = message.fromPubkey; + // Defensive: never badge or notify for your own message. NIP-17 doesn't + // loop a sent gift wrap back to its author; the guard keeps the seam honest. + if (peer != _selfPubkey) { + await _unread?.onMessageReceived(peer, message); + await _maybeNotify(peer); + } + await _cacheName(peer); + } + + /// Fires a text-free OS notification for a new message from [peer], unless its + /// chat is the one already on screen (then the message is visible anyway). + Future _maybeNotify(String peer) async { + final notifications = _notifications; + if (notifications == null) return; + if (_unread?.activePeer == peer) return; + final name = await _profileCache?.name(peer) ?? shortPubkey(peer); + await notifications.showMessage( + peerPubkey: peer, + title: t.notifications.newMessageFrom(name: name), + ); + } + + Future _cacheName(String peerPubkey) async { + final cache = _profileCache; + final session = _session; + if (cache == null || session == null) return; + // Fetch once we're missing either the name or the avatar for this peer. + final haveName = await cache.name(peerPubkey) != null; + final havePic = await cache.picture(peerPubkey) != null; + if (haveName && havePic) return; + try { + final profile = await session.profile.fetch(peerPubkey); + if (profile == null) return; + if (profile.name.isNotEmpty) await cache.setName(peerPubkey, profile.name); + if (profile.picture.isNotEmpty) { + await cache.setPicture(peerPubkey, profile.picture); + } + if ((profile.name.isNotEmpty || profile.picture.isNotEmpty) && + !_changes.isClosed) { + _changes.add(null); // re-render with the name/avatar + } + } catch (_) { + // best effort — the short key shows meanwhile + } + } + + Future stop() async { + _started = false; + await _sessionsSub?.cancel(); + _sessionsSub = null; + await _inboxSub?.cancel(); + _inboxSub = null; + _session = null; + if (!_changes.isClosed) await _changes.close(); + } +} diff --git a/apps/app_seeds/lib/services/inventory_snapshot_io.dart b/apps/app_seeds/lib/services/inventory_snapshot_io.dart new file mode 100644 index 0000000..7021bcc --- /dev/null +++ b/apps/app_seeds/lib/services/inventory_snapshot_io.dart @@ -0,0 +1,12 @@ +/// Reads/writes the inventory as a raw (unsealed) interchange snapshot — the +/// same JSON a backup uses, but WITHOUT the file-level sealing, because the sync +/// transport already encrypts on the wire. [SyncService] depends on this +/// interface (not the whole export service) so its logic is fakeable. +abstract interface class InventorySnapshotIO { + /// The full inventory serialized to interchange bytes. + Future> buildSnapshot(); + + /// Merges an incoming snapshot into the local inventory (LWW by HLC, + /// idempotent — re-applying the same or older state is a no-op). + Future applySnapshot(List bytes); +} diff --git a/apps/app_seeds/lib/services/label_sheet_service.dart b/apps/app_seeds/lib/services/label_sheet_service.dart new file mode 100644 index 0000000..d109f08 --- /dev/null +++ b/apps/app_seeds/lib/services/label_sheet_service.dart @@ -0,0 +1,227 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +import 'file_service.dart'; +import 'pdf_fonts.dart'; + +/// How the labels are tiled on the page: many small stickers to peel onto +/// packets, or fewer larger cards for jars and boxes. +enum LabelSheetFormat { stickers, cards } + +/// One printable seed label. All strings arrive already localized — this +/// service knows nothing about i18n or the domain. [qrData] is the opaque +/// payload for the QR (built by the caller via `SeedLabelCodec`). +class LabelSheetLabel { + const LabelSheetLabel({ + required this.varietyLabel, + required this.qrData, + this.commonName, + this.scientificName, + this.details, + }); + + /// The variety's label, as its keeper writes it ("Acelga de Perales"). + final String varietyLabel; + + /// The species' common name in the keeper's locale, shown uppercase on top + /// ("ACELGA"), like the old hand-written labels. Null when unknown. + final String? commonName; + + /// Scientific name of the linked species (rendered in italics), if any. + final String? scientificName; + + /// The batch facts line ("2006 · Perales · 2 cobs"), if any. + final String? details; + + /// The QR payload (a `tane://seed` URI), so the seed's data rides on the + /// physical packet. + final String qrData; +} + +/// Fixed page geometry per [LabelSheetFormat], in PDF points. +class _Geometry { + const _Geometry({ + required this.columns, + required this.rowHeight, + required this.qr, + required this.common, + required this.title, + required this.small, + }); + + final int columns; + final double rowHeight; + final double qr; + final double common; + final double title; + final double small; +} + +/// Renders a sheet of seed labels (each with a QR) as a PDF and hands it to the +/// user via the save dialog — a paper bridge that needs no network. Pure of +/// platform channels apart from loading the bundled font, so it runs (and is +/// tested) on the host. +/// +/// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek, with +/// Noto Arabic + Noto CJK as per-glyph fallbacks (see [buildPdfTheme]) so RTL +/// and CJK text render. Fonts are a per-locale resource, extensible to any +/// script. +class LabelSheetService { + LabelSheetService({required FileService files}) : _files = files; + + final FileService _files; + + /// Outer page margin (points, ~8.5 mm). + static const _margin = 24.0; + + static _Geometry _geometryOf(LabelSheetFormat format) => switch (format) { + // ~3 columns of ~64 mm stickers, tight text. + LabelSheetFormat.stickers => const _Geometry( + columns: 3, + rowHeight: 104, + qr: 46, + common: 7, + title: 9, + small: 6.5, + ), + // ~2 columns of ~96 mm cards, room for the full facts line. + LabelSheetFormat.cards => const _Geometry( + columns: 2, + rowHeight: 152, + qr: 82, + common: 9, + title: 13, + small: 9, + ), + }; + + Future buildPdf({ + required List labels, + required LabelSheetFormat format, + bool rtl = false, + }) async { + final textDirection = rtl ? pw.TextDirection.rtl : pw.TextDirection.ltr; + final fonts = await loadPdfFonts(); + final doc = pw.Document(theme: fonts.theme); + + final g = _geometryOf(format); + final labelWidth = (PdfPageFormat.a4.width - _margin * 2) / g.columns; + + pw.Widget labelBox(LabelSheetLabel label) => pw.Container( + width: labelWidth, + height: g.rowHeight, + padding: const pw.EdgeInsets.all(6), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey400, width: 0.5), + ), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.BarcodeWidget( + barcode: pw.Barcode.qrCode(), + data: label.qrData, + width: g.qr, + height: g.qr, + drawText: false, + ), + pw.SizedBox(width: 6), + pw.Expanded( + // Only the label's text mirrors for RTL; the grid itself stays + // left-to-right (a physical sheet has no reading direction). + child: pw.Directionality( + textDirection: textDirection, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.center, + mainAxisSize: pw.MainAxisSize.min, + children: [ + if (label.commonName != null) + pw.Text( + label.commonName!.toUpperCase(), + maxLines: 1, + overflow: pw.TextOverflow.clip, + style: pw.TextStyle( + font: fonts.bold, + fontSize: g.common, + color: PdfColors.grey800, + ), + ), + pw.Text( + label.varietyLabel, + maxLines: 2, + overflow: pw.TextOverflow.clip, + style: pw.TextStyle(font: fonts.bold, fontSize: g.title), + ), + if (label.scientificName != null) + pw.Text( + label.scientificName!, + maxLines: 1, + overflow: pw.TextOverflow.clip, + style: pw.TextStyle( + fontSize: g.small, + fontStyle: pw.FontStyle.italic, + color: PdfColors.grey700, + ), + ), + if (label.details != null) + pw.Text( + label.details!, + maxLines: 2, + overflow: pw.TextOverflow.clip, + style: pw.TextStyle(fontSize: g.small), + ), + ], + ), + ), + ), + ], + ), + ); + + // One MultiPage child per grid row, so pagination breaks cleanly between + // rows and never splits a label. The last row is padded with empty cells to + // keep the grid left-aligned. + final rows = []; + for (var i = 0; i < labels.length; i += g.columns) { + final end = math.min(i + g.columns, labels.length); + rows.add( + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + for (var j = i; j < end; j++) labelBox(labels[j]), + for (var k = end - i; k < g.columns; k++) + pw.SizedBox(width: labelWidth), + ], + ), + ); + } + + doc.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(_margin), + build: (context) => rows, + ), + ); + return doc.save(); + } + + /// Builds the sheet and asks the user where to keep it. Returns true when + /// saved, false when they cancelled the dialog. + Future saveLabels({ + required List labels, + required LabelSheetFormat format, + required String suggestedName, + bool rtl = false, + }) async { + final bytes = await buildPdf(labels: labels, format: format, rtl: rtl); + final path = await _files.saveFile( + suggestedName: suggestedName, + bytes: bytes, + ); + return path != null; + } +} diff --git a/apps/app_seeds/lib/services/locale_store.dart b/apps/app_seeds/lib/services/locale_store.dart new file mode 100644 index 0000000..2abf399 --- /dev/null +++ b/apps/app_seeds/lib/services/locale_store.dart @@ -0,0 +1,45 @@ +import '../i18n/strings.g.dart'; +import '../security/secret_store.dart'; + +/// Remembers the language the user picked in Settings so it survives restarts. +/// An empty/absent value means "follow the device language". Backed by the OS +/// keystore (via [SecretStore]), like the rest of the app's small preferences — +/// honouring "no plaintext at rest". +/// +/// Why persist at all: the device language picker is an unreliable way to reach +/// minority languages (Android often doesn't offer Asturian, and slang would +/// fall back to the device default on every launch), so the choice made in-app +/// must be remembered explicitly. Asturian itself is modelled as the `es-AST` +/// locale (Spanish language + `AST` region), not `ast`, so Flutter's +/// Material/Cupertino localizations — which don't ship `ast` — resolve it +/// through Spanish instead of throwing. See [SettingsScreen] for the picker. +class LocaleStore { + LocaleStore(this._store); + + final SecretStore _store; + + static const _key = 'tane.locale'; + + /// The saved locale, or null when the user follows the device language. + /// Unknown tags (e.g. a locale removed in a later build) also read as null. + Future saved() async { + final raw = await _store.read(_key); + if (raw == null || raw.isEmpty) return null; + for (final locale in AppLocale.values) { + if (locale.name == raw) return locale; + } + return null; + } + + /// Persists [locale] and applies it immediately. + Future setLocale(AppLocale locale) async { + await _store.write(_key, locale.name); + await LocaleSettings.setLocale(locale); + } + + /// Forgets the explicit choice and follows the device language from now on. + Future useDeviceLocale() async { + await _store.write(_key, ''); + await LocaleSettings.useDeviceLocale(); + } +} diff --git a/apps/app_seeds/lib/services/message_store.dart b/apps/app_seeds/lib/services/message_store.dart new file mode 100644 index 0000000..d08d267 --- /dev/null +++ b/apps/app_seeds/lib/services/message_store.dart @@ -0,0 +1,122 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:drift/drift.dart'; + +import '../db/chat_database.dart'; + +/// A conversation preview for the messages inbox. +class ChatSummary { + const ChatSummary({ + required this.peerPubkey, + required this.lastText, + required this.lastAt, + }); + + final String peerPubkey; + final String lastText; + final DateTime lastAt; +} + +/// Persists 1:1 chat history in the encrypted [ChatDatabase] (so no plaintext at +/// rest). Append is O(log n) — a single indexed insert, not a read-modify-write +/// of the whole conversation — and history is uncapped: nothing is silently +/// dropped. De-duplication is a DB invariant (the [Messages] unique key), so a +/// gift wrap re-delivered by a relay never piles up. +/// +/// [accountScope] namespaces rows per social identity (empty = the original +/// identity's legacy scope). See [socialAccountScope]. +class MessageStore { + MessageStore(this._db, {String accountScope = ''}) : _scope = accountScope; + + final ChatDatabase _db; + final String _scope; + + /// Messages exchanged with [peerPubkey], oldest first. + Future> history(String peerPubkey) async { + final rows = + await (_db.select(_db.messages) + ..where( + (m) => + m.accountScope.equals(_scope) & + m.peerPubkey.equals(peerPubkey), + ) + // Tie-break equal timestamps by insertion order (id) so history + // is stable — matches the old append-order behaviour. + ..orderBy([ + (m) => OrderingTerm(expression: m.sentAt), + (m) => OrderingTerm(expression: m.id), + ])) + .get(); + return [for (final r in rows) _toMessage(r)]; + } + + /// Appends [message] to the conversation with [peerPubkey]. Idempotent: an + /// identical message (same sender, timestamp and text) is dropped instead of + /// duplicated, since a relay re-delivers stored gift wraps on every + /// (re)subscribe. Returns true only when the message was newly stored. + Future append(String peerPubkey, PrivateMessage message) { + final at = message.at.millisecondsSinceEpoch; + // SELECT-then-INSERT in a transaction so the dedup check and the write are + // atomic; the unique key is the backstop. Both hit the conversation index. + return _db.transaction(() async { + final existing = + await (_db.select(_db.messages) + ..where( + (m) => + m.accountScope.equals(_scope) & + m.peerPubkey.equals(peerPubkey) & + m.fromPubkey.equals(message.fromPubkey) & + m.sentAt.equals(at) & + m.body.equals(message.text), + ) + ..limit(1)) + .getSingleOrNull(); + if (existing != null) return false; + await _db + .into(_db.messages) + .insert( + MessagesCompanion.insert( + accountScope: _scope, + peerPubkey: peerPubkey, + fromPubkey: message.fromPubkey, + body: message.text, + sentAt: at, + ), + ); + return true; + }); + } + + /// Conversations, most-recently-active first (for the messages inbox), each + /// with its latest message. One indexed scan; the last message per peer is + /// picked in Dart (peer counts are small). + Future> conversations() async { + final rows = + await (_db.select(_db.messages) + ..where((m) => m.accountScope.equals(_scope)) + ..orderBy([ + (m) => + OrderingTerm(expression: m.sentAt, mode: OrderingMode.desc), + (m) => OrderingTerm(expression: m.id, mode: OrderingMode.desc), + ])) + .get(); + final seen = {}; + final out = []; + for (final r in rows) { + if (!seen.add(r.peerPubkey)) continue; // first (newest) per peer wins + out.add( + ChatSummary( + peerPubkey: r.peerPubkey, + lastText: r.body, + lastAt: DateTime.fromMillisecondsSinceEpoch(r.sentAt), + ), + ); + } + return out; + } + + PrivateMessage _toMessage(Message r) => PrivateMessage( + fromPubkey: r.fromPubkey, + text: r.body, + at: DateTime.fromMillisecondsSinceEpoch(r.sentAt), + ); +} diff --git a/apps/app_seeds/lib/services/notification_service.dart b/apps/app_seeds/lib/services/notification_service.dart new file mode 100644 index 0000000..18418cc --- /dev/null +++ b/apps/app_seeds/lib/services/notification_service.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// Shows an OS notification when a private message arrives while the app is in +/// the foreground. Foreground only by design — when the app is backgrounded the +/// message listener stops, so nothing fires (background/push is a later, larger +/// concern). Web and Windows have no local-notification support, so there this +/// whole service is an inert no-op. +/// +/// For privacy it never carries message text — the caller passes a generic +/// `New message from ` title (see [InboxService]). The peer's pubkey rides +/// along as the payload so a tap can open that exact chat via [onTapChat]. +class NotificationService { + NotificationService({ + FlutterLocalNotificationsPlugin? plugin, + bool? supported, + }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), + _supported = supported ?? _platformSupported; + + final FlutterLocalNotificationsPlugin _plugin; + final bool _supported; + bool _ready = false; + + /// Called with a peer pubkey when the user taps a message notification. The + /// app wires this to `router.push('/chat/')` once the router exists. + void Function(String peerPubkey)? onTapChat; + + static const _channelId = 'messages'; + + static bool get _platformSupported { + if (kIsWeb) return false; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + return true; + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + return false; + } + } + + /// Initialises the plugin and requests permission (Android 13+/iOS/macOS ask + /// at runtime). Idempotent and safe to call on any platform. + Future initialize() async { + if (!_supported || _ready) return; + const android = AndroidInitializationSettings('@mipmap/ic_launcher'); + const darwin = DarwinInitializationSettings(); + const linux = LinuxInitializationSettings(defaultActionName: 'Open'); + await _plugin.initialize( + const InitializationSettings( + android: android, + iOS: darwin, + macOS: darwin, + linux: linux, + ), + onDidReceiveNotificationResponse: (response) { + final payload = response.payload; + if (payload != null && payload.isNotEmpty) onTapChat?.call(payload); + }, + ); + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestNotificationsPermission(); + await _plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions(alert: true, badge: true, sound: true); + await _plugin + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions(alert: true, badge: true, sound: true); + _ready = true; + } + + /// Shows a notification for a new message. [title] is a generic, text-free + /// line like "New message from Alice"; [peerPubkey] becomes the tap payload. + /// No-op on unsupported platforms. + Future showMessage({ + required String peerPubkey, + required String title, + }) async { + if (!_supported) return; + const details = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Messages', + channelDescription: 'New private messages', + importance: Importance.high, + priority: Priority.high, + ), + iOS: DarwinNotificationDetails(), + macOS: DarwinNotificationDetails(), + linux: LinuxNotificationDetails(), + ); + // One notification per peer (same id replaces the previous), so a chatty + // peer doesn't stack a wall of notifications. + await _plugin.show(peerPubkey.hashCode, title, null, details, + payload: peerPubkey); + } +} diff --git a/apps/app_seeds/lib/services/offer_mapper.dart b/apps/app_seeds/lib/services/offer_mapper.dart new file mode 100644 index 0000000..d09b500 --- /dev/null +++ b/apps/app_seeds/lib/services/offer_mapper.dart @@ -0,0 +1,55 @@ +import 'package:commons_core/commons_core.dart'; + +import '../db/enums.dart'; + +/// Turns a locally-shared lot into a publishable [Offer]. Pure — no I/O — so it +/// is easy to test and keeps the privacy seam explicit: only the fields passed +/// here ever reach the network (never the whole lot/inventory, never an exact +/// address; the area is a coarse geohash chosen by the user). +class OfferMapper { + const OfferMapper._(); + + /// Maps the app's local sharing intent ([OfferStatus]) to the network offer + /// type. `private` lots are never published. + static OfferType offerTypeFor(OfferStatus sharing) => switch (sharing) { + OfferStatus.shared => OfferType.gift, + OfferStatus.exchange => OfferType.exchange, + OfferStatus.sell => OfferType.sale, + OfferStatus.private => + throw ArgumentError('private lots are not published to the network'), + }; + + /// Builds the [Offer] for a shared lot. [lotId] gives the offer a stable, + /// addressable id (re-publishing updates in place). [areaGeohash] is the + /// user's coarse area — the transport coarsens it further on the wire. + static Offer fromSharedLot({ + required String lotId, + required String authorPubkeyHex, + required String summary, + required OfferStatus sharing, + required String areaGeohash, + String? category, + bool isOrganic = false, + num? priceAmount, + String? priceCurrency, + String? exchangeTerms, + String? imageUrl, + DateTime? expiresAt, + }) { + final type = offerTypeFor(sharing); + return Offer( + id: lotId, + authorPubkeyHex: authorPubkeyHex, + summary: summary, + type: type, + approxGeohash: areaGeohash, + category: category, + isOrganic: isOrganic, + priceAmount: type == OfferType.sale ? priceAmount : null, + priceCurrency: type == OfferType.sale ? priceCurrency : null, + exchangeTerms: type == OfferType.exchange ? exchangeTerms : null, + imageUrl: imageUrl, + expiresAt: expiresAt, + ); + } +} diff --git a/apps/app_seeds/lib/services/offer_outbox.dart b/apps/app_seeds/lib/services/offer_outbox.dart new file mode 100644 index 0000000..f952d6d --- /dev/null +++ b/apps/app_seeds/lib/services/offer_outbox.dart @@ -0,0 +1,37 @@ +import '../security/secret_store.dart'; + +/// A durable "to publish when connected" queue of lot ids. When sharing is +/// attempted offline, the lot ids are parked here (keystore-backed, so no +/// plaintext at rest) and flushed once a relay is reachable — offline delivery +/// for the sender side. +/// +/// We store lot ids, not serialized offers: on flush the offer is rebuilt from +/// the lot's current state, so an edit made while offline is respected and a +/// since-deleted lot simply drops out. +class OfferOutbox { + OfferOutbox(this._store); + + final SecretStore _store; + static const _key = 'tane.social.outbox'; + + /// Lot ids waiting to be published. + Future> pending() async { + final raw = await _store.read(_key); + if (raw == null || raw.isEmpty) return {}; + return raw.split('\n').where((s) => s.isNotEmpty).toSet(); + } + + Future enqueue(Iterable lotIds) async { + final next = await pending()..addAll(lotIds); + await _write(next); + } + + Future remove(Iterable lotIds) async { + final next = await pending()..removeAll(lotIds.toSet()); + await _write(next); + } + + Future clear() => _store.write(_key, ''); + + Future _write(Set ids) => _store.write(_key, ids.join('\n')); +} diff --git a/apps/app_seeds/lib/services/offer_thumbnail.dart b/apps/app_seeds/lib/services/offer_thumbnail.dart new file mode 100644 index 0000000..81e756a --- /dev/null +++ b/apps/app_seeds/lib/services/offer_thumbnail.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +/// Builds a tiny `data:image/jpeg;base64,…` thumbnail from a full photo, small +/// enough to ride *inside* a Nostr offer event — no media server, fully +/// decentralized. The full-resolution photo stays in the encrypted local +/// inventory; only this shrunken preview is published so peers can see the seed. +/// +/// Relays cap event size, so we downscale (longest edge) and re-encode until the +/// base64 fits under [maxBytes]. Returns null when the bytes aren't a decodable +/// image or no size small enough is reachable — the offer then publishes without +/// a photo (graceful, never a hard failure). +String? offerThumbnailDataUri( + Uint8List bytes, { + int maxBytes = 40000, + List edges = const [320, 256, 192, 128], + int quality = 68, +}) { + try { + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + + for (final edge in edges) { + final resized = _fitWithin(decoded, edge); + final jpeg = img.encodeJpg(resized, quality: quality); + final b64 = base64.encode(jpeg); + if (b64.length <= maxBytes) return 'data:image/jpeg;base64,$b64'; + } + return null; // even the smallest edge didn't fit — skip the image + } catch (_) { + return null; // undecodable/corrupt bytes — publish without a photo + } +} + +/// Extracts the raw bytes from a `data:...;base64,…` URI, or null when [uri] is +/// not a base64 data URI. Used by the UI to render an inline thumbnail with +/// `Image.memory` instead of a network fetch. +Uint8List? decodeDataUri(String uri) { + if (!uri.startsWith('data:')) return null; + try { + return Uri.parse(uri).data?.contentAsBytes(); + } on FormatException { + return null; + } +} + +/// Resizes [image] so its longest edge is at most [edge], preserving aspect +/// ratio. Never upscales a photo that's already smaller. +img.Image _fitWithin(img.Image image, int edge) { + if (image.width <= edge && image.height <= edge) return image; + return image.width >= image.height + ? img.copyResize(image, width: edge) + : img.copyResize(image, height: edge); +} diff --git a/apps/app_seeds/lib/services/onboarding_store.dart b/apps/app_seeds/lib/services/onboarding_store.dart index 47611a0..75c54fb 100644 --- a/apps/app_seeds/lib/services/onboarding_store.dart +++ b/apps/app_seeds/lib/services/onboarding_store.dart @@ -9,10 +9,20 @@ class OnboardingStore { final SecretStore _store; static const _introSeenKey = 'tane.intro_seen'; + static const _marketRulesKey = 'tane.market_rules_accepted'; /// Whether the user has already seen (or skipped) the intro carousel. Future introSeen() async => await _store.read(_introSeenKey) == '1'; /// Records that the intro has been shown; subsequent launches skip it. Future markIntroSeen() => _store.write(_introSeenKey, '1'); + + /// Whether the user has accepted the community rules that gate the market. + /// Required once before joining the market or publishing anything (stores + /// ask for terms acceptance before user content is created). + Future marketRulesAccepted() async => + await _store.read(_marketRulesKey) == '1'; + + /// Records the one-time acceptance of the community rules. + Future markMarketRulesAccepted() => _store.write(_marketRulesKey, '1'); } diff --git a/apps/app_seeds/lib/services/pdf_fonts.dart b/apps/app_seeds/lib/services/pdf_fonts.dart new file mode 100644 index 0000000..3f5f943 --- /dev/null +++ b/apps/app_seeds/lib/services/pdf_fonts.dart @@ -0,0 +1,38 @@ +import 'package:flutter/services.dart' show rootBundle; +import 'package:pdf/widgets.dart' as pw; + +/// Fonts and theme shared by every generated PDF (labels, catalog, recovery +/// sheet). [theme] is the document-wide default; [bold] is exposed for the few +/// places that set a heading weight explicitly — merging against [theme] keeps +/// its script fallbacks, so bold Arabic/CJK still renders. +typedef PdfFonts = ({pw.ThemeData theme, pw.Font bold}); + +/// Loads the PDF fonts. +/// +/// The base face is DejaVu (Latin extended, Cyrillic, Greek) with Noto Arabic +/// and Noto CJK registered as per-glyph fallbacks, so vernacular species names, +/// user notes, and localized labels in Arabic (RTL) or CJK render instead of +/// showing tofu boxes. The `pdf` package subsets fonts to the glyphs actually +/// used, so bundling the full Noto faces does not bloat the output file. +/// +/// Fonts are a per-locale resource, extensible to any script — widen the +/// fallback list as more scripts land. +Future loadPdfFonts() async { + final base = pw.Font.ttf( + await rootBundle.load('assets/fonts/DejaVuSans.ttf'), + ); + final bold = pw.Font.ttf( + await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'), + ); + final arabic = pw.Font.ttf( + await rootBundle.load('assets/fonts/NotoSansArabic.ttf'), + ); + final cjk = pw.Font.ttf(await rootBundle.load('assets/fonts/NotoSansJP.ttf')); + final theme = pw.ThemeData.withFont( + base: base, + bold: bold, + italic: base, + fontFallback: [arabic, cjk], + ); + return (theme: theme, bold: bold); +} diff --git a/apps/app_seeds/lib/services/plantare_service.dart b/apps/app_seeds/lib/services/plantare_service.dart new file mode 100644 index 0000000..c8dbd5a --- /dev/null +++ b/apps/app_seeds/lib/services/plantare_service.dart @@ -0,0 +1,281 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/foundation.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import 'social_connection.dart'; +import 'social_service.dart' show SocialSession; + +/// Drives the bilateral SIGNED Plantaré (plantare-bilateral.md) at the app +/// layer: turns local intent into a signed proposal, counter-signs or declines +/// incoming ones, and reconciles every move into the local ledger +/// ([VarietyRepository]). The cryptography and wire format live in +/// `commons_core`; this class owns the app-side orchestration and persistence. +/// +/// Local-first: a proposal is recorded locally the moment it's made, and sent +/// over the shared connection when online (best-effort — a durable outbox/retry +/// is a follow-up). Incoming proposals are held in memory keyed by pledge id so +/// an accept counter-signs the exact, verified payload; the relay redelivers +/// them on reconnect (NIP-17), so this survives an app restart without a +/// separate store. +class PlantareService { + PlantareService({ + required VarietyRepository repo, + required String selfPubkey, + required String selfSecretKey, + SocialConnection? connection, + }) : _repo = repo, + _self = selfPubkey, + _secret = selfSecretKey, + _connection = connection; + + final VarietyRepository _repo; + final String _self; + final String _secret; + final SocialConnection? _connection; + + final _changes = StreamController.broadcast(); + final _pending = {}; + + StreamSubscription? _sessionsSub; + StreamSubscription? _incomingSub; + PlantareTransport? _transport; + bool _started = false; + + /// Fires (no payload) after an incoming move is reconciled — the UI reloads. + Stream get changes => _changes.stream; + + /// The proposals received and awaiting this identity's decision, keyed by + /// pledge id (in-memory; refilled from the relay on reconnect). + Map get pendingProposals => + Map.unmodifiable(_pending); + + /// Begins listening for incoming moves over the shared connection. + void start() { + if (_started || _connection == null) return; + _started = true; + _sessionsSub = _connection.sessions.listen(_onSession); + final current = _connection.current; + if (current != null) _onSession(current); + } + + void _onSession(SocialSession? session) { + unawaited(_incomingSub?.cancel()); + _incomingSub = null; + _transport = session?.plantares; + final incoming = session?.plantares.incoming(); + if (incoming != null) { + _incomingSub = incoming.listen(ingest, onError: (_) {}); + } + } + + /// Test seam: bind a transport without a live connection. + @visibleForTesting + void bindTransport(PlantareTransport transport) => _transport = transport; + + /// Proposes a Plantaré to [counterpartyKey]. [direction] is this identity's + /// side: [PlantareDirection.iReturn] = I received and owe (I'm the debtor); + /// [PlantareDirection.owedToMe] = I gave (I'm the creditor). Records the local + /// row immediately (remoteState=proposed, my stub) and sends the signed + /// proposal. Returns the shared pledge id. + Future propose({ + required PlantareDirection direction, + required String counterpartyKey, + String? varietyId, + required String label, + String? counterpartyName, + String? owedDescription, + DateTime? dueBy, + PlantareReturnKind returnKind = PlantareReturnKind.similar, + double? workHours, + String? movementId, + }) async { + final iAmDebtor = direction == PlantareDirection.iReturn; + final now = DateTime.now(); + final pledgeId = _repo.idGen.newId(); + var pledge = PlantarePledge( + pledgeId: pledgeId, + debtorKey: iAmDebtor ? _self : counterpartyKey, + creditorKey: iAmDebtor ? counterpartyKey : _self, + madeOn: now, + label: label, + owedDescription: owedDescription, + returnKind: _toCoreReturnKind(returnKind), + workHours: workHours, + dueBy: dueBy, + ); + final mySig = await PlantareCrypto.sign(pledge, _secret); + pledge = pledge.copyWith( + debtorSignature: iAmDebtor ? mySig : null, + creditorSignature: iAmDebtor ? null : mySig, + ); + + await _repo.createPlantare( + id: pledgeId, + direction: direction, + varietyId: varietyId, + counterparty: counterpartyName ?? counterpartyKey, + owedDescription: owedDescription, + madeOn: now.millisecondsSinceEpoch, + dueBy: dueBy?.millisecondsSinceEpoch, + pledgeId: pledgeId, + debtorKey: pledge.debtorKey, + creditorKey: pledge.creditorKey, + debtorSignature: pledge.debtorSignature, + creditorSignature: pledge.creditorSignature, + movementId: movementId, + remoteState: PlantareRemoteState.proposed, + returnKind: returnKind, + workHours: workHours, + ); + + await _transport?.propose(pledge); + return pledgeId; + } + + /// Counter-signs a received proposal [pledgeId] and sends the fully-signed + /// copy back, flipping the local row to accepted. Throws if the proposal isn't + /// in hand (e.g. never received, or the relay hasn't redelivered it yet). + Future accept(String pledgeId) async { + final proposed = _pending[pledgeId]; + if (proposed == null) { + throw StateError('no pending proposal $pledgeId to accept'); + } + final iAmDebtor = proposed.debtorKey == _self; + final mySig = await PlantareCrypto.sign(proposed, _secret); + final full = proposed.copyWith( + debtorSignature: iAmDebtor ? mySig : null, + creditorSignature: iAmDebtor ? null : mySig, + ); + await _transport?.accept(full); + await _repo.applyPlantareSignatures( + pledgeId: pledgeId, + debtorSignature: iAmDebtor ? mySig : null, + creditorSignature: iAmDebtor ? null : mySig, + remoteState: PlantareRemoteState.accepted, + ); + _pending.remove(pledgeId); + _emit(); + } + + /// Declines a received proposal [pledgeId], notifying its proposer. + Future decline(String pledgeId, {String reason = ''}) async { + final proposed = _pending[pledgeId]; + final proposer = proposed?.counterpartyOf(_self); + if (proposer != null) { + await _transport?.decline( + toPubkey: proposer, + pledgeId: pledgeId, + reason: reason, + ); + } + await _repo.setPlantareRemoteState(pledgeId, PlantareRemoteState.declined); + _pending.remove(pledgeId); + _emit(); + } + + /// Reconciles one incoming move into the local ledger. Testable seam (no + /// relay). Unverifiable/tampered moves are dropped. + @visibleForTesting + Future ingest(PlantareEnvelope envelope) async { + switch (envelope.kind) { + case PlantareMessageKind.proposed: + await _ingestProposed(envelope); + case PlantareMessageKind.accepted: + await _ingestAccepted(envelope); + case PlantareMessageKind.declined: + await _ingestDeclined(envelope); + } + } + + Future _ingestProposed(PlantareEnvelope envelope) async { + final pledge = envelope.pledge; + if (pledge == null) return; + // The proposer signs their own stub; verify it before storing. + final proposerIsDebtor = pledge.debtorKey == envelope.fromPubkey; + final theirSig = + proposerIsDebtor ? pledge.debtorSignature : pledge.creditorSignature; + if (theirSig == null || + !await PlantareCrypto.verify(pledge, envelope.fromPubkey, theirSig)) { + return; // unsigned or tampered — drop + } + // My side of the deal is the mirror of the proposer's. + final iAmDebtor = pledge.debtorKey == _self; + if (!iAmDebtor && pledge.creditorKey != _self) return; // not addressed to me + + _pending[pledge.pledgeId] = pledge; + if (await _repo.plantareByPledgeId(pledge.pledgeId) == null) { + await _repo.createPlantare( + direction: + iAmDebtor ? PlantareDirection.iReturn : PlantareDirection.owedToMe, + counterparty: envelope.fromPubkey, + owedDescription: pledge.owedDescription, + madeOn: pledge.madeOn.millisecondsSinceEpoch, + dueBy: pledge.dueBy?.millisecondsSinceEpoch, + pledgeId: pledge.pledgeId, + debtorKey: pledge.debtorKey, + creditorKey: pledge.creditorKey, + debtorSignature: pledge.debtorSignature, + creditorSignature: pledge.creditorSignature, + remoteState: PlantareRemoteState.proposed, + returnKind: _fromCoreReturnKind(pledge.returnKind), + workHours: pledge.workHours, + ); + } + _emit(); + } + + Future _ingestAccepted(PlantareEnvelope envelope) async { + final pledge = envelope.pledge; + if (pledge == null) return; + // An accept must carry BOTH valid stubs — a provably closed deal. + if (!await PlantareCrypto.verifyBoth(pledge)) return; + if (pledge.debtorKey != _self && pledge.creditorKey != _self) return; + if (await _repo.plantareByPledgeId(pledge.pledgeId) == null) return; + await _repo.applyPlantareSignatures( + pledgeId: pledge.pledgeId, + debtorSignature: pledge.debtorSignature, + creditorSignature: pledge.creditorSignature, + remoteState: PlantareRemoteState.accepted, + ); + _pending.remove(pledge.pledgeId); + _emit(); + } + + Future _ingestDeclined(PlantareEnvelope envelope) async { + final id = envelope.pledgeId; + if (id == null) return; + if (await _repo.plantareByPledgeId(id) == null) return; + await _repo.setPlantareRemoteState(id, PlantareRemoteState.declined); + _pending.remove(id); + _emit(); + } + + void _emit() { + if (!_changes.isClosed) _changes.add(null); + } + + Future stop() async { + _started = false; + await _sessionsSub?.cancel(); + _sessionsSub = null; + await _incomingSub?.cancel(); + _incomingSub = null; + _transport = null; + if (!_changes.isClosed) await _changes.close(); + } + + static ReturnKind _toCoreReturnKind(PlantareReturnKind k) => switch (k) { + PlantareReturnKind.similar => ReturnKind.similar, + PlantareReturnKind.workHours => ReturnKind.workHours, + PlantareReturnKind.other => ReturnKind.other, + }; + + static PlantareReturnKind _fromCoreReturnKind(ReturnKind k) => switch (k) { + ReturnKind.similar => PlantareReturnKind.similar, + ReturnKind.workHours => PlantareReturnKind.workHours, + ReturnKind.other => PlantareReturnKind.other, + }; +} diff --git a/apps/app_seeds/lib/services/profile_cache.dart b/apps/app_seeds/lib/services/profile_cache.dart new file mode 100644 index 0000000..b997bd3 --- /dev/null +++ b/apps/app_seeds/lib/services/profile_cache.dart @@ -0,0 +1,44 @@ +import '../security/secret_store.dart'; + +/// Remembers the display names peers have published (their NIP-01 kind:0 +/// `name`), so the inbox and chat show a human name instead of a raw key — +/// even offline. Keystore-backed (no plaintext at rest). +class ProfileCache { + /// [accountScope] namespaces the keys per social identity (empty = the + /// original identity's legacy keys). See [socialAccountScope]. + ProfileCache(this._store, {String accountScope = ''}) + : _prefix = accountScope.isEmpty + ? 'tane.social.name.' + : 'tane.social.$accountScope.name.'; + + final SecretStore _store; + final String _prefix; + + // Avatars peers have published (kind:0 `picture`) live under a parallel key + // namespace, so a name and an avatar are cached independently. + String get _picPrefix => '${_prefix.substring(0, _prefix.length - 5)}picture.'; + + /// The cached name for [pubkeyHex], or null if none is known. + Future name(String pubkeyHex) async { + final value = await _store.read('$_prefix$pubkeyHex'); + return (value == null || value.isEmpty) ? null : value; + } + + Future setName(String pubkeyHex, String name) => + _store.write('$_prefix$pubkeyHex', name.trim()); + + /// The cached avatar for [pubkeyHex] (a `data:` photo or `tane:seed:` + /// token), or null if none is known. + Future picture(String pubkeyHex) async { + final value = await _store.read('$_picPrefix$pubkeyHex'); + return (value == null || value.isEmpty) ? null : value; + } + + Future setPicture(String pubkeyHex, String picture) => + _store.write('$_picPrefix$pubkeyHex', picture.trim()); +} + +/// A compact, human-ish rendering of a public key when no name is known yet. +String shortPubkey(String pubkeyHex) => pubkeyHex.length <= 12 + ? pubkeyHex + : '${pubkeyHex.substring(0, 6)}…${pubkeyHex.substring(pubkeyHex.length - 4)}'; diff --git a/apps/app_seeds/lib/services/profile_store.dart b/apps/app_seeds/lib/services/profile_store.dart new file mode 100644 index 0000000..d16abd5 --- /dev/null +++ b/apps/app_seeds/lib/services/profile_store.dart @@ -0,0 +1,44 @@ +import '../security/secret_store.dart'; + +/// Your own display name and short "about", persisted locally (keystore-backed, +/// no plaintext at rest). The network copy is a published NIP-01 kind:0 event; +/// this local copy prefills the editor and works offline. +class ProfileStore { + /// [accountScope] namespaces the keys per social identity (empty = the + /// original identity's legacy keys). See [socialAccountScope]. + ProfileStore(this._store, {String accountScope = ''}) + : _base = accountScope.isEmpty + ? 'tane.social.profile.' + : 'tane.social.$accountScope.profile.'; + + final SecretStore _store; + final String _base; + + String get _nameKey => '${_base}name'; + String get _aboutKey => '${_base}about'; + String get _g1Key => '${_base}g1'; + String get _avatarKey => '${_base}avatar'; + + Future name() async => (await _store.read(_nameKey)) ?? ''; + Future about() async => (await _store.read(_aboutKey)) ?? ''; + + /// Your Ğ1 (Duniter) address, if you chose to share one. + Future g1() async => (await _store.read(_g1Key)) ?? ''; + + /// Your avatar: either a `data:image/jpeg;base64,…` photo thumbnail or a + /// `tane:seed:` illustration token (see `ui/avatar.dart`). Empty for + /// the default coloured-initial disc. Published as the kind:0 `picture`. + Future avatar() async => (await _store.read(_avatarKey)) ?? ''; + + Future save({ + required String name, + required String about, + String g1 = '', + String avatar = '', + }) async { + await _store.write(_nameKey, name.trim()); + await _store.write(_aboutKey, about.trim()); + await _store.write(_g1Key, g1.trim()); + await _store.write(_avatarKey, avatar.trim()); + } +} diff --git a/apps/app_seeds/lib/services/recovery_sheet_service.dart b/apps/app_seeds/lib/services/recovery_sheet_service.dart index ac688c5..b271c74 100644 --- a/apps/app_seeds/lib/services/recovery_sheet_service.dart +++ b/apps/app_seeds/lib/services/recovery_sheet_service.dart @@ -1,10 +1,10 @@ import 'dart:typed_data'; -import 'package:flutter/services.dart' show rootBundle; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'file_service.dart'; +import 'pdf_fonts.dart'; /// Renders the printable recovery sheet — a QR plus the typed code and short /// instructions ("keep two paper copies, like your best seed"). All strings @@ -19,22 +19,15 @@ class RecoverySheetService { required String intro, required String code, }) async { - final base = pw.Font.ttf( - await rootBundle.load('assets/fonts/DejaVuSans.ttf'), - ); - final bold = pw.Font.ttf( - await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'), - ); - final doc = pw.Document( - theme: pw.ThemeData.withFont(base: base, bold: bold), - ); + final fonts = await loadPdfFonts(); + final doc = pw.Document(theme: fonts.theme); doc.addPage( pw.Page( pageFormat: PdfPageFormat.a4, build: (context) => pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)), + pw.Text(title, style: pw.TextStyle(font: fonts.bold, fontSize: 20)), pw.SizedBox(height: 12), pw.Text(intro, style: const pw.TextStyle(fontSize: 11)), pw.SizedBox(height: 24), @@ -54,7 +47,7 @@ class RecoverySheetService { child: pw.Text( code, textAlign: pw.TextAlign.center, - style: pw.TextStyle(font: bold, fontSize: 12), + style: pw.TextStyle(font: fonts.bold, fontSize: 12), ), ), ], diff --git a/apps/app_seeds/lib/services/saved_offers_store.dart b/apps/app_seeds/lib/services/saved_offers_store.dart new file mode 100644 index 0000000..b168d70 --- /dev/null +++ b/apps/app_seeds/lib/services/saved_offers_store.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:commons_core/commons_core.dart'; + +import '../security/secret_store.dart'; + +/// A person's saved ("favorite") market offers — other people's listings they +/// bookmarked, Wallapop-style. Keystore-backed (no plaintext at rest) and +/// namespaced by the active identity's [accountScope], like [ProfileStore]. +/// +/// We keep a *snapshot* of each offer (not just its coordinate) so the Favorites +/// list renders offline, and so a since-withdrawn offer can still be shown as +/// "no longer available" instead of vanishing. The stable key is the offer's +/// addressable coordinate `authorPubkeyHex:id` ([keyOf]). +class SavedOffersStore { + SavedOffersStore(this._store, {String accountScope = ''}) + : _key = accountScope.isEmpty + ? 'tane.social.saved_offers' + : 'tane.social.$accountScope.saved_offers'; + + final SecretStore _store; + final String _key; + + final _changes = StreamController.broadcast(); + + /// Emits whenever the saved set changes, so open screens can refresh. + Stream get changes => _changes.stream; + + /// The stable per-offer key: its NIP-99 addressable coordinate without the + /// kind (`authorPubkeyHex:id`). Same offer → same key on every device. + static String keyOf(Offer offer) => '${offer.authorPubkeyHex}:${offer.id}'; + + /// Every saved offer, newest first (by save time). + Future> list() async { + final raw = await _store.read(_key); + if (raw == null || raw.isEmpty) return const []; + final entries = (jsonDecode(raw) as List) + .cast>() + .toList() + ..sort((a, b) => (b['savedAt'] as int).compareTo(a['savedAt'] as int)); + return [for (final e in entries) _decode(e)]; + } + + /// Whether [key] (from [keyOf]) is currently saved. + Future isSaved(String key) async => + (await _rawList()).any((e) => e['key'] == key); + + /// Saves [offer] (idempotent on its key). Newer snapshot replaces an older one. + Future save(Offer offer, {required int savedAt}) async { + final key = keyOf(offer); + final list = await _rawList()..removeWhere((e) => e['key'] == key); + list.add({..._encode(offer), 'key': key, 'savedAt': savedAt}); + await _writeRaw(list); + } + + /// Removes the saved offer with [key] (from [keyOf]); no-op if absent. + Future remove(String key) async { + final list = await _rawList()..removeWhere((e) => e['key'] == key); + await _writeRaw(list); + } + + /// Toggles [offer]'s saved state; returns the new state (true = now saved). + Future toggle(Offer offer, {required int savedAt}) async { + if (await isSaved(keyOf(offer))) { + await remove(keyOf(offer)); + return false; + } + await save(offer, savedAt: savedAt); + return true; + } + + Future>> _rawList() async { + final raw = await _store.read(_key); + if (raw == null || raw.isEmpty) return []; + return (jsonDecode(raw) as List).cast>().toList(); + } + + Future _writeRaw(List> list) async { + await _store.write(_key, jsonEncode(list)); + if (!_changes.isClosed) _changes.add(null); + } + + Map _encode(Offer o) => { + 'id': o.id, + 'author': o.authorPubkeyHex, + 'summary': o.summary, + 'type': o.type.name, + 'status': o.status.name, + if (o.category != null) 'category': o.category, + 'geohash': o.approxGeohash, + if (o.radiusKm != null) 'radiusKm': o.radiusKm, + if (o.priceAmount != null) 'priceAmount': o.priceAmount, + if (o.priceCurrency != null) 'priceCurrency': o.priceCurrency, + if (o.exchangeTerms != null) 'exchangeTerms': o.exchangeTerms, + if (o.imageUrl != null) 'imageUrl': o.imageUrl, + if (o.expiresAt != null) 'expiresAt': o.expiresAt!.millisecondsSinceEpoch, + 'isOrganic': o.isOrganic, + }; + + Offer _decode(Map e) => Offer( + id: e['id'] as String, + authorPubkeyHex: e['author'] as String, + summary: e['summary'] as String, + type: OfferType.values.byName(e['type'] as String), + status: OfferLifecycle.values.byName(e['status'] as String? ?? 'active'), + category: e['category'] as String?, + approxGeohash: e['geohash'] as String? ?? '', + radiusKm: e['radiusKm'] as int?, + priceAmount: e['priceAmount'] as num?, + priceCurrency: e['priceCurrency'] as String?, + exchangeTerms: e['exchangeTerms'] as String?, + imageUrl: e['imageUrl'] as String?, + expiresAt: e['expiresAt'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch(e['expiresAt'] as int), + isOrganic: e['isOrganic'] as bool? ?? false, + ); + + Future close() async { + await _changes.close(); + } +} diff --git a/apps/app_seeds/lib/services/share_catalog_service.dart b/apps/app_seeds/lib/services/share_catalog_service.dart index 469a7a3..7979a39 100644 --- a/apps/app_seeds/lib/services/share_catalog_service.dart +++ b/apps/app_seeds/lib/services/share_catalog_service.dart @@ -1,10 +1,10 @@ import 'dart:typed_data'; -import 'package:flutter/services.dart' show rootBundle; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'file_service.dart'; +import 'pdf_fonts.dart'; /// One printable line of the "what I share" catalog. All strings arrive /// already localized — this service knows nothing about i18n or the domain. @@ -47,20 +47,13 @@ class ShareCatalogService { required String date, required List rows, }) async { - final base = pw.Font.ttf( - await rootBundle.load('assets/fonts/DejaVuSans.ttf'), - ); - final bold = pw.Font.ttf( - await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'), - ); - final doc = pw.Document( - theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base), - ); + final fonts = await loadPdfFonts(); + final doc = pw.Document(theme: fonts.theme); doc.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, build: (context) => [ - pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)), + pw.Text(title, style: pw.TextStyle(font: fonts.bold, fontSize: 20)), pw.SizedBox(height: 2), pw.Text( date, @@ -84,7 +77,7 @@ class ShareCatalogService { children: [ pw.Text( row.name, - style: pw.TextStyle(font: bold, fontSize: 12), + style: pw.TextStyle(font: fonts.bold, fontSize: 12), ), if (row.scientificName != null) pw.Text( diff --git a/apps/app_seeds/lib/services/social_account_store.dart b/apps/app_seeds/lib/services/social_account_store.dart new file mode 100644 index 0000000..5c449c8 --- /dev/null +++ b/apps/app_seeds/lib/services/social_account_store.dart @@ -0,0 +1,60 @@ +import '../security/secret_store.dart'; + +/// The per-identity key scope for [account]: empty for the original identity +/// (account 0, so its data keeps the legacy un-namespaced keys), otherwise a +/// distinct namespace. Used by the per-identity stores (chats, profile, name +/// cache) so switching identity never mixes one identity's data into another. +String socialAccountScope(int account) => account == 0 ? '' : 'acct$account'; + +/// Remembers which social identity ("account") of the root seed is active, and +/// how many the user has created, so identities can be switched and listed. +/// +/// Each account is a pseudonymous Nostr identity deterministically derived from +/// the SAME root seed (see `NostrKeyDerivation.deriveFromSeed`), so switching +/// never adds anything to back up — the one seed still regenerates them all. +/// Keystore-backed: no plaintext at rest. +class SocialAccountStore { + SocialAccountStore(this._store); + + final SecretStore _store; + + static const _activeKey = 'tane.social.account.active'; + static const _maxKey = 'tane.social.account.max'; + + /// The active account index (0 = the original identity). + Future active() async => _readInt(_activeKey); + + /// The highest account index ever created (so the list is 0..max and a new + /// identity takes max + 1). At least the active one always exists. + Future maxCreated() async { + final max = await _readInt(_maxKey); + final activeIdx = await active(); + return max > activeIdx ? max : activeIdx; + } + + /// Switches the active identity to [account] (must already be within + /// 0..maxCreated, or be maxCreated + 1 for a freshly created one). + Future setActive(int account) async { + if (account < 0) { + throw ArgumentError.value(account, 'account', 'must be >= 0'); + } + await _store.write(_activeKey, '$account'); + if (account > await _readInt(_maxKey)) { + await _store.write(_maxKey, '$account'); + } + } + + /// Creates a brand-new identity (the next index) and makes it active. Returns + /// the new account index. + Future createNew() async { + final next = await maxCreated() + 1; + await setActive(next); + return next; + } + + Future _readInt(String key) async { + final raw = await _store.read(key); + if (raw == null || raw.isEmpty) return 0; + return int.tryParse(raw) ?? 0; + } +} diff --git a/apps/app_seeds/lib/services/social_connection.dart b/apps/app_seeds/lib/services/social_connection.dart new file mode 100644 index 0000000..8e57949 --- /dev/null +++ b/apps/app_seeds/lib/services/social_connection.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; + +import 'social_service.dart'; +import 'social_settings.dart'; + +/// Opens a session against the given relays. Injectable so the connection logic +/// can be unit-tested without a real relay. +typedef SessionOpener = Future Function(List relays); + +/// ONE shared relay connection per identity, reused by every social feature +/// (offers, messaging, trust, profile, the inbox listener) instead of each +/// opening its own `RelayPool`. Fewer sockets, less battery, one place to manage +/// reconnection. +/// +/// Lazily connects on first [session] call; reconnects when the network returns +/// and drops the session when it goes away, announcing each change on [sessions] +/// so long-lived consumers (the inbox listener) can re-subscribe. Callers must +/// NOT close the session they get — this owns its lifecycle. Recreated on an +/// identity switch (the old one is disposed). +class SocialConnection { + SocialConnection({ + required SocialService social, + required SocialSettings settings, + SessionOpener? open, + Stream? online, + }) : _settings = settings, + _open = open ?? social.openSession, + _online = online; + + final SocialSettings _settings; + final SessionOpener _open; + final Stream? _online; + + final _sessions = StreamController.broadcast(); + SocialSession? _current; + Future? _pending; + StreamSubscription? _onlineSub; + bool _disposed = false; + + /// Emits the live session on each (re)connect, and null when it drops. + Stream get sessions => _sessions.stream; + + /// The current shared session, or null if not connected right now. + SocialSession? get current => _current; + + /// Begins watching connectivity (reconnect on regain, drop when offline) and + /// attempts an initial connect. Idempotent-ish; call once at startup. + void start() { + _onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) { + if (!isOnline) { + _drop(); + } else if (_current == null) { + unawaited(session()); + } + }); + unawaited(session()); // initial attempt (also connects if already online) + } + + /// The shared session, connecting on first use. Returns null when offline or + /// no relay is reachable; the feature then degrades gracefully. + Future session() { + if (_current != null) return Future.value(_current); + return _pending ??= _connect(); + } + + Future _connect() async { + try { + final relays = await _settings.relayUrls(); + if (relays.isEmpty) return null; + final s = await _open(relays); + if (_disposed) { + await s.close(); + return null; + } + _current = s; + _sessions.add(s); + return s; + } catch (_) { + return null; // unreachable — a later connectivity change retries + } finally { + _pending = null; + } + } + + void _drop() { + final s = _current; + _current = null; + if (s != null) { + unawaited(s.close()); + if (!_sessions.isClosed) _sessions.add(null); + } + } + + Future dispose() async { + _disposed = true; + await _onlineSub?.cancel(); + _onlineSub = null; + _drop(); + if (!_sessions.isClosed) await _sessions.close(); + } + + static Stream _connectivityOnline() => + Connectivity().onConnectivityChanged.map((results) => + results.any((r) => r != ConnectivityResult.none)); +} diff --git a/apps/app_seeds/lib/services/social_service.dart b/apps/app_seeds/lib/services/social_service.dart new file mode 100644 index 0000000..f258fc5 --- /dev/null +++ b/apps/app_seeds/lib/services/social_service.dart @@ -0,0 +1,137 @@ +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; + +/// The app-side entry point to the (Block 2) social layer. +/// +/// Holds the user's Nostr identity — deterministically derived from the same +/// root seed the recovery QR backs up (so it needs no extra backup) — and opens +/// social sessions on demand. Local-first: constructing this is cheap and +/// offline; nothing connects to a relay until [openSession] is called, so the +/// app runs fully without network and the social layer only enriches. +/// The app-data namespace this identity's devices sync their inventory under. +/// Lives here (app layer), not in `commons_core`, which stays seed-agnostic. +const kInventorySyncNamespace = 'org.comunes.tane/inventory'; + +class SocialService { + SocialService({ + required this.identity, + this.account = 0, + this.rootSeedHex = '', + this.deviceId = '', + List relays = const [], + }) : relays = List.unmodifiable(relays); + + /// This install's device id (for device-to-device sync). Empty in tests / + /// when sync isn't wired. + final String deviceId; + + /// The derived Nostr identity (secp256k1). Same key across reinstalls that + /// restore the same seed (for a given [account]). + final NostrIdentity identity; + + /// Which pseudonymous identity of the root seed this is (0 = the original). + /// Switching account changes the social identity while the backup stays the + /// single root seed. See [NostrKeyDerivation.deriveFromSeed]. + final int account; + + /// The root-seed hex, kept so the switcher can preview OTHER accounts' + /// identities without re-reading the keystore. Empty when unknown (tests). + final String rootSeedHex; + + /// Community relay URLs (may be empty — discovery/publishing degrade to + /// nothing when offline or unconfigured). + final List relays; + + /// Shareable public identity (`npub…`) and its hex form. + String get npub => identity.npub; + String get publicKeyHex => identity.publicKeyHex; + + /// Derives the identity from the stored root-seed hex (32-byte, 64 chars) for + /// the given [account] (0 = the original identity). + static Future fromRootSeedHex( + String rootSeedHex, { + int account = 0, + String deviceId = '', + List relays = const [], + }) async { + final identity = await NostrKeyDerivation.deriveFromSeed( + _hexToBytes(rootSeedHex), + account: account, + ); + return SocialService( + identity: identity, + account: account, + rootSeedHex: rootSeedHex, + deviceId: deviceId, + relays: relays, + ); + } + + /// The `npub` for another [account] of the same root seed, so the switcher can + /// show identities before committing to one. Returns null if the seed is + /// unknown (e.g. in tests constructed without it). + Future npubForAccount(int account) async { + if (rootSeedHex.isEmpty) return null; + final id = await NostrKeyDerivation.deriveFromSeed( + _hexToBytes(rootSeedHex), + account: account, + ); + return id.npub; + } + + /// Opens a session against [relayUrls]: one fault-tolerant pool carrying all + /// three transports (offers, messaging, trust). Relays that are down are + /// skipped; throws only when none are reachable. Caller closes it. + Future openSession(List relayUrls) async { + final pool = await RelayPool.connect(relayUrls, identity: identity); + return SocialSession(pool, deviceId: deviceId); + } +} + +/// One relay channel with the transports on top — the +/// "one channel, N interfaces" shape, at the app layer. +class SocialSession { + SocialSession(this._channel, {String deviceId = ''}) + : offers = NostrOfferTransport(_channel), + messages = NostrMessageTransport(_channel), + trust = NostrTrustTransport(_channel), + ratings = NostrRatingTransport(_channel), + reports = NostrReportTransport(_channel), + profile = NostrProfileTransport(_channel), + plantares = NostrPlantareTransport(_channel), + sync = NostrSyncTransport( + _channel, + namespace: kInventorySyncNamespace, + deviceId: deviceId, + ); + + final NostrChannel _channel; + + final OfferTransport offers; + final MessageTransport messages; + final TrustTransport trust; + final RatingTransport ratings; + final ReportTransport reports; + final ProfileTransport profile; + + /// Bilateral signed Plantaré handshake (propose/accept/decline), private + /// end-to-end over the shared connection. + final PlantareTransport plantares; + + /// Device-to-device inventory sync for THIS identity's own devices. + final SyncTransport sync; + + Future close() => _channel.close(); +} + +Uint8List _hexToBytes(String hex) { + if (hex.length.isOdd) { + throw ArgumentError('hex string must have an even length'); + } + final out = Uint8List(hex.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart new file mode 100644 index 0000000..8dbe023 --- /dev/null +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -0,0 +1,134 @@ +import '../security/secret_store.dart'; + +/// User choices for the (Block 2) social layer: the coarse area to publish/ +/// browse offers in, and which community relays to talk to. Backed by the OS +/// keystore (via [SecretStore]) to honour "no plaintext at rest" — no +/// shared_preferences. +/// +/// Relays default to a small set of well-known public servers so the market +/// works out of the box; the exposure is minimal (offers are opt-in and carry +/// only a coarse geohash) and the user can swap them for a community server. +/// The area stays unset until the user picks one (it's inherently personal). +class SocialSettings { + SocialSettings(this._store); + + final SecretStore _store; + + static const _areaKey = 'tane.social.area_geohash'; + static const _relaysKey = 'tane.social.relays'; + static const _searchPrecisionKey = 'tane.social.search_precision'; + static const _blockedKey = 'tane.social.blocked_pubkeys'; + static const _hiddenOffersKey = 'tane.social.hidden_offers'; + + /// How wide "your zone" searches, as a geohash prefix length: 5 ≈ ±2.4 km + /// ("very close"), 4 ≈ ±20 km ("around here"), 3 ≈ ±78 km ("my region"). + /// Publishing always emits the full prefix ladder, so a coarser search still + /// matches finer offers — the default is deliberately wide so a still-sparse + /// network shows something. + static const int minSearchPrecision = 3; + static const int maxSearchPrecision = 5; + static const int defaultSearchPrecision = 4; + + /// Community servers used automatically so sharing works from the first + /// launch. The relay pool skips any that are unreachable, so a dead one never + /// breaks the market; the user never has to know these exist. The Comunes + /// relay comes first as the reliable, non-commercial home; the public ones + /// are backup. + static const List defaultRelays = [ + 'wss://relay.comunes.org', + 'wss://nos.lol', + 'wss://relay.damus.io', + 'wss://relay.primal.net', + ]; + + /// The user's coarse area as a low-precision geohash (may be empty). Set + /// manually or from device location; the transport coarsens it further. + Future areaGeohash() async => (await _store.read(_areaKey)) ?? ''; + + Future setAreaGeohash(String geohash) => + _store.write(_areaKey, geohash.trim().toLowerCase()); + + /// The relays to talk to. When the user has never configured any, falls back + /// to [defaultRelays]. Once they save a choice (even an empty one), that wins + /// — so a privacy-minded user can turn the network off entirely. + Future> relayUrls() async { + final raw = await _store.read(_relaysKey); + if (raw == null) return const [...defaultRelays]; // never configured + return raw.split('\n').where((s) => s.trim().isNotEmpty).toList(); + } + + Future setRelayUrls(List urls) => _store.write( + _relaysKey, + urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'), + ); + + /// How wide to search — a geohash prefix length in [minSearchPrecision, + /// maxSearchPrecision]. Defaults (and falls back on any garbage) to + /// [defaultSearchPrecision]. + Future searchPrecision() async { + final raw = await _store.read(_searchPrecisionKey); + final value = int.tryParse(raw ?? ''); + if (value == null) return defaultSearchPrecision; + return _clampPrecision(value); + } + + Future setSearchPrecision(int precision) => + _store.write(_searchPrecisionKey, '${_clampPrecision(precision)}'); + + int _clampPrecision(int p) => p < minSearchPrecision + ? minSearchPrecision + : (p > maxSearchPrecision ? maxSearchPrecision : p); + + /// Whether the social layer has enough config to attempt going online. + Future get isConfigured async => + (await relayUrls()).isNotEmpty && (await areaGeohash()).isNotEmpty; + + /// Pubkeys (hex) this user has blocked: their offers are hidden, their + /// chats disappear and their incoming messages are dropped. Purely local — + /// nothing is published about who you block. + Future> blockedPubkeys() async { + final raw = await _store.read(_blockedKey); + if (raw == null) return {}; + return raw + .split('\n') + .map((s) => s.trim().toLowerCase()) + .where((s) => s.isNotEmpty) + .toSet(); + } + + Future isBlocked(String pubkeyHex) async => + (await blockedPubkeys()).contains(pubkeyHex.trim().toLowerCase()); + + Future block(String pubkeyHex) async { + final set = await blockedPubkeys() + ..add(pubkeyHex.trim().toLowerCase()); + await _store.write(_blockedKey, set.join('\n')); + } + + Future unblock(String pubkeyHex) async { + final set = await blockedPubkeys() + ..remove(pubkeyHex.trim().toLowerCase()); + await _store.write(_blockedKey, set.join('\n')); + } + + /// The stable key of one published offer, for the hidden-offers set. + static String offerKey(String authorPubkeyHex, String offerId) => + '${authorPubkeyHex.trim().toLowerCase()}:$offerId'; + + /// Offers this user reported and no longer wants to see, as + /// [offerKey] entries. Purely local, like the blocklist. + Future> hiddenOfferKeys() async { + final raw = await _store.read(_hiddenOffersKey); + if (raw == null) return {}; + return raw.split('\n').where((s) => s.trim().isNotEmpty).toSet(); + } + + Future hideOffer({ + required String authorPubkeyHex, + required String offerId, + }) async { + final set = await hiddenOfferKeys() + ..add(offerKey(authorPubkeyHex, offerId)); + await _store.write(_hiddenOffersKey, set.join('\n')); + } +} diff --git a/apps/app_seeds/lib/services/sync_service.dart b/apps/app_seeds/lib/services/sync_service.dart new file mode 100644 index 0000000..e585156 --- /dev/null +++ b/apps/app_seeds/lib/services/sync_service.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/foundation.dart'; + +import 'inventory_snapshot_io.dart'; +import 'social_connection.dart'; +import 'social_service.dart'; + +/// Keeps this identity's inventory replicated across its OWN devices, over the +/// shared [SocialConnection]'s [SyncTransport]. +/// +/// On (re)connect it publishes this device's snapshot and subscribes to the +/// others'; a local edit (debounced) republishes. Incoming snapshots are merged +/// with the existing idempotent LWW importer — so applying one you already have +/// writes nothing and can't echo back into a push loop. A content check skips +/// re-publishing an unchanged snapshot, bounding the ping-pong to convergence. +/// +/// Foreground only, like the inbox — a background sync daemon is a later concern. +class SyncService { + SyncService({ + required SocialConnection connection, + required InventorySnapshotIO io, + required Stream localChanges, + required String selfDeviceId, + Duration debounce = const Duration(seconds: 2), + }) : _connection = connection, + _io = io, + _localChanges = localChanges, + _selfDeviceId = selfDeviceId, + _debounce = debounce; + + final SocialConnection _connection; + final InventorySnapshotIO _io; + final Stream _localChanges; + final String _selfDeviceId; + final Duration _debounce; + + StreamSubscription? _sessionsSub; + StreamSubscription? _snapSub; + StreamSubscription? _changesSub; + Timer? _debounceTimer; + SocialSession? _session; + List? _lastPushed; // the snapshot we last published (for change dedup) + bool _started = false; + + /// Begins syncing. Subscribe to the connection's sessions BEFORE it starts + /// connecting, so the first session is caught too. + void start() { + if (_started) return; + _started = true; + _sessionsSub = _connection.sessions.listen(_onSession); + _changesSub = _localChanges.listen((_) => _schedulePush()); + final current = _connection.current; + if (current != null) _onSession(current); + } + + void _onSession(SocialSession? session) { + if (identical(session, _session) && _snapSub != null) return; + unawaited(_snapSub?.cancel()); + _snapSub = null; + _session = session; + if (session != null) { + _snapSub = session.sync.snapshots().listen(handleRemote, onError: (_) {}); + _lastPushed = null; // a fresh session should re-publish + unawaited(pushLocal()); // publish our latest on (re)connect + } + } + + /// Merges one incoming [snapshot], skipping our own device's echo. A testable + /// seam (no relay). + @visibleForTesting + Future handleRemote(SyncSnapshot snapshot) async { + if (snapshot.deviceId == _selfDeviceId) return; // our own — nothing to do + try { + await _io.applySnapshot(snapshot.data); + } catch (_) { + // A malformed/old snapshot merges to nothing; ignore. + } + } + + void _schedulePush() { + _debounceTimer?.cancel(); + _debounceTimer = Timer(_debounce, () => unawaited(pushLocal())); + } + + /// Publishes this device's current inventory, unless it's byte-identical to + /// what we last published (so a merge that changed nothing doesn't loop). + @visibleForTesting + Future pushLocal() async { + final session = _session; + if (session == null) return; + try { + final bytes = await _io.buildSnapshot(); + if (_sameBytes(bytes, _lastPushed)) return; + await session.sync.pushSnapshot(bytes); + _lastPushed = bytes; + } catch (_) { + // offline / relay refused — the next connect or change retries. + } + } + + static bool _sameBytes(List a, List? b) { + if (b == null || a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + Future stop() async { + _started = false; + _debounceTimer?.cancel(); + await _changesSub?.cancel(); + _changesSub = null; + await _sessionsSub?.cancel(); + _sessionsSub = null; + await _snapSub?.cancel(); + _snapSub = null; + _session = null; + } +} diff --git a/apps/app_seeds/lib/services/unread_service.dart b/apps/app_seeds/lib/services/unread_service.dart new file mode 100644 index 0000000..47cfc3b --- /dev/null +++ b/apps/app_seeds/lib/services/unread_service.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; + +import '../security/secret_store.dart'; +import 'message_store.dart'; + +/// Tracks which private messages are still unread, so the UI can show a badge. +/// +/// There is no read/unread flag on a message; instead we keep a per-peer +/// "last read at" timestamp in the keystore (just a timestamp and a pubkey — no +/// message text, so it honours "no plaintext at rest"). A conversation's unread +/// count is simply the peer's messages newer than that mark. Reading history +/// from the [MessageStore] keeps a single source of truth. +/// +/// Only inbound messages count: a message whose author is the peer. Your own +/// sent messages (stored with your pubkey) are never counted. +/// +/// [activePeer] is the chat currently on screen. A message that arrives for it +/// is treated as already read (you're looking at it), and the inbox listener +/// also skips its notification — so the chat you're in never badges or beeps. +class UnreadService { + UnreadService(this._store, this._secrets); + + final MessageStore _store; + final SecretStore _secrets; + + final _changes = StreamController.broadcast(); + + /// The peer whose chat is currently open, or null. Set by [ChatScreen]. + String? activePeer; + + static const _prefix = 'tane.social.unread.'; + + String _key(String peer) => '$_prefix$peer'; + + /// Fires (no payload) whenever an unread count may have changed — on a new + /// inbound message and on [markRead]. Broadcast, so several badges can listen. + Stream get changes => _changes.stream; + + /// Records that a new [message] for [peer] was just persisted. If its chat is + /// open it is marked read immediately; otherwise badges are asked to refresh. + Future onMessageReceived(String peer, PrivateMessage message) async { + if (peer == activePeer) { + await markRead(peer); + } else { + _fire(); + } + } + + /// Marks the conversation with [peer] read up to its latest message, so its + /// unread count drops to zero. Called when the chat opens (and when a message + /// arrives while it's open). + Future markRead(String peer) async { + final history = await _store.history(peer); + final upTo = history.isEmpty + ? DateTime.now() + : history + .map((m) => m.at) + .reduce((a, b) => a.isAfter(b) ? a : b); + await _secrets.write(_key(peer), '${upTo.millisecondsSinceEpoch}'); + _fire(); + } + + /// Unread inbound messages from [peer] (those newer than its last-read mark). + Future unreadCount(String peer) async { + final lastRead = await _lastRead(peer); + final history = await _store.history(peer); + return history + .where((m) => m.fromPubkey == peer && m.at.isAfter(lastRead)) + .length; + } + + /// Unread messages across every conversation (the number for the app badge). + Future totalUnreadCount() async { + var total = 0; + for (final c in await _store.conversations()) { + total += await unreadCount(c.peerPubkey); + } + return total; + } + + Future _lastRead(String peer) async { + final raw = await _secrets.read(_key(peer)); + final ms = raw == null ? null : int.tryParse(raw); + return DateTime.fromMillisecondsSinceEpoch(ms ?? 0); + } + + void _fire() { + if (!_changes.isClosed) _changes.add(null); + } + + Future dispose() async { + if (!_changes.isClosed) await _changes.close(); + } +} diff --git a/apps/app_seeds/lib/state/inventory_cubit.dart b/apps/app_seeds/lib/state/inventory_cubit.dart index f3dc365..5dedbf3 100644 --- a/apps/app_seeds/lib/state/inventory_cubit.dart +++ b/apps/app_seeds/lib/state/inventory_cubit.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../data/variety_repository.dart'; import '../db/enums.dart'; +import '../domain/crop_calendar.dart'; /// Inventory list state: all items from the DB plus the current search query /// and active filters. [visibleItems] applies query ∧ category ∧ form; grouping @@ -19,7 +20,12 @@ class InventoryState extends Equatable { this.organicOnly = false, this.needsReproductionOnly = false, this.sharingOnly = false, + this.sowThisMonthOnly = false, + this.filterMonth = 0, this.loading = true, + this.error, + this.selectionMode = false, + this.selectedIds = const {}, }); final List items; @@ -47,8 +53,29 @@ class InventoryState extends Equatable { /// "what I share" view. final bool sharingOnly; + /// When true, keep only varieties whose recorded sow-months include + /// [filterMonth] — the inventory "to sow this month" view. + final bool sowThisMonthOnly; + + /// The month (1..12) the "to sow this month" filter matches against — set to + /// the current month by the cubit when the filter is turned on. Only read + /// while [sowThisMonthOnly] is true. + final int filterMonth; + final bool loading; + /// Set when the inventory stream fails (e.g. the encrypted DB wasn't ready). + /// The UI shows a retry affordance instead of an endless spinner; null when + /// fine. See [InventoryCubit.retry]. + final String? error; + + /// Whether the list is in multi-select mode — used to pick a subset of the + /// inventory to print labels for. + final bool selectionMode; + + /// Ids of the varieties currently selected in [selectionMode]. + final Set selectedIds; + /// Categories present across all items, in display order (deduped), so the /// UI can offer one chip per category actually in use. List get categories { @@ -75,10 +102,17 @@ class InventoryState extends Equatable { if (organicOnly && !i.isOrganic) return false; if (needsReproductionOnly && !i.needsReproduction) return false; if (sharingOnly && !i.isShared) return false; + if (sowThisMonthOnly && !maskHasMonth(i.sowMonths, filterMonth)) { + return false; + } return true; }).toList(); } + /// Whether any variety has recorded sow-months, so the UI only offers the + /// "to sow this month" chip when it would match something. + bool get hasSowCalendar => items.any((i) => i.sowMonths != null); + /// Whether any item is flagged organic, so the UI only offers the eco filter /// chip when it would actually match something. bool get hasOrganic => items.any((i) => i.isOrganic); @@ -100,7 +134,12 @@ class InventoryState extends Equatable { bool? organicOnly, bool? needsReproductionOnly, bool? sharingOnly, + bool? sowThisMonthOnly, + int? filterMonth, bool? loading, + String? Function()? error, + bool? selectionMode, + Set? selectedIds, }) { return InventoryState( items: items ?? this.items, @@ -112,7 +151,12 @@ class InventoryState extends Equatable { needsReproductionOnly: needsReproductionOnly ?? this.needsReproductionOnly, sharingOnly: sharingOnly ?? this.sharingOnly, + sowThisMonthOnly: sowThisMonthOnly ?? this.sowThisMonthOnly, + filterMonth: filterMonth ?? this.filterMonth, loading: loading ?? this.loading, + error: error != null ? error() : this.error, + selectionMode: selectionMode ?? this.selectionMode, + selectedIds: selectedIds ?? this.selectedIds, ); } @@ -126,29 +170,96 @@ class InventoryState extends Equatable { organicOnly, needsReproductionOnly, sharingOnly, + sowThisMonthOnly, + filterMonth, loading, + error, + selectionMode, + selectedIds, ]; } /// Subscribes to the repository's reactive inventory stream. The list updates /// automatically after a quick-add — no manual refresh. class InventoryCubit extends Cubit { - InventoryCubit(this._repo) : super(const InventoryState()) { - // One combined subscription (list + draft tray). Two separate StreamGroups - // here re-emit in a loop that hangs widget tests — see watchInventoryView. - _sub = _repo.watchInventoryView().listen( - (view) => emit( - state.copyWith(items: view.items, drafts: view.drafts, loading: false), - ), - ); + InventoryCubit(this._repo, {int Function()? nowMonth}) + : _nowMonth = nowMonth ?? (() => DateTime.now().month), + super(const InventoryState()) { + _subscribe(); } final VarietyRepository _repo; - late final StreamSubscription< + final int Function() _nowMonth; + StreamSubscription< ({List items, List drafts}) - > + >? _sub; + /// Pending auto-retry, cancelled on a fresh (re)subscribe or on close. + Timer? _retryTimer; + + /// Consecutive stream failures since the last good emission. Drives the + /// backoff and, once [_maxAutoRetries] is hit, the switch to a manual retry. + int _failures = 0; + + /// How many times we silently re-open the stream before giving up and asking + /// the user. The startup DB-not-ready race clears in well under a second, so + /// a handful of backed-off attempts recovers it without the user noticing. + static const _maxAutoRetries = 6; + + /// (Re)opens the combined inventory subscription (list + draft tray). One + /// subscription only: two separate StreamGroups here re-emit in a loop that + /// hangs widget tests — see watchInventoryView. + /// + /// The onError handler is load-bearing: without it a transient stream failure + /// (e.g. the encrypted DB not yet ready at startup) would go unhandled and + /// leave [InventoryState.loading] true forever — the "stuck spinner" that a + /// restart clears. On error we auto-retry with backoff (staying in [loading] + /// so the user just sees the spinner briefly), and only surface [error] for a + /// manual [retry] once the transient window has clearly passed. + void _subscribe() { + _retryTimer?.cancel(); + _sub?.cancel(); + _sub = _repo.watchInventoryView().listen( + (view) { + _failures = 0; + emit( + state.copyWith( + items: view.items, + drafts: view.drafts, + loading: false, + error: () => null, + ), + ); + }, + onError: _onStreamError, + ); + } + + void _onStreamError(Object e) { + if (isClosed) return; + _failures++; + if (_failures <= _maxAutoRetries) { + // Exponential backoff capped at ~4s: 250ms, 500ms, 1s, 2s, 4s, 4s. + final delayMs = (250 * (1 << (_failures - 1))).clamp(250, 4000); + // Stay in loading — an auto-recovering spinner, not an error screen. + _retryTimer = Timer(Duration(milliseconds: delayMs), () { + if (!isClosed) _subscribe(); + }); + } else { + // Transient window has passed; hand it to the user. + emit(state.copyWith(loading: false, error: () => '$e')); + } + } + + /// Re-opens the inventory stream on demand (from the manual retry button), + /// resetting the auto-retry budget and returning to the loading state. + void retry() { + _failures = 0; + emit(state.copyWith(loading: true, error: () => null)); + _subscribe(); + } + void search(String query) => emit(state.copyWith(query: query)); /// Toggles a category in the filter (add if absent, remove if present). @@ -177,6 +288,42 @@ class InventoryCubit extends Cubit { void toggleSharingOnly() => emit(state.copyWith(sharingOnly: !state.sharingOnly)); + /// Toggles the "to sow this month" filter, pinned to the current month. + void toggleSowThisMonth() => emit(state.copyWith( + sowThisMonthOnly: !state.sowThisMonthOnly, + filterMonth: _nowMonth(), + )); + + /// Enters multi-select mode with an empty selection (from the toolbar). + void startSelection() => + emit(state.copyWith(selectionMode: true, selectedIds: const {})); + + /// Enters multi-select mode with [id] as the first selected variety + /// (triggered by a long-press on a tile). + void enterSelection(String id) => + emit(state.copyWith(selectionMode: true, selectedIds: {id})); + + /// Toggles a variety in the selection (add if absent, remove if present). + /// An empty selection stays in selection mode; the user exits explicitly. + void toggleSelection(String id) { + final next = Set.of(state.selectedIds); + if (!next.remove(id)) next.add(id); + emit(state.copyWith(selectionMode: true, selectedIds: next)); + } + + /// Selects every currently visible variety, so "filter to a category, then + /// select all" is the way to pick a subset of the inventory to print. + void selectAllVisible() => emit( + state.copyWith( + selectionMode: true, + selectedIds: state.visibleItems.map((i) => i.id).toSet(), + ), + ); + + /// Leaves selection mode and clears the selection. + void exitSelection() => + emit(state.copyWith(selectionMode: false, selectedIds: const {})); + /// Clears all filters (search is left untouched). void clearFilters() => emit( state.copyWith( @@ -185,12 +332,14 @@ class InventoryCubit extends Cubit { organicOnly: false, needsReproductionOnly: false, sharingOnly: false, + sowThisMonthOnly: false, ), ); @override Future close() async { - await _sub.cancel(); + _retryTimer?.cancel(); + await _sub?.cancel(); return super.close(); } } diff --git a/apps/app_seeds/lib/state/messages_cubit.dart b/apps/app_seeds/lib/state/messages_cubit.dart new file mode 100644 index 0000000..ee7a210 --- /dev/null +++ b/apps/app_seeds/lib/state/messages_cubit.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../domain/message_rules.dart'; +import '../services/message_store.dart'; + +/// A 1:1 conversation with one peer. Holds the running message list plus send +/// status. Transport-agnostic — depends on [MessageTransport], not the relay. +class ChatState extends Equatable { + const ChatState({this.messages = const [], this.sending = false, this.error}); + + /// Messages in this conversation, oldest first. Incoming ones arrive from the + /// inbox; our own are appended optimistically on send (NIP-17 wraps don't come + /// back to the sender). + final List messages; + final bool sending; + final String? error; + + ChatState copyWith({ + List? messages, + bool? sending, + String? Function()? error, + }) => ChatState( + messages: messages ?? this.messages, + sending: sending ?? this.sending, + error: error != null ? error() : this.error, + ); + + @override + List get props => [messages, sending, error]; +} + +/// Drives a 1:1 chat over a [MessageTransport]. Filters the shared inbox to +/// [peerPubkey], and tags our own sent messages with [selfPubkey] so the UI can +/// tell the two sides apart. Degrades gracefully when offline (null transport). +class MessagesCubit extends Cubit { + MessagesCubit( + this._transport, { + required this.peerPubkey, + required this.selfPubkey, + MessageStore? store, + Future Function()? onDispose, + }) : _store = store, + _onDispose = onDispose, + super(const ChatState()); + + final MessageTransport? _transport; + final MessageStore? _store; + final String peerPubkey; + final String selfPubkey; + final Future Function()? _onDispose; + StreamSubscription? _sub; + + /// Messages already shown, keyed by sender+timestamp+text. Relays re-deliver + /// stored gift wraps on every (re)subscribe, and the live subscription can + /// hand back the very wrap we just loaded from history — so dedupe on display, + /// not just in the store. (Pre-existing duplicates in old saved history are + /// collapsed here too.) + final _seen = {}; + + bool get isOnline => _transport != null; + + String _key(PrivateMessage m) => + '${m.fromPubkey}|${m.at.millisecondsSinceEpoch}|${m.text}'; + + /// Subscribes to incoming messages, then loads any saved history. Subscribing + /// first (before the async history load) avoids dropping an event that arrives + /// during the load; the seen-set keeps either order duplicate-free while + /// preserving arrival order (history first, live appended). + Future start() async { + final transport = _transport; + if (transport != null) { + _sub = transport.inbox().listen((message) async { + if (message.fromPubkey != peerPubkey) return; // another conversation + if (!_seen.add(_key(message))) return; // already shown / re-delivered + await _store?.append(peerPubkey, message); + emit(state.copyWith(messages: [...state.messages, message])); + }, onError: (Object e) => emit(state.copyWith(error: () => '$e'))); + } + final history = await _store?.history(peerPubkey); + if (history != null && history.isNotEmpty) { + // Skip any that the live subscription already surfaced during the load, + // and collapse duplicates already sitting in old saved history. + final fresh = history.where((m) => _seen.add(_key(m))).toList(); + if (fresh.isNotEmpty) { + emit(state.copyWith(messages: [...fresh, ...state.messages])); + } + } + } + + /// Sends [text] to the peer and appends it optimistically. + Future send(String text) async { + final transport = _transport; + final trimmed = text.trim(); + // Links aren't allowed in messages (the UI warns; this is the backstop so + // nothing sends a URL programmatically). See message_rules.dart. + if (transport == null || trimmed.isEmpty || containsUrl(trimmed)) return; + emit(state.copyWith(sending: true, error: () => null)); + try { + await transport.send(toPubkey: peerPubkey, text: trimmed); + final mine = PrivateMessage( + fromPubkey: selfPubkey, + text: trimmed, + at: DateTime.now(), + ); + _seen.add(_key(mine)); + await _store?.append(peerPubkey, mine); + emit(state.copyWith(sending: false, messages: [...state.messages, mine])); + } catch (e) { + emit(state.copyWith(sending: false, error: () => '$e')); + } + } + + /// Whether [message] was sent by us (for right-aligned bubbles). + bool isMine(PrivateMessage message) => message.fromPubkey == selfPubkey; + + @override + Future close() async { + await _sub?.cancel(); + await _onDispose?.call(); + return super.close(); + } +} diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart new file mode 100644 index 0000000..8aa6e9d --- /dev/null +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -0,0 +1,401 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../data/variety_repository.dart'; +import '../services/offer_mapper.dart'; +import '../services/offer_outbox.dart'; +import '../services/offer_thumbnail.dart'; +import '../services/social_connection.dart'; + +/// State of the offer discovery/publish screen. Transport-agnostic — it holds +/// only what the UI shows, never a relay handle. +class OffersState extends Equatable { + const OffersState({ + this.offers = const [], + this.areaGeohash = '', + this.query = '', + this.typeFilter = const {}, + this.categoryFilter = const {}, + this.organicOnly = false, + this.blockedAuthors = const {}, + this.hiddenOfferKeys = const {}, + this.searching = false, + this.publishing = false, + this.hasSearched = false, + this.error, + }); + + /// Offers discovered so far for [areaGeohash], newest appended as they arrive. + final List offers; + + /// The coarse area currently being browsed. + final String areaGeohash; + + /// Free-text filter over [offers] typed in the search box (empty = show all). + final String query; + + /// Active reciprocity-mode filter (gift/exchange/sale/wanted); empty = all. + final Set typeFilter; + + /// Active category filter (free-text categories); empty = all. + final Set categoryFilter; + + /// When true, show only offers the grower declared organic ("eco"). + final bool organicOnly; + + /// Authors this user has blocked; their offers never surface. Kept in the + /// state (not dropped at merge time) so unblocking can resurface what was + /// already discovered. + final Set blockedAuthors; + + /// Individual offers this user reported and hid ("author:id" keys, see + /// SocialSettings.offerKey). Same idea as [blockedAuthors], finer grain. + final Set hiddenOfferKeys; + + final bool searching; + final bool publishing; + + /// True once a discovery has been started, so the UI can tell "no search yet" + /// from "searched, found nothing". + final bool hasSearched; + + /// Last error, in human terms for the UI (null when fine). + final String? error; + + /// [offers] narrowed by the text [query] and the chip filters (all ANDed). + /// Kept separate from [offers] so filtering never drops the discoveries. + List get visibleOffers { + final q = query.trim().toLowerCase(); + return offers.where((o) { + if (blockedAuthors.contains(o.authorPubkeyHex)) return false; + if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) { + return false; + } + if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false; + if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false; + if (categoryFilter.isNotEmpty && + (o.category == null || !categoryFilter.contains(o.category))) { + return false; + } + if (organicOnly && !o.isOrganic) return false; + return true; + }).toList(); + } + + /// The distinct categories present across discovered [offers], sorted, so the + /// UI only offers a category chip when some offer carries it. + List get categories { + final set = { + for (final o in offers) + if (o.category != null && o.category!.isNotEmpty) o.category!, + }; + final list = set.toList()..sort(); + return list; + } + + /// Whether any discovered offer declares organic, so the eco chip only shows + /// when it can match something. + bool get hasOrganic => offers.any((o) => o.isOrganic); + + bool get hasActiveFilter => + typeFilter.isNotEmpty || categoryFilter.isNotEmpty || organicOnly; + + OffersState copyWith({ + List? offers, + String? areaGeohash, + String? query, + Set? typeFilter, + Set? categoryFilter, + bool? organicOnly, + Set? blockedAuthors, + Set? hiddenOfferKeys, + bool? searching, + bool? publishing, + bool? hasSearched, + String? Function()? error, + }) { + return OffersState( + offers: offers ?? this.offers, + areaGeohash: areaGeohash ?? this.areaGeohash, + query: query ?? this.query, + typeFilter: typeFilter ?? this.typeFilter, + categoryFilter: categoryFilter ?? this.categoryFilter, + organicOnly: organicOnly ?? this.organicOnly, + blockedAuthors: blockedAuthors ?? this.blockedAuthors, + hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys, + searching: searching ?? this.searching, + publishing: publishing ?? this.publishing, + hasSearched: hasSearched ?? this.hasSearched, + error: error != null ? error() : this.error, + ); + } + + @override + List get props => [ + offers, + areaGeohash, + query, + typeFilter, + categoryFilter, + organicOnly, + blockedAuthors, + hiddenOfferKeys, + searching, + publishing, + hasSearched, + error, + ]; +} + +/// Drives offer discovery and publishing over an [OfferTransport]. Depends on +/// the interface, not the Nostr backend, so it unit-tests with a fake and the +/// UI stays offline-tolerant (a null transport = the social layer is unavailable +/// and the screen degrades gracefully). +class OffersCubit extends Cubit { + OffersCubit( + this._transport, { + Future Function(String varietyId)? coverPhoto, + String? Function(Uint8List bytes)? thumbnail, + Future Function()? onDispose, + }) : _coverPhoto = coverPhoto, + _thumbnail = thumbnail, + _onDispose = onDispose, + super(const OffersState()); + + final OfferTransport? _transport; + + /// Fetches a variety's cover photo bytes; null in tests or when no inventory + /// repo is wired. + final Future Function(String varietyId)? _coverPhoto; + + /// Turns full photo bytes into a small `data:` thumbnail embedded in the offer + /// (no media server). Null → offers publish without a photo. + final String? Function(Uint8List bytes)? _thumbnail; + + /// Closes the owning [SocialSession]/connection when the cubit is disposed + /// (null in tests, where the transport is a fake with nothing to close). + final Future Function()? _onDispose; + StreamSubscription? _sub; + Timer? _searchTimeout; + + /// Whether a live transport is available (relay configured and reachable). + bool get isOnline => _transport != null; + + /// Starts (or restarts) discovery for [geohashPrefix]. Results stream in. + Future discover(String geohashPrefix) async { + final transport = _transport; + if (transport == null) { + emit(state.copyWith(error: () => 'offline', hasSearched: true)); + return; + } + await _sub?.cancel(); + _searchTimeout?.cancel(); + emit(OffersState( + areaGeohash: geohashPrefix, + // Keep the text and chip filters across a refresh (only the results reset). + query: state.query, + typeFilter: state.typeFilter, + categoryFilter: state.categoryFilter, + organicOnly: state.organicOnly, + blockedAuthors: state.blockedAuthors, + hiddenOfferKeys: state.hiddenOfferKeys, + searching: true, + hasSearched: true, + )); + _sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen( + (offer) => + emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)), + onError: (Object e) => + emit(state.copyWith(searching: false, error: () => '$e')), + ); + // The discover stream stays open for live offers and never signals "done", + // so stop the spinner after a beat: no results → show the empty state, not + // an endless "searching". + _searchTimeout = Timer(const Duration(seconds: 6), () { + if (!isClosed && state.searching) { + emit(state.copyWith(searching: false)); + } + }); + } + + /// Appends [incoming] to [current], replacing any existing offer with the same + /// author + id. Relays legitimately resend addressable events (a stored copy + /// plus a live echo after publishing), so a plain append would double the + /// listing; keeping one entry per (author, id) is the NIP-99 semantics. + static List _merge(List current, Offer incoming) => [ + for (final o in current) + if (!(o.id == incoming.id && + o.authorPubkeyHex == incoming.authorPubkeyHex)) + o, + incoming, + ]; + + /// Narrows the visible offers to those whose summary matches [query]. Purely + /// local over the already-discovered list; does not re-hit the transport. + void search(String query) => emit(state.copyWith(query: query)); + + /// Toggles a reciprocity-mode chip on/off. Purely local over the discovered + /// list; does not re-hit the transport. + void toggleType(OfferType type) { + final next = {...state.typeFilter}; + next.contains(type) ? next.remove(type) : next.add(type); + emit(state.copyWith(typeFilter: next)); + } + + /// Toggles a category chip on/off. + void toggleCategory(String category) { + final next = {...state.categoryFilter}; + next.contains(category) ? next.remove(category) : next.add(category); + emit(state.copyWith(categoryFilter: next)); + } + + /// Toggles the organic ("eco") filter. + void toggleOrganicOnly() => + emit(state.copyWith(organicOnly: !state.organicOnly)); + + /// Replaces the set of blocked authors (loaded from the local blocklist); + /// their offers disappear from the visible list at once. + void setBlockedAuthors(Set pubkeys) => + emit(state.copyWith(blockedAuthors: pubkeys)); + + /// Replaces the set of locally hidden (reported) offers. + void setHiddenOffers(Set offerKeys) => + emit(state.copyWith(hiddenOfferKeys: offerKeys)); + + /// Clears every chip filter (leaves the text search untouched). + void clearFilters() => emit(state.copyWith( + typeFilter: const {}, + categoryFilter: const {}, + organicOnly: false, + )); + + /// Publishes [offer]; returns the transport's verdict. No-op result when + /// offline. + Future publish(Offer offer) async { + final transport = _transport; + if (transport == null) { + return const PublishResult(accepted: false, transportRef: '', message: 'offline'); + } + emit(state.copyWith(publishing: true, error: () => null)); + try { + final result = await transport.publish(offer); + emit(state.copyWith( + publishing: false, + error: () => result.accepted ? null : result.message, + )); + return result; + } catch (e) { + emit(state.copyWith(publishing: false, error: () => '$e')); + rethrow; + } + } + + /// Publishes the user's [lots] as offers, each tagged with the coarse + /// [areaGeohash] and signed by [authorPubkeyHex]. Returns how many the relay + /// accepted. No-op (returns 0) when offline or with no area set. + Future publishLots( + List lots, { + required String authorPubkeyHex, + required String areaGeohash, + }) async { + final transport = _transport; + if (transport == null || areaGeohash.isEmpty || lots.isEmpty) return 0; + emit(state.copyWith(publishing: true, error: () => null)); + var accepted = 0; + try { + for (final lot in lots) { + final offer = OfferMapper.fromSharedLot( + lotId: lot.lotId, + authorPubkeyHex: authorPubkeyHex, + summary: lot.summary, + sharing: lot.offerStatus, + areaGeohash: areaGeohash, + category: lot.category, + isOrganic: lot.isOrganic, + priceAmount: lot.priceAmount, + priceCurrency: lot.priceCurrency, + imageUrl: await _coverThumbnail(lot.varietyId), + ); + final result = await transport.publish(offer); + if (result.accepted) accepted++; + } + emit(state.copyWith(publishing: false)); + } catch (e) { + emit(state.copyWith(publishing: false, error: () => '$e')); + rethrow; + } + return accepted; + } + + /// Builds a small inline `data:` thumbnail from the lot's cover photo to embed + /// in the offer, or null when there's no photo, no thumbnailer, or it can't be + /// shrunk to fit. Best-effort: a missing image never blocks publishing. + Future _coverThumbnail(String varietyId) async { + final coverPhoto = _coverPhoto; + final thumbnail = _thumbnail; + if (coverPhoto == null || thumbnail == null) return null; + try { + final Uint8List? bytes = await coverPhoto(varietyId); + if (bytes == null || bytes.isEmpty) return null; + return thumbnail(bytes); + } catch (_) { + return null; // degrade: publish the offer without a photo + } + } + + @override + Future close() async { + _searchTimeout?.cancel(); + await _sub?.cancel(); + await _onDispose?.call(); + return super.close(); + } +} + +/// Opens an [OffersCubit] over the SHARED [SocialConnection], or an offline one +/// that degrades gracefully. Local-first: when the connection isn't up (no relay +/// configured / unreachable), the cubit gets a null transport and the screen +/// still opens. Does NOT close the session — the connection owns it. +Future createOffersCubit( + SocialConnection connection, { + VarietyRepository? repository, +}) async { + final session = await connection.session(); + return OffersCubit( + session?.offers, + coverPhoto: repository?.coverPhotoFor, + thumbnail: offerThumbnailDataUri, + ); +} + +/// Publishes any queued (offline-parked) lots now that we're online, then clears +/// them from the [outbox]. Rebuilds each offer from the lot's CURRENT state, so +/// since-deleted or now-private lots simply drop out. Returns how many published. +Future flushOutbox({ + required OfferOutbox outbox, + required OffersCubit cubit, + required List shareableLots, + required String authorPubkeyHex, + required String areaGeohash, +}) async { + if (!cubit.isOnline || areaGeohash.isEmpty) return 0; + final queued = await outbox.pending(); + if (queued.isEmpty) return 0; + final toPublish = + shareableLots.where((l) => queued.contains(l.lotId)).toList(); + var published = 0; + if (toPublish.isNotEmpty) { + published = await cubit.publishLots( + toPublish, + authorPubkeyHex: authorPubkeyHex, + areaGeohash: areaGeohash, + ); + } + // Clear everything attempted (or gone) so we don't loop on it. + await outbox.remove(queued); + return published; +} diff --git a/apps/app_seeds/lib/state/peer_rating_cubit.dart b/apps/app_seeds/lib/state/peer_rating_cubit.dart new file mode 100644 index 0000000..d59f80c --- /dev/null +++ b/apps/app_seeds/lib/state/peer_rating_cubit.dart @@ -0,0 +1,145 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Reputation of one peer, seen from this user's position: everyone's active +/// ratings plus how many of those come from people in your own circle — the +/// signal that makes strangers' review-stuffing irrelevant. +class PeerRatingState extends Equatable { + const PeerRatingState({ + this.count = 0, + this.average = 0, + this.circleCount = 0, + this.myStars, + this.myComment = '', + this.loading = true, + this.busy = false, + }); + + /// Active ratings of the peer, one per rater. + final int count; + + /// Mean stars over [count]; 0 when there are none. + final double average; + + /// How many of those ratings come from your circle (you or a + /// friend-of-a-friend). These are the ones worth highlighting. + final int circleCount; + + /// Your own rating of the peer, if any. + final int? myStars; + final String myComment; + + final bool loading; + final bool busy; + + bool get hasRatings => count > 0; + bool get iRated => myStars != null; + + PeerRatingState copyWith({ + int? count, + double? average, + int? circleCount, + int? Function()? myStars, + String? myComment, + bool? loading, + bool? busy, + }) => + PeerRatingState( + count: count ?? this.count, + average: average ?? this.average, + circleCount: circleCount ?? this.circleCount, + myStars: myStars != null ? myStars() : this.myStars, + myComment: myComment ?? this.myComment, + loading: loading ?? this.loading, + busy: busy ?? this.busy, + ); + + @override + List get props => + [count, average, circleCount, myStars, myComment, loading, busy]; +} + +/// Reads and edits this user's rating of [peerPubkey] over a +/// [RatingTransport], and summarises everyone else's. The circle weighting +/// reuses the trust graph (same ego-centric rule as TrustCubit). +class PeerRatingCubit extends Cubit { + PeerRatingCubit( + this._ratings, { + required this.peerPubkey, + required this.selfPubkey, + TrustTransport? trust, + }) : _trust = trust, + super(const PeerRatingState()); + + final RatingTransport? _ratings; + final TrustTransport? _trust; + final String peerPubkey; + final String selfPubkey; + + bool get isOnline => _ratings != null; + + /// Same personal "circle" rule as TrustCubit: one vouch from your side, out + /// to a friend-of-a-friend. + static const _circleThreshold = 1; + static const _circleDistance = 2; + + Future load() async { + final ratings = _ratings; + if (ratings == null) { + emit(state.copyWith(loading: false)); + return; + } + emit(state.copyWith(loading: true)); + final all = await ratings.ratingsOf(peerPubkey); + final mine = await ratings.myRatingOf(peerPubkey); + + var circle = const {}; + final trust = _trust; + if (trust != null && all.isNotEmpty) { + final wot = WebOfTrust.fromCertifications( + await trust.allCertifications(), + now: DateTime.now(), + ); + circle = wot.members( + seeds: {selfPubkey}, + threshold: _circleThreshold, + maxDistance: _circleDistance, + ); + } + + final total = all.fold(0, (sum, r) => sum + r.stars); + emit(state.copyWith( + count: all.length, + average: all.isEmpty ? 0 : total / all.length, + circleCount: all.where((r) => circle.contains(r.rater)).length, + myStars: () => mine?.stars, + myComment: mine?.comment ?? '', + loading: false, + )); + } + + /// Publishes (or replaces) your rating, then reloads. Never rates self. + Future rate(int stars, {String comment = ''}) async { + final ratings = _ratings; + if (ratings == null || peerPubkey == selfPubkey) return; + emit(state.copyWith(busy: true)); + await ratings.rate( + subjectPubkey: peerPubkey, + stars: stars, + comment: comment, + ); + await load(); + emit(state.copyWith(busy: false)); + } + + /// Takes your rating back, then reloads. + Future retract() async { + final ratings = _ratings; + if (ratings == null) return; + emit(state.copyWith(busy: true)); + await ratings.retract(subjectPubkey: peerPubkey); + await load(); + emit(state.copyWith(busy: false)); + } +} diff --git a/apps/app_seeds/lib/state/trust_cubit.dart b/apps/app_seeds/lib/state/trust_cubit.dart new file mode 100644 index 0000000..21fd4f4 --- /dev/null +++ b/apps/app_seeds/lib/state/trust_cubit.dart @@ -0,0 +1,154 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Where a peer stands, from strongest to weakest signal. Trust is +/// ego-centric: computed from YOUR position in the public vouch graph, +/// never from a global membership verdict. +enum TrustTier { + /// In your personal circle (you vouch, or a friend-of-a-friend does). + inYourCircle, + + /// Vouched for by someone, but not (yet) in your circle. + vouched, + + /// No certifications seen. + unknown, +} + +/// Trust standing of one peer, seen from this user's position: your own +/// vouch, your circle (friend-of-a-friend), and the raw certifier count — +/// computed from the public certification graph. +class TrustState extends Equatable { + const TrustState({ + this.certifierCount = 0, + this.iVouch = false, + this.knownToYou = false, + this.loading = true, + this.busy = false, + }); + + /// How many people (network-wide) currently vouch for the peer. + final int certifierCount; + + /// Whether this user vouches for the peer. + final bool iVouch; + + /// Whether the peer is within the user's own circle (you, or a + /// friend-of-a-friend, vouch). Spam-resistant and works from day one. + final bool knownToYou; + + final bool loading; + final bool busy; + + /// The strongest applicable signal, for the badge. + TrustTier get tier { + if (knownToYou) return TrustTier.inYourCircle; + if (certifierCount > 0) return TrustTier.vouched; + return TrustTier.unknown; + } + + TrustState copyWith({ + int? certifierCount, + bool? iVouch, + bool? knownToYou, + bool? loading, + bool? busy, + }) => + TrustState( + certifierCount: certifierCount ?? this.certifierCount, + iVouch: iVouch ?? this.iVouch, + knownToYou: knownToYou ?? this.knownToYou, + loading: loading ?? this.loading, + busy: busy ?? this.busy, + ); + + @override + List get props => [ + certifierCount, + iVouch, + knownToYou, + loading, + busy, + ]; +} + +/// Reads and toggles this user's vouch for [peerPubkey] over a +/// [TrustTransport], and computes the peer's standing from this user's own +/// position in the vouch graph (ego-centric — no referents, no parameters). +class TrustCubit extends Cubit { + TrustCubit( + this._transport, { + required this.peerPubkey, + required this.selfPubkey, + Future Function()? onDispose, + }) : _onDispose = onDispose, + super(const TrustState()); + + final TrustTransport? _transport; + final String peerPubkey; + final String selfPubkey; + final Future Function()? _onDispose; + + bool get isOnline => _transport != null; + + /// Personal "circle" rule: one vouch from your side, out to a friend-of-a- + /// friend. Loose by design — "people near you". + static const _circleThreshold = 1; + static const _circleDistance = 2; + + /// Vouches expire and must be renewed, so stale trust prunes itself. + static const _vouchValidity = Duration(days: 365); + + /// Loads the certification graph and computes: the peer's certifier count, + /// whether you vouch, and whether they're in your circle. + Future load() async { + final transport = _transport; + if (transport == null) { + emit(state.copyWith(loading: false)); + return; + } + emit(state.copyWith(loading: true)); + final wot = WebOfTrust.fromCertifications( + await transport.allCertifications(), + now: DateTime.now(), + ); + final certifiers = wot.certifiersOf(peerPubkey); + final circle = wot.members( + seeds: {selfPubkey}, + threshold: _circleThreshold, + maxDistance: _circleDistance, + ); + emit(state.copyWith( + certifierCount: certifiers.length, + iVouch: certifiers.contains(selfPubkey), + knownToYou: circle.contains(peerPubkey), + loading: false, + )); + } + + /// Adds or removes this user's vouch, then reloads. Never vouches for self. + /// The certification is issued with a validity so it expires and must be + /// renewed. + Future toggleVouch() async { + final transport = _transport; + if (transport == null || peerPubkey == selfPubkey) return; + emit(state.copyWith(busy: true)); + if (state.iVouch) { + await transport.revoke(subjectPubkey: peerPubkey); + } else { + await transport.certify( + subjectPubkey: peerPubkey, + validity: _vouchValidity, + ); + } + await load(); + emit(state.copyWith(busy: false)); + } + + @override + Future close() async { + await _onDispose?.call(); + return super.close(); + } +} diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart index 292c07f..7767976 100644 --- a/apps/app_seeds/lib/state/variety_detail_cubit.dart +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -81,6 +81,8 @@ class VarietyDetailCubit extends Cubit { Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, + double? priceAmount, + String? priceCurrency, }) => _repo.addLot( varietyId: varietyId, type: type, @@ -93,6 +95,8 @@ class VarietyDetailCubit extends Cubit { abundance: abundance, preservationFormat: preservationFormat, offerStatus: offerStatus, + priceAmount: priceAmount, + priceCurrency: priceCurrency, ); Future updateLot({ @@ -107,6 +111,8 @@ class VarietyDetailCubit extends Cubit { Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, + double? priceAmount, + String? priceCurrency, }) => _repo.updateLot( lotId: lotId, type: type, @@ -119,6 +125,8 @@ class VarietyDetailCubit extends Cubit { abundance: abundance, preservationFormat: preservationFormat, offerStatus: offerStatus, + priceAmount: priceAmount, + priceCurrency: priceCurrency, ); Future deleteLot(String lotId) => _repo.softDeleteLot(lotId); diff --git a/apps/app_seeds/lib/ui/about_screen.dart b/apps/app_seeds/lib/ui/about_screen.dart index a09e77c..d111c8b 100644 --- a/apps/app_seeds/lib/ui/about_screen.dart +++ b/apps/app_seeds/lib/ui/about_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -6,10 +7,16 @@ import '../i18n/strings.g.dart'; import 'theme.dart'; /// The app's public website. Shown as a tappable row in [AboutScreen]. -const String _websiteUrl = 'https://tanemaki.app'; +const String _websiteUrl = 'https://tane.comunes.org'; -/// The year Tanemaki's copyright starts. Ğ1nkgo uses its own first year (2023); -/// Tanemaki's is 2026. +/// The public source repository (AGPL). Shown as a tappable row in [AboutScreen]. +const String _sourceUrl = 'https://git.comunes.org/comunes/tane'; + +/// The translation platform where anyone can help translate Tane. +const String _translateUrl = 'https://translate.comunes.org/projects/tane/'; + +/// The year Tane's copyright starts. Ğ1nkgo uses its own first year (2023); +/// Tane's is 2026. const int _copyrightStartYear = 2026; /// A single year (`2026`) until the calendar rolls over, then a range @@ -22,7 +29,7 @@ String _copyrightYears() { } /// An "about" screen à la Ğ1nkgo: brand header, the human explanation of what -/// Tanemaki is and where its name comes from, the app version, license, the +/// Tane is and where its name comes from, the app version, license, the /// website, and the bundled open-source licenses page. class AboutScreen extends StatelessWidget { const AboutScreen({super.key}); @@ -68,6 +75,31 @@ class AboutScreen extends StatelessWidget { mode: LaunchMode.externalApplication, ), ), + ListTile( + leading: const Icon(Icons.code, color: seedGreen), + title: Text(t.about.sourceCode), + subtitle: const Text(_sourceUrl), + onTap: () => launchUrl( + Uri.parse(_sourceUrl), + mode: LaunchMode.externalApplication, + ), + ), + ListTile( + leading: const Icon(Icons.translate, color: seedGreen), + title: Text(t.about.translate), + subtitle: Text(t.about.translateSubtitle), + onTap: () => launchUrl( + Uri.parse(_translateUrl), + mode: LaunchMode.externalApplication, + ), + ), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined, color: seedGreen), + title: Text(t.legal.title), + subtitle: Text(t.legal.subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/legal'), + ), ListTile( leading: const Icon(Icons.description_outlined, color: seedGreen), title: Text(t.about.openSourceLicenses), @@ -99,7 +131,7 @@ class AboutScreen extends StatelessWidget { } } -/// Brand block: the logo, the app name and its 種まき kanji. +/// Brand block: the logo, the app name and its 種 kanji. class _Header extends StatelessWidget { const _Header(); @@ -133,7 +165,7 @@ class _Header extends StatelessWidget { } } -/// The two explanatory paragraphs: what Tanemaki is, and the heritage of its +/// The two explanatory paragraphs: what Tane is, and the heritage of its /// name (yui / tanomoshi / the paper Plantare). class _Paragraph extends StatelessWidget { const _Paragraph(); diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 9bf5bfb..28d9264 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -5,13 +5,18 @@ import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; import 'seed_glyph.dart'; import 'theme.dart'; +import 'unread_badge.dart'; /// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is /// the live destination (green seed glyph), the social items (market, profile, /// chat…) belong to Block 2 and are greyed with a "soon" tag, and Settings sits /// pinned at the bottom. class AppDrawer extends StatelessWidget { - const AppDrawer({super.key}); + const AppDrawer({this.marketEnabled = false, super.key}); + + /// When the Block 2 social layer is wired, the Market becomes a live drawer + /// destination; other social items stay "soon" until they're built. + final bool marketEnabled; @override Widget build(BuildContext context) { @@ -22,30 +27,104 @@ class AppDrawer extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const _DrawerHeader(), - _DrawerItem( - icon: const SeedGlyph(SeedGlyphs.jars, size: 23), - label: t.menu.inventory, - onTap: () { - Navigator.of(context).pop(); - context.go('/inventory'); - }, + // The destinations scroll; the header above and the utility footer + // below stay pinned, so Settings/About are always reachable even on + // a short screen (drawer used to overflow once Calendar was added). + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: [ + _DrawerItem( + icon: const SeedGlyph(SeedGlyphs.jars, size: 23), + label: t.menu.inventory, + onTap: () { + Navigator.of(context).pop(); + context.push('/inventory'); + }, + ), + // Local, works offline — a reproduction-commitment ledger. + _DrawerItem( + icon: const Icon(Icons.volunteer_activism_outlined), + label: t.menu.plantares, + onTap: () { + Navigator.of(context).pop(); + context.push('/plantares'); + }, + ), + // Local sales ledger — also offline. + _DrawerItem( + icon: const Icon(Icons.sell_outlined), + label: t.menu.sales, + onTap: () { + Navigator.of(context).pop(); + context.push('/sales'); + }, + ), + // "What's due this month" — the aggregate crop calendar. + _DrawerItem( + icon: const Icon(Icons.calendar_month), + label: t.menu.calendar, + onTap: () { + Navigator.of(context).pop(); + context.push('/calendar'); + }, + ), + _DrawerItem( + icon: const Icon(Symbols.storefront), + label: t.menu.market, + divider: true, + onTap: marketEnabled + ? () { + Navigator.of(context).pop(); + context.push('/market'); + } + : null, + ), + _DrawerItem( + icon: const Icon(Icons.person), + label: t.menu.profile, + onTap: marketEnabled + ? () { + Navigator.of(context).pop(); + context.push('/profile'); + } + : null, + ), + _DrawerItem( + icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), + label: t.menu.chat, + onTap: marketEnabled + ? () { + Navigator.of(context).pop(); + context.push('/messages'); + } + : null, + ), + _DrawerItem( + icon: const Icon(Icons.favorite), + label: t.menu.wishlist, + onTap: marketEnabled + ? () { + Navigator.of(context).pop(); + context.push('/favorites'); + } + : null, + ), + // The ego-centric web of trust ("your people") — live once the + // social layer is on. + _DrawerItem( + icon: const Icon(Icons.group), + label: t.menu.following, + onTap: marketEnabled + ? () { + Navigator.of(context).pop(); + context.push('/your-people'); + } + : null, + ), + ], + ), ), - _DrawerItem( - icon: const Icon(Symbols.storefront), - label: t.menu.market, - divider: true, - ), - _DrawerItem(icon: const Icon(Icons.person), label: t.menu.profile), - _DrawerItem( - icon: const Icon(Icons.chat_bubble), - label: t.menu.chat, - ), - _DrawerItem( - icon: const Icon(Icons.favorite), - label: t.menu.wishlist, - ), - _DrawerItem(icon: const Icon(Icons.group), label: t.menu.following), - const Spacer(), const Divider(height: 1), _DrawerItem( icon: const Icon(Icons.auto_stories_outlined), diff --git a/apps/app_seeds/lib/ui/auto_backup_gate.dart b/apps/app_seeds/lib/ui/auto_backup_gate.dart new file mode 100644 index 0000000..8fb514c --- /dev/null +++ b/apps/app_seeds/lib/ui/auto_backup_gate.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; + +import '../services/auto_backup_service.dart'; + +/// Drives the automatic backup off the app lifecycle: once on startup and again +/// each time the app becomes hidden (backgrounded on mobile, window hidden on +/// desktop). The [AutoBackupService] decides whether a copy is actually due, so +/// these are cheap no-ops most of the time. +/// +/// When [service] is null (widget tests, or platforms without file storage such +/// as web) it wraps [child] untouched and does nothing. +class AutoBackupGate extends StatefulWidget { + const AutoBackupGate({required this.child, this.service, super.key}); + + final Widget child; + final AutoBackupService? service; + + @override + State createState() => _AutoBackupGateState(); +} + +class _AutoBackupGateState extends State { + AppLifecycleListener? _listener; + + @override + void initState() { + super.initState(); + final service = widget.service; + if (service == null) return; + _listener = AppLifecycleListener(onHide: () => service.runIfDue()); + WidgetsBinding.instance.addPostFrameCallback((_) => service.runIfDue()); + } + + @override + void dispose() { + _listener?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/app_seeds/lib/ui/avatar.dart b/apps/app_seeds/lib/ui/avatar.dart new file mode 100644 index 0000000..29d9461 --- /dev/null +++ b/apps/app_seeds/lib/ui/avatar.dart @@ -0,0 +1,27 @@ +import 'seed_glyph.dart'; + +/// A profile avatar is carried in one string (stored locally and published as +/// the NIP-01 kind:0 `picture`), in one of two shapes: +/// - `data:image/jpeg;base64,…` — a tiny photo thumbnail (rides inline, no +/// media server, like offer photos); +/// - empty — a DiceBear "thumbs" face generated deterministically from the pubkey. +/// +/// A legacy `tane:seed:` value (a seed illustration, no longer offered in +/// the picker) is still rendered for anyone who set one before; see +/// [avatarGlyphChar]. +const avatarGlyphPrefix = 'tane:seed:'; + +/// The glyph char for a legacy seed-illustration [name], or null if unknown. +/// Retained only to render `tane:seed:` avatars stored before the picker dropped +/// them; new avatars are photos or the generated default. +String? avatarGlyphChar(String name) => switch (name) { + 'jars' => SeedGlyphs.jars, + 'sack' => SeedGlyphs.sack, + 'jar' => SeedGlyphs.jar, + 'scattered' => SeedGlyphs.scattered, + 'pouring' => SeedGlyphs.pouring, + 'mug' => SeedGlyphs.mug, + 'bigSpoon' => SeedGlyphs.bigSpoon, + 'smallSpoon' => SeedGlyphs.smallSpoon, + _ => null, + }; diff --git a/apps/app_seeds/lib/ui/avatar_edit.dart b/apps/app_seeds/lib/ui/avatar_edit.dart new file mode 100644 index 0000000..4ed15c3 --- /dev/null +++ b/apps/app_seeds/lib/ui/avatar_edit.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import '../services/offer_thumbnail.dart' show offerThumbnailDataUri; +import 'photo_crop.dart'; +import 'photo_pick.dart'; +import 'theme.dart'; + +/// Lets the user choose a profile avatar: take/pick a photo, square-crop it, and +/// keep it as a tiny inline thumbnail — or remove it. With no photo, the +/// coloured-initial disc is used. +/// +/// Returns the new avatar value (a `data:` photo thumbnail), an empty string to +/// clear it (falls back to the coloured-initial disc), or null when cancelled. +Future showAvatarPicker( + BuildContext context, { + required String current, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) { + final t = sheetContext.t; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.avatar.title, + style: Theme.of(sheetContext).textTheme.titleMedium), + const SizedBox(height: 8), + ListTile( + key: const Key('avatar.fromPhoto'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.add_a_photo_outlined, + color: seedGreen), + title: Text(t.avatar.fromPhoto), + onTap: () async { + final bytes = await pickPhoto(sheetContext); + if (bytes == null || !sheetContext.mounted) return; + final cropped = await cropToSquare(sheetContext, bytes); + if (cropped == null || !sheetContext.mounted) return; + final uri = offerThumbnailDataUri(cropped, maxBytes: 24000); + Navigator.of(sheetContext).pop(uri ?? ''); + }, + ), + if (current.isNotEmpty) ...[ + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + key: const Key('avatar.remove'), + onPressed: () => Navigator.of(sheetContext).pop(''), + icon: const Icon(Icons.delete_outline, size: 18), + label: Text(t.avatar.remove), + ), + ), + ], + ], + ), + ), + ); + }, + ); +} diff --git a/apps/app_seeds/lib/ui/backup_section.dart b/apps/app_seeds/lib/ui/backup_section.dart index 7278d95..3a4dc28 100644 --- a/apps/app_seeds/lib/ui/backup_section.dart +++ b/apps/app_seeds/lib/ui/backup_section.dart @@ -1,10 +1,12 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; import '../data/export_import/inventory_snapshot.dart'; import '../di/injector.dart'; import '../i18n/strings.g.dart'; +import '../services/auto_backup_service.dart'; import '../services/export_import_service.dart'; import '../services/recovery_sheet_service.dart'; @@ -15,21 +17,32 @@ import '../services/recovery_sheet_service.dart'; /// for confirmation first (it merges into the live inventory), then reports /// what happened in a SnackBar. class BackupSection extends StatelessWidget { - const BackupSection({this.service, this.sheet, super.key}); + const BackupSection({this.service, this.sheet, this.autoBackup, super.key}); /// Injectable for widget tests; the app resolves them lazily (on tap) from /// the service locator so merely mounting Settings needs no DI setup. final ExportImportService? service; final RecoverySheetService? sheet; + /// Drives the reassurance line about automatic copies. When null (and none is + /// registered — e.g. web) the line is simply hidden. + final AutoBackupService? autoBackup; + ExportImportService get _service => service ?? getIt(); RecoverySheetService get _sheet => sheet ?? getIt(); + AutoBackupService? get _autoBackup => + autoBackup ?? + (getIt.isRegistered() + ? getIt() + : null); + @override Widget build(BuildContext context) { final t = context.t; return Column( children: [ + _AutoBackupTile(service: _autoBackup), ListTile( leading: const Icon(Icons.save_alt_outlined), title: Text(t.backup.exportJson), @@ -127,7 +140,7 @@ class BackupSection extends StatelessWidget { title: t.backup.recoverySheetTitle, intro: t.backup.recoveryIntro, code: code, - suggestedName: 'tanemaki-recovery.pdf', + suggestedName: 'tane-recovery.pdf', ); _show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled); } on Object { @@ -299,3 +312,77 @@ class BackupSection extends StatelessWidget { messenger.showSnackBar(SnackBar(content: Text(message))); } } + +/// The automatic-copies line: the app keeps sealed copies on its own, and this +/// says when the last one was made. Tapping it makes one right now (handy after +/// a big change, and so the entry visibly *does* something). Hidden entirely +/// when there is no automatic backup on this platform. +class _AutoBackupTile extends StatefulWidget { + const _AutoBackupTile({required this.service}); + + final AutoBackupService? service; + + @override + State<_AutoBackupTile> createState() => _AutoBackupTileState(); +} + +class _AutoBackupTileState extends State<_AutoBackupTile> { + late Future _lastBackup = _read(); + bool _busy = false; + + Future _read() async => widget.service?.lastBackupAt(); + + Future _backupNow() async { + final service = widget.service; + if (service == null || _busy) return; + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + setState(() => _busy = true); + final ok = await service.backupNow(); + if (!mounted) return; + setState(() { + _busy = false; + _lastBackup = _read(); + }); + messenger.showSnackBar( + SnackBar(content: Text(ok ? t.backup.exportSaved : t.backup.failed)), + ); + } + + @override + Widget build(BuildContext context) { + if (widget.service == null) return const SizedBox.shrink(); + final t = context.t; + return FutureBuilder( + future: _lastBackup, + builder: (context, snapshot) { + final last = snapshot.data; + final days = AutoBackupService.backupInterval.inDays; + final subtitle = last == null + ? t.backup.autoBackupNone(days: days) + : t.backup.autoBackupLast( + // Format via the Localizations locale, not the raw app locale: + // Asturian (`ast`) has no `intl` date symbols and would throw, + // but it's mapped to Spanish for the framework (see app.dart). + date: DateFormat.yMMMd( + Localizations.localeOf(context).languageCode, + ).format(last), + days: days, + ); + return ListTile( + leading: const Icon(Icons.shield_outlined), + title: Text(t.backup.autoBackupTitle), + subtitle: Text(subtitle), + trailing: _busy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.backup_outlined), + onTap: _busy ? null : _backupNow, + ); + }, + ); + } +} diff --git a/apps/app_seeds/lib/ui/blocked_people.dart b/apps/app_seeds/lib/ui/blocked_people.dart new file mode 100644 index 0000000..5982dd2 --- /dev/null +++ b/apps/app_seeds/lib/ui/blocked_people.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +import '../di/injector.dart'; +import '../i18n/strings.g.dart'; +import '../services/profile_cache.dart'; +import '../services/social_settings.dart'; +import 'peer_avatar.dart'; +import 'theme.dart'; + +/// Management list for the local blocklist: who is blocked, with one-tap +/// unblock. Opened from the market's sharing setup. +class BlockedPeopleSheet extends StatefulWidget { + const BlockedPeopleSheet({required this.settings, this.profileCache, super.key}); + + final SocialSettings settings; + + /// Resolves peers' display names; falls back to the app-wide cache when not + /// injected (tests pass one explicitly or leave names as short keys). + final ProfileCache? profileCache; + + @override + State createState() => _BlockedPeopleSheetState(); +} + +class _BlockedPeopleSheetState extends State { + List? _blocked; + final Map _names = {}; + + ProfileCache? get _cache => + widget.profileCache ?? + (getIt.isRegistered() ? getIt() : null); + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final blocked = (await widget.settings.blockedPubkeys()).toList()..sort(); + final cache = _cache; + if (cache != null) { + for (final p in blocked) { + final name = await cache.name(p); + if (name != null) _names[p] = name; + } + } + if (mounted) setState(() => _blocked = blocked); + } + + Future _unblock(String pubkey) async { + await widget.settings.unblock(pubkey); + await _load(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final blocked = _blocked; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.block.manageTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + if (blocked == null) + const Center(child: CircularProgressIndicator()) + else if (blocked.isEmpty) + Text( + t.block.manageEmpty, + style: const TextStyle(color: seedMuted), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: blocked.length, + itemBuilder: (context, i) { + final pubkey = blocked[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: PeerAvatar( + pubkey: pubkey, + name: _names[pubkey], + ), + title: Text(_names[pubkey] ?? shortPubkey(pubkey)), + trailing: TextButton( + onPressed: () => _unblock(pubkey), + child: Text(t.block.unblock), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/calendar_screen.dart b/apps/app_seeds/lib/ui/calendar_screen.dart new file mode 100644 index 0000000..d01df9e --- /dev/null +++ b/apps/app_seeds/lib/ui/calendar_screen.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../app.dart' show materialLocaleFor; +import '../data/variety_repository.dart'; +import '../domain/crop_calendar.dart'; +import '../i18n/strings.g.dart'; +import 'category_palette.dart'; +import 'edge_fade.dart'; +import 'theme.dart'; + +/// One crop-calendar phase as shown on the "this month" screen: its label, +/// which month mask on a [CalendarEntry] it reads, an icon and a tint. +class _Phase { + const _Phase(this.label, this.maskOf, this.icon, this.color); + final String Function(Translations t) label; + final int? Function(CalendarEntry e) maskOf; + final IconData icon; + final Color color; +} + +// Actions first (sow / transplant / harvest seed), then the informational +// phases (flowering / fruiting) — "qué toca hacer" above "qué está pasando". +const _phases = <_Phase>[ + _Phase(_sowLabel, _sowMask, Icons.grass, Color(0xFF2F7D34)), + _Phase(_transplantLabel, _transplantMask, Icons.spa, Color(0xFF3E7168)), + _Phase(_harvestLabel, _harvestMask, Icons.grain, Color(0xFF8A6D1E)), + _Phase(_floweringLabel, _floweringMask, Icons.local_florist, Color(0xFF9B5566)), + _Phase(_fruitingLabel, _fruitingMask, Icons.eco, Color(0xFF9C5B3B)), +]; + +String _sowLabel(Translations t) => t.cropCalendar.sow; +String _transplantLabel(Translations t) => t.cropCalendar.transplant; +String _harvestLabel(Translations t) => t.cropCalendar.seedHarvest; +String _floweringLabel(Translations t) => t.cropCalendar.flowering; +String _fruitingLabel(Translations t) => t.cropCalendar.fruiting; +int? _sowMask(CalendarEntry e) => e.sowMonths; +int? _transplantMask(CalendarEntry e) => e.transplantMonths; +int? _harvestMask(CalendarEntry e) => e.seedHarvestMonths; +int? _floweringMask(CalendarEntry e) => e.floweringMonths; +int? _fruitingMask(CalendarEntry e) => e.fruitingMonths; + +/// "What's due this month" across the whole inventory: a month strip plus the +/// varieties whose recorded calendar has an action in the picked month, grouped +/// by phase. Reuses the per-variety calendar data — no schema of its own. +class CalendarScreen extends StatefulWidget { + const CalendarScreen({this.initialMonth, super.key}); + + /// The month (1..12) to open on; defaults to the current month. Injected in + /// tests so they don't depend on the wall clock. + final int? initialMonth; + + @override + State createState() => _CalendarScreenState(); +} + +class _CalendarScreenState extends State { + late int _month = widget.initialMonth ?? DateTime.now().month; + + @override + Widget build(BuildContext context) { + final t = context.t; + final repo = context.read(); + return Scaffold( + appBar: AppBar(title: Text(t.calendar.title)), + body: Column( + children: [ + _MonthStrip( + month: _month, + onSelect: (m) => setState(() => _month = m), + ), + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 8), + child: Text( + t.calendar.selfNote, + style: const TextStyle(color: seedMuted, fontSize: 12), + ), + ), + const Divider(height: 1), + Expanded( + child: StreamBuilder>( + stream: repo.watchCalendar(), + builder: (context, snapshot) { + final entries = snapshot.data ?? const []; + final sections = []; + for (final phase in _phases) { + final matches = [ + for (final e in entries) + if (maskHasMonth(phase.maskOf(e), _month)) e, + ]; + if (matches.isEmpty) continue; + sections.add(_PhaseGroup(phase: phase, entries: matches)); + } + if (sections.isEmpty) { + return _EmptyMonth(monthName: _monthName(context, _month)); + } + return ListView( + padding: const EdgeInsets.only(bottom: 24), + children: sections, + ); + }, + ), + ), + ], + ), + ); + } +} + +/// A horizontally scrolling strip of the twelve months; the picked one is +/// filled. Scrolls the picked month into view (so late-year months aren't left +/// off-screen when the screen opens in, say, November). +class _MonthStrip extends StatefulWidget { + const _MonthStrip({required this.month, required this.onSelect}); + + final int month; + final ValueChanged onSelect; + + @override + State<_MonthStrip> createState() => _MonthStripState(); +} + +class _MonthStripState extends State<_MonthStrip> { + final _selectedKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _revealSelected(); + } + + @override + void didUpdateWidget(_MonthStrip old) { + super.didUpdateWidget(old); + if (old.month != widget.month) _revealSelected(); + } + + void _revealSelected() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _selectedKey.currentContext; + if (ctx != null) { + Scrollable.ensureVisible(ctx, + alignment: 0.5, duration: const Duration(milliseconds: 250)); + } + }); + } + + @override + Widget build(BuildContext context) { + final locale = materialLocaleFor(Localizations.localeOf(context)); + final fmt = DateFormat.MMM(locale.toLanguageTag()); + return SizedBox( + height: 56, + child: EdgeFade( + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + itemCount: 12, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final m = i + 1; + final selected = m == widget.month; + return ChoiceChip( + key: Key('calendar.month.$m'), + showCheckmark: false, + labelPadding: const EdgeInsets.symmetric(horizontal: 6), + label: Text( + _capitalise(fmt.format(DateTime(2000, m))), + key: selected ? _selectedKey : null, + ), + selected: selected, + onSelected: (_) => widget.onSelect(m), + selectedColor: seedGreen, + labelStyle: TextStyle( + color: selected ? Colors.white : seedMuted, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ); + }, + ), + ), + ); + } +} + +class _PhaseGroup extends StatelessWidget { + const _PhaseGroup({required this.phase, required this.entries}); + + final _Phase phase; + final List entries; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 18, 16, 6), + child: Row( + children: [ + Icon(phase.icon, size: 20, color: phase.color), + const SizedBox(width: 8), + Text( + phase.label(t), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: phase.color, + ), + ), + const SizedBox(width: 6), + Text('· ${entries.length}', + style: const TextStyle(color: seedMuted)), + ], + ), + ), + for (final e in entries) _CalendarRow(entry: e), + ], + ); + } +} + +class _CalendarRow extends StatelessWidget { + const _CalendarRow({required this.entry}); + + final CalendarEntry entry; + + @override + Widget build(BuildContext context) { + final swatch = categorySwatch(entry.category ?? ''); + return ListTile( + key: Key('calendar.entry.${entry.id}'), + leading: CircleAvatar( + radius: 20, + backgroundColor: swatch.fill, + foregroundImage: + entry.photo == null ? null : MemoryImage(entry.photo!), + child: entry.photo == null + ? Text( + entry.label.isEmpty + ? '?' + : entry.label.substring(0, 1).toUpperCase(), + style: TextStyle(color: swatch.ink, fontWeight: FontWeight.w600), + ) + : null, + ), + title: Text(entry.label), + subtitle: entry.category == null ? null : Text(entry.category!), + onTap: () => context.push('/variety/${entry.id}'), + ); + } +} + +class _EmptyMonth extends StatelessWidget { + const _EmptyMonth({required this.monthName}); + + final String monthName; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event_available, + size: 64, color: seedGreen.withValues(alpha: 0.5)), + const SizedBox(height: 16), + Text( + t.calendar.nothing(month: monthName), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ); + } +} + +String _monthName(BuildContext context, int month) { + final locale = materialLocaleFor(Localizations.localeOf(context)); + return _capitalise( + DateFormat.MMMM(locale.toLanguageTag()).format(DateTime(2000, month))); +} + +String _capitalise(String s) => + s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); diff --git a/apps/app_seeds/lib/ui/category_palette.dart b/apps/app_seeds/lib/ui/category_palette.dart new file mode 100644 index 0000000..d0006f8 --- /dev/null +++ b/apps/app_seeds/lib/ui/category_palette.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../db/enums.dart'; +import 'theme.dart'; + +/// A colour for a chip/label: a soft [fill] to tint the background and a darker, +/// same-hue [ink] readable on that fill and on white (text, dot, checkmark). +class Swatch { + const Swatch(this.fill, this.ink); + final Color fill; + final Color ink; +} + +/// A muted, earthy palette — one low-saturation key, hues spread around the +/// wheel — so botanical families (categories) are distinguishable at a glance +/// without shouting over the green brand. Kept calm on purpose (soft fills, +/// legible ink). 14 tones keep collisions rare for a typical 6–10 family +/// inventory. Order is stable; new entries append only. +// Inks are tuned so the label meets WCAG AA (≥4.5:1) on its own fill — see +// test/ui/category_palette_contrast_test.dart. +const _familyPalette = [ + Swatch(Color(0xFFE8EEDA), Color(0xFF556B2F)), // olive + Swatch(Color(0xFFF3E2D8), Color(0xFF8E5336)), // terracotta + Swatch(Color(0xFFF3EBD3), Color(0xFF7C621B)), // ochre + Swatch(Color(0xFFDDEAE6), Color(0xFF3E7168)), // sage teal + Swatch(Color(0xFFF0E1E4), Color(0xFF8F4E5E)), // dusty rose + Swatch(Color(0xFFE0E6EE), Color(0xFF4A6489)), // slate blue + Swatch(Color(0xFFE9E1EE), Color(0xFF6E5090)), // plum + Swatch(Color(0xFFEBE3D8), Color(0xFF7A5A3A)), // warm brown + Swatch(Color(0xFFDCEAD6), Color(0xFF3E6B3A)), // forest green + Swatch(Color(0xFFD6E7EA), Color(0xFF2C6A73)), // petrol + Swatch(Color(0xFFF3DEDB), Color(0xFFA14234)), // brick red + Swatch(Color(0xFFEFDCEA), Color(0xFF864C7C)), // mauve + Swatch(Color(0xFFE1E1F1), Color(0xFF4E5199)), // indigo + Swatch(Color(0xFFDFE4E9), Color(0xFF4C5E70)), // steel +]; + +/// A stable (launch-to-launch, platform-independent) hash of a category name. +/// `String.hashCode` is randomised per run, which would reshuffle colours on +/// every launch — so fold the code units ourselves (FNV-1a-ish). +int _stableHash(String s) { + var h = 0x811c9dc5; + for (final c in s.codeUnits) { + h = (h ^ c) * 0x01000193; + h &= 0xffffffff; + } + return h; +} + +/// The stable colour for a free-text category (a botanical family). The same +/// name always maps to the same tonality. +Swatch categorySwatch(String category) => + _familyPalette[_stableHash(category) % _familyPalette.length]; + +/// A fixed tonality per lot form, so the form chips read as their own colour +/// group (distinct from the family chips). Greens for the leafy forms, warmer +/// tones for woody/underground ones. +Swatch lotTypeSwatch(LotType type) => switch (type) { + LotType.seed => const Swatch(seedPrimaryContainer, seedOnPrimaryContainer), + LotType.seedling => const Swatch(Color(0xFFDDEBCF), Color(0xFF456D29)), + LotType.plant => const Swatch(Color(0xFFD9EAD9), Color(0xFF2A702F)), + LotType.tree => const Swatch(Color(0xFFE3D9C8), Color(0xFF6B4F2A)), + LotType.bulb => const Swatch(Color(0xFFF3E9CF), Color(0xFF7B611B)), + LotType.cutting => const Swatch(Color(0xFFE6EAD3), Color(0xFF5D692C)), +}; diff --git a/apps/app_seeds/lib/ui/chat_list_screen.dart b/apps/app_seeds/lib/ui/chat_list_screen.dart new file mode 100644 index 0000000..15559f4 --- /dev/null +++ b/apps/app_seeds/lib/ui/chat_list_screen.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../i18n/strings.g.dart'; +import '../services/inbox_service.dart'; +import '../services/message_store.dart'; +import '../services/profile_cache.dart'; +import 'peer_avatar.dart'; +import '../services/social_connection.dart'; +import '../services/social_settings.dart'; +import 'theme.dart'; +import 'unread_badge.dart'; + +/// The messages inbox: past conversations, newest first. Shows each peer's +/// published name (falling back to a short key), and opens the chat on tap. +class ChatListScreen extends StatefulWidget { + const ChatListScreen({ + required this.store, + this.connection, + this.profileCache, + this.inbox, + this.settings, + super.key, + }); + + final MessageStore store; + + /// The shared relay connection, for resolving peers' published names. + final SocialConnection? connection; + final ProfileCache? profileCache; + + /// App-wide inbox listener; the list reloads whenever it reports a change. + final InboxService? inbox; + + /// Social settings holding the local blocklist; blocked peers' conversations + /// are hidden. Null in tests → nothing filtered. + final SocialSettings? settings; + + @override + State createState() => _ChatListScreenState(); +} + +class _ChatListScreenState extends State { + List? _items; + final Map _names = {}; + StreamSubscription? _changesSub; + + @override + void initState() { + super.initState(); + _load(); + // Live-refresh when the app-wide inbox listener persists a new message. + _changesSub = widget.inbox?.changes.listen((_) => _load()); + } + + @override + void dispose() { + _changesSub?.cancel(); + super.dispose(); + } + + Future _load() async { + var items = await widget.store.conversations(); + final blocked = await widget.settings?.blockedPubkeys(); + if (blocked != null && blocked.isNotEmpty) { + items = items.where((c) => !blocked.contains(c.peerPubkey)).toList(); + } + final cache = widget.profileCache; + if (cache != null) { + for (final c in items) { + final name = await cache.name(c.peerPubkey); + if (name != null) _names[c.peerPubkey] = name; + } + } + if (!mounted) return; + setState(() => _items = items); + unawaited(_resolveMissingNames(items)); + } + + /// Fetches published names for peers we don't have cached yet, over the shared + /// connection. Best effort; the list already shows short keys meanwhile. + Future _resolveMissingNames(List items) async { + final connection = widget.connection; + final cache = widget.profileCache; + if (connection == null || cache == null) return; + final missing = + items.map((c) => c.peerPubkey).where((p) => !_names.containsKey(p)); + if (missing.isEmpty) return; + final session = await connection.session(); + if (session == null) return; + try { + for (final peer in missing) { + final profile = await session.profile.fetch(peer); + if (profile != null && profile.name.isNotEmpty) { + await cache.setName(peer, profile.name); + if (mounted) setState(() => _names[peer] = profile.name); + } + } + } catch (_) { + // offline / unreachable — short keys stay. + } + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final items = _items; + return Scaffold( + appBar: AppBar(title: Text(t.chatList.title)), + body: items == null + ? const Center(child: CircularProgressIndicator()) + : items.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + t.chatList.empty, + textAlign: TextAlign.center, + style: const TextStyle(color: seedMuted, fontSize: 15), + ), + ), + ) + : ListView.separated( + itemCount: items.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) { + final c = items[i]; + return ListTile( + leading: UnreadBadge( + peer: c.peerPubkey, + child: CachedAvatar( + pubkey: c.peerPubkey, + name: _names[c.peerPubkey], + cache: widget.profileCache, + radius: 20, + ), + ), + title: Text(_names[c.peerPubkey] ?? + shortPubkey(c.peerPubkey)), + subtitle: Text( + c.lastText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => context.push('/chat/${c.peerPubkey}'), + ); + }, + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart new file mode 100644 index 0000000..6598f36 --- /dev/null +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -0,0 +1,815 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../data/variety_repository.dart'; +import '../di/injector.dart'; +import '../domain/chat_timeline.dart'; +import '../domain/message_rules.dart'; +import '../i18n/strings.g.dart'; +import '../services/message_store.dart'; +import '../services/onboarding_store.dart'; +import '../services/plantare_service.dart'; +import '../services/profile_cache.dart'; +import '../services/profile_store.dart'; +import '../services/social_connection.dart'; +import '../services/social_service.dart'; +import '../services/social_settings.dart'; +import '../services/unread_service.dart'; +import '../state/messages_cubit.dart'; +import '../state/peer_rating_cubit.dart'; +import '../state/trust_cubit.dart'; +import 'market_gate.dart'; +import 'peer_avatar.dart'; +import 'plantare_propose_sheet.dart'; +import 'rating_sheet.dart'; +import 'report_sheet.dart'; +import 'theme.dart'; + +/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and +/// metadata-hiding (NIP-17) under the hood; local-first, so it degrades to a +/// "set up sharing" note when there's no relay. +class ChatScreen extends StatefulWidget { + const ChatScreen({ + required this.social, + required this.connection, + required this.peerPubkey, + this.messageStore, + this.profileCache, + this.onboarding, + this.settings, + super.key, + }); + + final SocialService social; + + /// The shared relay connection (one per identity), carrying messaging + trust. + final SocialConnection connection; + final String peerPubkey; + + /// Optional persistence for chat history (encrypted [ChatDatabase]-backed); + /// null in tests. + final MessageStore? messageStore; + + /// Optional cache of peer display names; null in tests. + final ProfileCache? profileCache; + + /// When set, the first message requires the one-time community-rules + /// acceptance (a chat can be reached from an incoming message without ever + /// entering the market). Null in tests → no gate. + final OnboardingStore? onboarding; + + /// Social settings holding the local blocklist; falls back to the app-wide + /// instance so the block action also works when not injected. Null in + /// tests → the block menu is hidden. + final SocialSettings? settings; + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + MessagesCubit? _messages; + TrustCubit? _trust; + PeerRatingCubit? _rating; + bool _loading = true; + String? _peerName; + String? _selfName; + String? _peerPicture; + String? _selfPicture; + String? _peerG1; + final _input = TextEditingController(); + + /// App-wide unread tracker (absent in tests / inventory-only builds). While + /// this chat is open it is the "active peer", so incoming messages for it are + /// read on arrival and never badge or notify. + UnreadService? _unread; + + @override + void initState() { + super.initState(); + _unread = getIt.isRegistered() + ? getIt() + : null; + // Set synchronously (before any await) so a message landing during _init is + // already suppressed. + _unread?.activePeer = widget.peerPubkey; + unawaited(_unread?.markRead(widget.peerPubkey)); + _init(); + } + + Future _init() async { + // The shared connection carries both messaging and trust. Offline (not + // connected / unreachable) → null transports and the screen degrades. + final session = await widget.connection.session(); + final cachedName = await widget.profileCache?.name(widget.peerPubkey); + final cachedPicture = await widget.profileCache?.picture(widget.peerPubkey); + // My own name + avatar, so my side shows me (not a bare glyph). + final selfStore = getIt.isRegistered() + ? getIt() + : null; + final selfName = selfStore == null ? '' : await selfStore.name(); + final selfAvatar = selfStore == null ? '' : await selfStore.avatar(); + if (!mounted) return; // shared session is owned by the connection, not us + final self = widget.social.publicKeyHex; + final messages = MessagesCubit( + session?.messages, + peerPubkey: widget.peerPubkey, + selfPubkey: self, + store: widget.messageStore, + )..start(); + final trust = TrustCubit( + session?.trust, + peerPubkey: widget.peerPubkey, + selfPubkey: self, + ); + unawaited(trust.load()); + final rating = PeerRatingCubit( + session?.ratings, + peerPubkey: widget.peerPubkey, + selfPubkey: self, + trust: session?.trust, + ); + unawaited(rating.load()); + setState(() { + _messages = messages; + _trust = trust; + _rating = rating; + _peerName = cachedName; + _selfName = selfName.isEmpty ? null : selfName; + _peerPicture = cachedPicture; + _selfPicture = selfAvatar.isEmpty ? null : selfAvatar; + _loading = false; + }); + + // Freshen the peer's name from their published profile. + final live = session; + if (live != null && widget.profileCache != null) { + unawaited(() async { + try { + final profile = await live.profile.fetch(widget.peerPubkey); + if (profile != null && mounted) { + if (profile.name.isNotEmpty) { + await widget.profileCache!.setName( + widget.peerPubkey, + profile.name, + ); + } + if (profile.picture.isNotEmpty) { + await widget.profileCache!.setPicture( + widget.peerPubkey, + profile.picture, + ); + } + setState(() { + if (profile.name.isNotEmpty) _peerName = profile.name; + if (profile.picture.isNotEmpty) _peerPicture = profile.picture; + if (profile.g1.isNotEmpty) _peerG1 = profile.g1; + }); + } + } catch (_) { + // best effort + } + }()); + } + } + + /// Opens the peer's Ğ1 wallet so you can pay them (Ğ1 is separate from the + /// Nostr key — this uses the address they published in their profile). Falls + /// back to copying the address if no wallet handles the link. + /// + /// NOTE: confirm the deep-link scheme for the target wallet (Ğ1nkgo / Ğecko / + /// Cesium) — this uses the Cesium web wallet as a universal fallback. + Future _payG1() async { + final g1 = _peerG1; + if (g1 == null) return; + final messenger = ScaffoldMessenger.of(context); + final t = context.t; + final uri = Uri.parse('https://demo.cesium.app/#/app/wallet/$g1/'); + var launched = false; + try { + launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + launched = false; + } + if (!launched && mounted) { + await Clipboard.setData(ClipboardData(text: g1)); + messenger.showSnackBar(SnackBar(content: Text(t.chat.g1Copied))); + } + } + + SocialSettings? get _settings => + widget.settings ?? + (getIt.isRegistered() ? getIt() : null); + + /// Reports this peer (a standard report event to the community servers); + /// when they were also blocked from the sheet, leaves the chat too. + /// Opens the "propose a signed Plantaré" sheet against this peer (their key is + /// in hand from the conversation), and confirms when a proposal is sent. + Future _proposePlantare() async { + if (!getIt.isRegistered()) return; + final sent = await showProposePlantareSheet( + context, + service: getIt(), + repository: getIt(), + peerPubkey: widget.peerPubkey, + peerName: _peerName, + ); + if (sent == true && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.t.plantare.sent)), + ); + } + } + + Future _reportPeer() async { + final outcome = await showReportSheet( + context, + connection: widget.connection, + subjectPubkey: widget.peerPubkey, + settings: _settings, + ); + if (outcome != null && outcome.blocked && mounted) { + Navigator.of(context).pop(); + } + } + + /// Blocks this peer after confirmation and leaves the chat; their offers, + /// conversation and future messages disappear (all locally). + Future _blockPeer() async { + final settings = _settings; + if (settings == null) return; + final t = context.t; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.block.confirmTitle), + content: Text(t.block.confirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.common.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.block.confirm), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await settings.block(widget.peerPubkey); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(t.block.blockedToast))); + Navigator.of(context).pop(); + } + + Future _send() async { + final cubit = _messages; + if (cubit == null || _input.text.trim().isEmpty) return; + // Writing to someone is creating content: the community-rules acceptance + // must exist even when this chat was reached without entering the market. + final store = widget.onboarding; + if (store != null) { + final ok = await ensureMarketRulesAccepted(context, store); + if (!ok || !mounted) return; + } + final text = _input.text; + // Links aren't allowed — warn and keep the text so it can be edited. + if (containsUrl(text)) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(context.t.chat.noLinks))); + return; + } + _input.clear(); + await cubit.send(text); + } + + @override + void dispose() { + if (_unread?.activePeer == widget.peerPubkey) _unread!.activePeer = null; + // Mark read once more on the way out, so anything that arrived while the + // chat was open (and was auto-read) stays cleared. + unawaited(_unread?.markRead(widget.peerPubkey)); + _messages?.close(); + _trust?.close(); + _rating?.close(); + // The session belongs to the shared connection — don't close it here. + _input.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final messages = _messages; + final trust = _trust; + final rating = _rating; + final t = context.t; + return Scaffold( + appBar: AppBar( + title: Text(_peerName ?? shortPubkey(widget.peerPubkey)), + actions: [ + if (_peerG1 != null) + IconButton( + key: const Key('chat.payG1'), + icon: const Icon(Icons.payments_outlined), + tooltip: t.chat.payG1, + onPressed: _payG1, + ), + PopupMenuButton( + key: const Key('chat.menu'), + onSelected: (value) { + if (value == 'plantare') _proposePlantare(); + if (value == 'report') _reportPeer(); + if (value == 'block') _blockPeer(); + }, + itemBuilder: (context) => [ + if (getIt.isRegistered()) + PopupMenuItem( + value: 'plantare', + child: Text(t.plantare.propose), + ), + PopupMenuItem( + value: 'report', + child: Text(t.report.person), + ), + if (_settings != null) + PopupMenuItem( + value: 'block', + child: Text(t.block.action), + ), + ], + ), + ], + ), + body: _loading || messages == null || trust == null || rating == null + ? const Center(child: CircularProgressIndicator()) + : MultiBlocProvider( + providers: [ + BlocProvider.value(value: messages), + BlocProvider.value(value: trust), + BlocProvider.value(value: rating), + ], + child: _ChatBody( + controller: _input, + onSend: _send, + peerName: _peerName, + selfName: _selfName, + peerPicture: _peerPicture, + selfPicture: _selfPicture, + ), + ), + ); + } +} + +class _ChatBody extends StatelessWidget { + const _ChatBody({ + required this.controller, + required this.onSend, + this.peerName, + this.selfName, + this.peerPicture, + this.selfPicture, + }); + + final TextEditingController controller; + final Future Function() onSend; + final String? peerName; + final String? selfName; + final String? peerPicture; + final String? selfPicture; + + @override + Widget build(BuildContext context) { + final t = context.t; + final cubit = context.read(); + if (!cubit.isOnline) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + t.chat.offline, + textAlign: TextAlign.center, + style: const TextStyle(color: seedMuted, fontSize: 15), + ), + ), + ); + } + // Surface a failed send instead of losing it silently (relay down, etc.). + return BlocListener( + listenWhen: (prev, cur) => cur.error != null && prev.error != cur.error, + listener: (context, state) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(t.chat.sendError))); + }, + child: Column( + children: [ + // Free the vertical space the trust/rating strips take while the + // keyboard is up: on short screens (e.g. landscape) their fixed height + // plus the composer would otherwise overflow the shrunken body. They + // reappear the moment the keyboard is dismissed. + const HideWhenKeyboardOpen(child: _TrustBanner()), + const HideWhenKeyboardOpen(child: _RatingStrip()), + Expanded( + child: ChatMessageList( + peerName: peerName, + selfName: selfName, + peerPicture: peerPicture, + selfPicture: selfPicture, + ), + ), + _Composer(controller: controller, onSend: onSend), + ], + ), + ); + } +} + +/// Hides [child] while the on-screen keyboard is open. Used for the chat's +/// secondary strips (trust, rating) so their fixed height doesn't push the +/// composer off a short screen when the keyboard steals vertical space. +class HideWhenKeyboardOpen extends StatelessWidget { + const HideWhenKeyboardOpen({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) => + MediaQuery.viewInsetsOf(context).bottom > 0 + ? const SizedBox.shrink() + : child; +} + +/// The scrolling list of chat bubbles. Anchored at the bottom (chat +/// convention): the newest message is always in view, and new arrivals show up +/// in place instead of landing below the fold. Reads the [MessagesCubit] from +/// context. +class ChatMessageList extends StatelessWidget { + const ChatMessageList({ + this.peerName, + this.selfName, + this.peerPicture, + this.selfPicture, + super.key, + }); + + /// The peer's display name, for their avatar's initial (null → a glyph). + final String? peerName; + + /// My own display name, for my avatar's initial (null → a glyph). + final String? selfName; + + /// The peer's / my published avatar (null → the coloured-initial disc). + final String? peerPicture; + final String? selfPicture; + + @override + Widget build(BuildContext context) { + final t = context.t; + final cubit = context.read(); + return BlocBuilder( + builder: (context, state) { + if (state.messages.isEmpty) { + return Center( + child: Text(t.chat.empty, style: const TextStyle(color: seedMuted)), + ); + } + // Group into bubbles + day separators (oldest-first), then render + // bottom-anchored: with `reverse: true` index 0 is the bottom-most + // (newest), so map it to the tail. Scrolling up to read history stays + // put when new messages come in. + final items = chatTimeline(state.messages); + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(12), + itemCount: items.length, + itemBuilder: (context, i) { + final item = items[items.length - 1 - i]; + return switch (item) { + ChatDaySeparator(:final day) => _DaySeparator(day: day), + ChatMessageRow(:final message) => _MessageRow( + message: message, + mine: cubit.isMine(message), + peerName: peerName, + selfName: selfName, + peerPicture: peerPicture, + selfPicture: selfPicture, + selfPubkey: cubit.selfPubkey, + ), + }; + }, + ); + }, + ); + } +} + +/// A slim "web of trust" strip: how many vouch for this peer, and a toggle to +/// add/remove your own vouch ("I know this person"). +class _TrustBanner extends StatelessWidget { + const _TrustBanner(); + + @override + Widget build(BuildContext context) { + final t = context.t; + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + if (!cubit.isOnline || state.loading) return const SizedBox.shrink(); + // Strongest applicable signal decides the badge (circle > vouched > + // unknown). + final (IconData icon, String label, bool strong) = switch (state.tier) { + TrustTier.inYourCircle => (Icons.verified_user, t.trust.circle, true), + TrustTier.vouched => ( + Icons.people_outline, + t.trust.count(n: state.certifierCount), + false, + ), + TrustTier.unknown => (Icons.person_outline, t.trust.none, false), + }; + return Container( + width: double.infinity, + color: seedPrimaryContainer.withValues(alpha: 0.5), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(icon, size: 18, color: strong ? seedGreen : seedMuted), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle( + color: strong ? seedGreen : seedOnSurface, + fontSize: 13, + fontWeight: strong ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + TextButton( + key: const Key('chat.vouch'), + onPressed: state.busy ? null : cubit.toggleVouch, + child: Text(state.iVouch ? t.trust.vouched : t.trust.vouch), + ), + ], + ), + ); + }, + ); + } +} + +/// A slim reputation strip under the trust banner: everyone's stars for this +/// peer (highlighting how many come from people you know) and, once you've +/// actually talked with them, a button to leave or edit your own rating. +class _RatingStrip extends StatelessWidget { + const _RatingStrip(); + + @override + Widget build(BuildContext context) { + final t = context.t; + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + if (!cubit.isOnline || state.loading) return const SizedBox.shrink(); + // Soft anchor: you can only rate someone you've talked with. + final canRate = context.select( + (m) => m.state.messages.isNotEmpty, + ); + if (!state.hasRatings && !canRate) return const SizedBox.shrink(); + return Container( + width: double.infinity, + color: seedPrimaryContainer.withValues(alpha: 0.3), + padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 8, 4), + child: Row( + children: [ + Expanded( + child: state.hasRatings + ? RatingStars( + average: state.average, + count: state.count, + circleCount: state.circleCount, + ) + : const SizedBox.shrink(), + ), + if (canRate) + TextButton( + key: const Key('chat.rate'), + onPressed: state.busy + ? null + : () => showRatingSheet(context, cubit), + child: + Text(state.iRated ? t.ratings.edit : t.ratings.rate), + ), + ], + ), + ); + }, + ); + } +} + +/// A centered pill introducing a new day ("Today", "Yesterday", or a +/// locale-formatted date). Localized via [chatDayLabel]. +class _DaySeparator extends StatelessWidget { + const _DaySeparator({required this.day}); + + final DateTime day; + + @override + Widget build(BuildContext context) { + final t = context.t; + // Localizations locale, not the raw app locale: `ast` has no intl date + // symbols and is mapped to Spanish for the framework (see app.dart). + final localeCode = Localizations.localeOf(context).languageCode; + final label = chatDayLabel( + day, + now: DateTime.now(), + today: t.chat.today, + yesterday: t.chat.yesterday, + localeCode: localeCode, + ); + return Center( + key: const Key('chat.daySeparator'), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: seedMuted.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: const TextStyle( + color: seedMuted, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +/// One message line. Each sender gets their own coloured avatar on their side — +/// mine on the trailing edge, the peer's on the leading edge — so at a glance +/// the two sides are two people, not one column of look-alike bubbles. +class _MessageRow extends StatelessWidget { + const _MessageRow({ + required this.message, + required this.mine, + this.peerName, + this.selfName, + this.peerPicture, + this.selfPicture, + this.selfPubkey, + }); + + final PrivateMessage message; + final bool mine; + final String? peerName; + final String? selfName; + final String? peerPicture; + final String? selfPicture; + + /// My own pubkey, so my messages carry my (distinctly coloured) avatar. + final String? selfPubkey; + + @override + Widget build(BuildContext context) { + // Each avatar shows its owner's photo/illustration when set, else their + // initial (or a glyph) on a colour derived from their pubkey. + final avatar = mine + ? PeerAvatar( + pubkey: selfPubkey ?? message.fromPubkey, + name: selfName, + picture: selfPicture, + ) + : PeerAvatar( + pubkey: message.fromPubkey, + name: peerName, + picture: peerPicture, + ); + final bubble = Flexible( + child: _Bubble(message: message, mine: mine), + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: mine + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: mine + ? [bubble, const SizedBox(width: 8), avatar] + : [avatar, const SizedBox(width: 8), bubble], + ), + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.message, required this.mine}); + + final PrivateMessage message; + final bool mine; + + @override + Widget build(BuildContext context) { + final localeCode = Localizations.localeOf(context).languageCode; + final time = chatBubbleTime(message.at, localeCode); + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + // Soft, light bubbles both ways (dark text): mine a tonal green, the + // peer's white with a hairline. Sides + avatar colours carry the + // "who", so the bubbles themselves stay light and easy on the eye. + decoration: BoxDecoration( + color: mine ? seedPrimaryContainer : Colors.white, + borderRadius: BorderRadius.circular(16), + border: mine ? null : Border.all(color: seedOutline), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Long text can be selected/copied — useful for addresses, links. + SelectableText( + message.text, + style: const TextStyle(color: seedOnSurface, fontSize: 15), + ), + const SizedBox(height: 2), + // A subtle timestamp, trailing edge (RTL-aware). + Align( + alignment: AlignmentDirectional.centerEnd, + child: Text( + time, + style: const TextStyle(color: seedMuted, fontSize: 11), + ), + ), + ], + ), + ); + } +} + +class _Composer extends StatelessWidget { + const _Composer({required this.controller, required this.onSend}); + + final TextEditingController controller; + final Future Function() onSend; + + @override + Widget build(BuildContext context) { + final t = context.t; + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), + child: Row( + children: [ + Expanded( + child: TextField( + key: const Key('chat.input'), + controller: controller, + textInputAction: TextInputAction.send, + onSubmitted: (_) => onSend(), + decoration: InputDecoration( + hintText: t.chat.hint, + filled: true, + fillColor: seedField, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + key: const Key('chat.send'), + icon: const Icon(Icons.send), + tooltip: t.chat.send, + onPressed: onSend, + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/currency_quick_picks.dart b/apps/app_seeds/lib/ui/currency_quick_picks.dart new file mode 100644 index 0000000..70ef5b1 --- /dev/null +++ b/apps/app_seeds/lib/ui/currency_quick_picks.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; + +/// One-tap currency chips bound to a free-text [controller]. Ğ1 sits quietly +/// among the familiar ones — offered, never imposed — and free text still +/// takes anything else (sharing-model §6). Shared by the sale sheet, the lot +/// price field and the hand-over sheet so every money-ish input feels the same. +class CurrencyQuickPicks extends StatelessWidget { + const CurrencyQuickPicks({ + required this.controller, + required this.keyPrefix, + required this.onChanged, + super.key, + }); + + final TextEditingController controller; + + /// Namespaces the chip [Key]s (e.g. `sale` → `sale.currencyChip.€`). + final String keyPrefix; + + /// Fires after a chip toggles the controller text (for `setState`). + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Wrap( + spacing: 8, + children: [ + for (final c in ['€', 'Ğ1', t.sale.hours]) + ChoiceChip( + key: Key('$keyPrefix.currencyChip.$c'), + label: Text(c), + selected: controller.text.trim() == c, + onSelected: (sel) { + controller.text = sel ? c : ''; + onChanged(); + }, + ), + ], + ); + } +} diff --git a/apps/app_seeds/lib/ui/edge_fade.dart b/apps/app_seeds/lib/ui/edge_fade.dart new file mode 100644 index 0000000..eea3f81 --- /dev/null +++ b/apps/app_seeds/lib/ui/edge_fade.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// Marks the edges of a horizontal scrollable that have more content beyond +/// them, so it is obvious the row scrolls. +/// +/// Wrap directly around a horizontal [SingleChildScrollView] or [ListView]. +/// Each overflowing edge gets a soft scrim in the surface colour — the chips +/// appear to slide *under* it — topped by a chevron pointing outward. The +/// scrim only shows while content remains beyond that edge; it disappears once +/// the row reaches that end (or does not overflow at all). Edge state is +/// tracked per logical side (start/end) and mapped to physical left/right via +/// [Directionality], so RTL mirrors correctly. +class EdgeFade extends StatefulWidget { + const EdgeFade({super.key, required this.child, this.scrimWidth = 40}); + + /// The horizontal scrollable to mark. + final Widget child; + + /// Width of each edge scrim in logical pixels. + final double scrimWidth; + + @override + State createState() => EdgeFadeState(); +} + +class EdgeFadeState extends State { + bool _fadeStart = false; + bool _fadeEnd = false; + + /// Whether the physical left edge currently shows a scroll cue. + @visibleForTesting + bool get fadeLeft => _isRtl ? _fadeEnd : _fadeStart; + + /// Whether the physical right edge currently shows a scroll cue. + @visibleForTesting + bool get fadeRight => _isRtl ? _fadeStart : _fadeEnd; + + bool get _isRtl => Directionality.of(context) == TextDirection.rtl; + + bool _onMetrics(ScrollMetrics metrics) { + if (metrics.axis != Axis.horizontal) return false; + final start = metrics.extentBefore > 0.5; + final end = metrics.extentAfter > 0.5; + if (start != _fadeStart || end != _fadeEnd) { + setState(() { + _fadeStart = start; + _fadeEnd = end; + }); + } + return false; + } + + @override + Widget build(BuildContext context) { + final surface = Theme.of(context).scaffoldBackgroundColor; + return NotificationListener( + onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false, + child: NotificationListener( + onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false, + child: Stack( + children: [ + widget.child, + if (fadeLeft) + _EdgeCue( + left: true, + width: widget.scrimWidth, + surface: surface, + ), + if (fadeRight) + _EdgeCue( + left: false, + width: widget.scrimWidth, + surface: surface, + ), + ], + ), + ), + ); + } +} + +/// A gradient scrim over one physical edge of the row, opaque at the very edge +/// and fading inward, with a chevron pointing outward toward the hidden chips. +/// Ignores pointers so taps still reach the chip underneath. +class _EdgeCue extends StatelessWidget { + const _EdgeCue({ + required this.left, + required this.width, + required this.surface, + }); + + final bool left; + final double width; + final Color surface; + + @override + Widget build(BuildContext context) { + return Positioned( + top: 0, + bottom: 0, + left: left ? 0 : null, + right: left ? null : 0, + width: width, + child: IgnorePointer( + child: Container( + alignment: left ? Alignment.centerLeft : Alignment.centerRight, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: left ? Alignment.centerLeft : Alignment.centerRight, + end: left ? Alignment.centerRight : Alignment.centerLeft, + colors: [surface, surface.withValues(alpha: 0)], + ), + ), + child: Icon( + left ? Icons.chevron_left : Icons.chevron_right, + size: 22, + color: seedMuted, + ), + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/favorites_screen.dart b/apps/app_seeds/lib/ui/favorites_screen.dart new file mode 100644 index 0000000..0d1e3b8 --- /dev/null +++ b/apps/app_seeds/lib/ui/favorites_screen.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../i18n/strings.g.dart'; +import '../services/saved_offers_store.dart'; +import '../services/social_connection.dart'; +import 'market_widgets.dart'; +import 'theme.dart'; + +/// The Wallapop-style "Favorites": offers from other people the user saved. +/// Shows the saved snapshots offline-first; when a relay is reachable it checks +/// which ones are still published and flags the rest as no longer available +/// (they may have been withdrawn or expired). Tapping opens the offer detail. +class FavoritesScreen extends StatefulWidget { + const FavoritesScreen({ + required this.savedOffers, + this.connection, + super.key, + }); + + final SavedOffersStore savedOffers; + + /// The shared relay connection, used to check whether saved offers still + /// exist. Null (tests / no social layer) → no check, snapshots shown as-is. + final SocialConnection? connection; + + @override + State createState() => _FavoritesScreenState(); +} + +class _FavoritesScreenState extends State { + List? _offers; + var _liveKeys = {}; + var _checked = false; + StreamSubscription? _changesSub; + + @override + void initState() { + super.initState(); + _changesSub = widget.savedOffers.changes.listen((_) => _load()); + _load(); + } + + @override + void dispose() { + _changesSub?.cancel(); + super.dispose(); + } + + Future _load() async { + final offers = await widget.savedOffers.list(); + if (!mounted) return; + setState(() => _offers = offers); + unawaited(_check(offers)); + } + + /// Asks the relay which saved offers are still out there. Offline (no session) + /// leaves [_checked] false, so nothing is wrongly flagged as gone. + Future _check(List offers) async { + final connection = widget.connection; + if (connection == null || offers.isEmpty) return; + final session = await connection.session(); + if (session == null || !mounted) return; + + final live = {}; + final geohashes = { + for (final o in offers) + if (o.approxGeohash.isNotEmpty) o.approxGeohash, + }; + final subs = >[]; + for (final geohash in geohashes) { + subs.add( + session.offers + .discover(DiscoveryQuery(geohashPrefix: geohash)) + .listen((o) => live.add(SavedOffersStore.keyOf(o)), onError: (_) {}), + ); + } + // The discover streams stay open for live offers and never signal "done"; + // give them a beat to deliver what's stored, then take stock. + await Future.delayed(const Duration(seconds: 5)); + for (final s in subs) { + unawaited(s.cancel()); + } + if (!mounted) return; + setState(() { + _liveKeys = live; + _checked = true; + }); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final offers = _offers; + return Scaffold( + appBar: AppBar(title: Text(t.favorites.title)), + body: offers == null + ? const Center(child: CircularProgressIndicator()) + : offers.isEmpty + ? _Empty(text: t.favorites.empty) + : ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: offers.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, i) { + final offer = offers[i]; + final key = SavedOffersStore.keyOf(offer); + // Only call something gone once a check actually ran and didn't + // see it — never while offline (checked == false). + final gone = _checked && !_liveKeys.contains(key); + return _FavoriteCard( + offer: offer, + unavailable: gone, + onOpen: () => context.push('/market/offer', extra: offer), + onRemove: () => widget.savedOffers.remove(key), + ); + }, + ), + ); + } +} + +class _FavoriteCard extends StatelessWidget { + const _FavoriteCard({ + required this.offer, + required this.unavailable, + required this.onOpen, + required this.onRemove, + }); + + final Offer offer; + final bool unavailable; + final VoidCallback onOpen; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final t = context.t; + final radius = BorderRadius.circular(14); + return Material( + color: Colors.white, + borderRadius: radius, + child: InkWell( + key: Key('favorites.offer.${offer.authorPubkeyHex}.${offer.id}'), + onTap: onOpen, + borderRadius: radius, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: seedOutline), + ), + padding: const EdgeInsets.all(16), + child: Opacity( + opacity: unavailable ? 0.55 : 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (offer.imageUrl != null) ...[ + OfferThumbnail( + url: offer.imageUrl!, + semanticLabel: t.market.photo, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + offer.summary, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: seedOnSurface, + ), + ), + ), + IconButton( + key: Key('favorites.remove.${offer.authorPubkeyHex}' + '.${offer.id}'), + icon: const Icon(Icons.favorite, color: seedFavorite), + tooltip: t.favorites.remove, + onPressed: onRemove, + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + if (unavailable) + Row( + children: [ + const Icon(Icons.cloud_off, + size: 15, color: seedMuted), + const SizedBox(width: 4), + Text( + t.favorites.unavailable, + style: + const TextStyle(color: seedMuted, fontSize: 13), + ), + ], + ) + else + OfferTypeChip(label: offerTypeLabel(t, offer.type)), + const Spacer(), + if (offer.type == OfferType.sale && offer.priceAmount != null) + Text( + '${offer.priceAmount} ${offer.priceCurrency ?? ''}' + .trim(), + style: const TextStyle( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.favorite_border, + size: 64, + color: seedGreen.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + text, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/filter_chips.dart b/apps/app_seeds/lib/ui/filter_chips.dart new file mode 100644 index 0000000..5599de1 --- /dev/null +++ b/apps/app_seeds/lib/ui/filter_chips.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// A filter chip for the horizontal filter rows: light with dark ink when idle, +/// a solid green fill with white content when selected — no checkmark, since the +/// fill already reads as "on" and a tick over the leading icon just muddles it. +/// White on [seedGreen] meets WCAG AA (see test/ui/theme_contrast_test.dart). +class PlainFilterChip extends StatelessWidget { + const PlainFilterChip({ + required this.label, + required this.selected, + required this.onSelected, + this.icon, + super.key, + }); + + final String label; + final bool selected; + final ValueChanged onSelected; + + /// Optional leading icon (attribute chips carry one; type/category chips do + /// not). + final IconData? icon; + + @override + Widget build(BuildContext context) { + final fg = selected ? Colors.white : seedOnSurface; + return FilterChip( + avatar: icon == null + ? null + : Icon(icon, size: 18, color: selected ? Colors.white : seedGreen), + label: Text(label), + selected: selected, + onSelected: onSelected, + showCheckmark: false, + selectedColor: seedGreen, + labelStyle: TextStyle( + color: fg, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/handover_sheet.dart b/apps/app_seeds/lib/ui/handover_sheet.dart new file mode 100644 index 0000000..36c9440 --- /dev/null +++ b/apps/app_seeds/lib/ui/handover_sheet.dart @@ -0,0 +1,346 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import '../i18n/strings.g.dart'; +import 'currency_quick_picks.dart'; +import 'harvest_date_picker.dart'; +import 'quantity_kind_l10n.dart'; +import 'quantity_picker.dart'; +import 'theme.dart'; + +/// Opens the hand-over sheet — the ONE place to note that seeds changed hands +/// (a gift, a swap, a sale…), optionally with money and/or a return promise. +/// It records the Movement and spawns the Sale/Plantare rows in one save +/// (data-model §2.8), so the person never juggles three separate forms. +/// +/// [lots] are the variety's live batches: one → preselected; several → a +/// chip picker; none → the quantity section hides and only the optional +/// records are saved. [initialLot] preselects a batch (entry from a lot row). +Future showHandoverSheet( + BuildContext context, { + required VarietyRepository repository, + required String varietyId, + List lots = const [], + VarietyLot? initialLot, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _HandoverSheet( + repository: repository, + varietyId: varietyId, + lots: lots, + initialLot: initialLot ?? (lots.length == 1 ? lots.single : null), + ), + ); +} + +class _HandoverSheet extends StatefulWidget { + const _HandoverSheet({ + required this.repository, + required this.varietyId, + required this.lots, + this.initialLot, + }); + + final VarietyRepository repository; + final String varietyId; + final List lots; + final VarietyLot? initialLot; + + @override + State<_HandoverSheet> createState() => _HandoverSheetState(); +} + +class _HandoverSheetState extends State<_HandoverSheet> { + HandoverDirection _direction = HandoverDirection.iGave; + VarietyLot? _lot; + bool _gaveAll = true; + Quantity? _partialQuantity; + final _counterparty = TextEditingController(); + bool _withPayment = false; + final _amount = TextEditingController(); + final _currency = TextEditingController(); + bool _withPromise = false; + final _owed = TextEditingController(); + bool _saving = false; + + @override + void initState() { + super.initState(); + _lot = widget.initialLot; + } + + @override + void dispose() { + _counterparty.dispose(); + _amount.dispose(); + _currency.dispose(); + _owed.dispose(); + super.dispose(); + } + + String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); + + String _lotLabel(Translations t, VarietyLot lot) { + final parts = [ + if (lot.harvestYear != null) + harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth) + else + t.detail.noYear, + if (lot.quantity != null) quantityDisplay(t, lot.quantity!), + ]; + return parts.join(' · '); + } + + Future _save() async { + setState(() => _saving = true); + final counterparty = _nullIfBlank(_counterparty.text); + final amount = double.tryParse(_amount.text.trim().replaceAll(',', '.')); + final currency = _nullIfBlank(_currency.text); + final owed = _nullIfBlank(_owed.text); + final lot = _lot; + if (lot != null) { + await widget.repository.recordHandover( + lotId: lot.id, + direction: _direction, + gaveAll: _gaveAll, + quantity: _gaveAll ? null : _partialQuantity, + counterparty: counterparty, + withPayment: _withPayment, + paymentAmount: amount, + paymentCurrency: currency, + withPromise: _withPromise, + promiseOwedDescription: owed, + ); + } else { + // No batch to move — record just the ledgers the person asked for. + if (_withPayment) { + await widget.repository.createSale( + direction: _direction == HandoverDirection.iGave + ? SaleDirection.iSold + : SaleDirection.iBought, + varietyId: widget.varietyId, + counterparty: counterparty, + amount: amount, + currency: currency, + ); + } + if (_withPromise) { + await widget.repository.createPlantare( + direction: _direction == HandoverDirection.iGave + ? PlantareDirection.owedToMe + : PlantareDirection.iReturn, + varietyId: widget.varietyId, + counterparty: counterparty, + owedDescription: owed, + ); + } + } + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final gave = _direction == HandoverDirection.iGave; + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.handover.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + t.handover.help, + style: const TextStyle(color: seedMuted, fontSize: 13), + ), + const SizedBox(height: 12), + // Two big, human choices — same idiom as the plantare sheet. + for (final d in HandoverDirection.values) + ListTile( + key: Key('handover.direction.${d.name}'), + contentPadding: EdgeInsets.zero, + leading: Icon( + _direction == d + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: _direction == d ? seedGreen : seedMuted, + ), + title: Text( + d == HandoverDirection.iGave + ? t.handover.iGave + : t.handover.iReceived, + ), + onTap: () => setState(() => _direction = d), + ), + if (widget.lots.length > 1) ...[ + const SizedBox(height: 8), + Text( + t.handover.whichLot, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final lot in widget.lots) + ChoiceChip( + key: Key('handover.lot.${lot.id}'), + label: Text(_lotLabel(t, lot)), + selected: _lot?.id == lot.id, + onSelected: (sel) => + setState(() => _lot = sel ? lot : null), + ), + ], + ), + ], + if (_lot != null && gave) ...[ + const SizedBox(height: 8), + Text( + t.handover.howMuch, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + ChoiceChip( + key: const Key('handover.allOfIt'), + label: Text(t.handover.allOfIt), + selected: _gaveAll, + onSelected: (sel) => setState(() => _gaveAll = true), + ), + ChoiceChip( + key: const Key('handover.partOfIt'), + label: Text(t.handover.partOfIt), + selected: !_gaveAll, + onSelected: (sel) => setState(() => _gaveAll = false), + ), + ], + ), + if (!_gaveAll) ...[ + const SizedBox(height: 8), + QuantityPicker( + type: _lot!.type, + value: _partialQuantity, + onChanged: (q) => _partialQuantity = q, + ), + ], + ], + const SizedBox(height: 12), + TextField( + key: const Key('handover.counterparty'), + controller: _counterparty, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: t.sale.counterparty, + helperText: t.sale.counterpartyHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + // Money and promise stay one tap away — a plain gift needs neither. + Wrap( + spacing: 8, + children: [ + if (!_withPayment) + ActionChip( + key: const Key('handover.addPayment'), + avatar: const Icon(Icons.sell_outlined, size: 18), + label: Text(t.handover.paymentChip), + onPressed: () => setState(() => _withPayment = true), + ), + if (!_withPromise) + ActionChip( + key: const Key('handover.addPromise'), + avatar: const Icon( + Icons.volunteer_activism_outlined, + size: 18, + ), + label: Text( + gave + ? t.handover.promiseGave + : t.handover.promiseReceived, + ), + onPressed: () => setState(() => _withPromise = true), + ), + ], + ), + if (_withPayment) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + key: const Key('handover.amount'), + controller: _amount, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + labelText: t.sale.amount, + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + key: const Key('handover.currency'), + controller: _currency, + decoration: InputDecoration( + labelText: t.sale.currency, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 8), + CurrencyQuickPicks( + controller: _currency, + keyPrefix: 'handover', + onChanged: () => setState(() {}), + ), + ], + if (_withPromise) ...[ + const SizedBox(height: 12), + TextField( + key: const Key('handover.owed'), + controller: _owed, + decoration: InputDecoration( + labelText: t.plantare.owed, + helperText: t.plantare.owedHint, + helperMaxLines: 2, + border: const OutlineInputBorder(), + ), + ), + ], + const SizedBox(height: 20), + FilledButton( + key: const Key('handover.save'), + onPressed: _saving ? null : _save, + child: Text(t.common.save), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/home_screen.dart b/apps/app_seeds/lib/ui/home_screen.dart index 42cb960..adf4aba 100644 --- a/apps/app_seeds/lib/ui/home_screen.dart +++ b/apps/app_seeds/lib/ui/home_screen.dart @@ -5,27 +5,51 @@ import '../i18n/strings.g.dart'; import 'app_drawer.dart'; import 'seed_glyph.dart'; import 'theme.dart'; +import 'unread_badge.dart'; /// The main menu (redesign screen 00): a sprout logo in a soft green disc over a /// faint seed-glyph watermark, with "Your inventory" as the primary green call /// to action and "Open market" (Block 2) as a disabled outlined card. The /// hamburger opens [AppDrawer]. class HomeScreen extends StatelessWidget { - const HomeScreen({super.key}); + const HomeScreen({this.marketEnabled = false, super.key}); + + /// When the Block 2 social layer is wired, the market becomes a live + /// destination; otherwise it stays a disabled "coming soon" card. + final bool marketEnabled; @override Widget build(BuildContext context) { final t = context.t; return Scaffold( - appBar: AppBar(title: Text(t.app.title)), - drawer: const AppDrawer(), + appBar: AppBar( + title: Text(t.app.title), + // Badge the hamburger so unread messages are visible from home, without + // opening the drawer. Total count across conversations. + leading: Builder( + builder: (context) => IconButton( + icon: const UnreadBadge(child: Icon(Icons.menu)), + tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), + ), + drawer: AppDrawer(marketEnabled: marketEnabled), body: Stack( children: [ const Positioned.fill(child: _SeedWatermark()), - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 440), - child: Padding( + // Scroll when the menu doesn't fit a short screen (small phones), so + // the market card / buttons are never clipped; stays centered when it + // does fit. + LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), child: Column( mainAxisSize: MainAxisSize.min, @@ -73,20 +97,27 @@ class HomeScreen extends StatelessWidget { icon: Icons.inventory_2_outlined, label: t.home.yourInventory, subtitle: t.home.yourInventorySubtitle, - onTap: () => context.go('/inventory'), + onTap: () => context.push('/inventory'), ), const SizedBox(height: 16), _OutlinedMenuCard( + key: const Key('home.market'), icon: Icons.storefront_outlined, label: t.home.openMarket, subtitle: t.home.openMarketSubtitle, - tag: t.common.comingSoon, + tag: marketEnabled ? null : t.common.comingSoon, + onTap: marketEnabled + ? () => context.push('/market') + : null, ), ], ), ), ), ), + ), + ), + ), ], ), ); @@ -151,17 +182,24 @@ class _OutlinedMenuCard extends StatelessWidget { required this.icon, required this.label, required this.subtitle, - required this.tag, + this.tag, + this.onTap, + super.key, }); final IconData icon; final String label; final String subtitle; - final String tag; + + /// A small trailing tag (e.g. "coming soon"); omitted for live cards. + final String? tag; + + /// When set, the card is tappable; otherwise it reads as disabled. + final VoidCallback? onTap; @override Widget build(BuildContext context) { - return Container( + final card = Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), @@ -183,18 +221,31 @@ class _OutlinedMenuCard extends StatelessWidget { subtitleColor: seedMuted, ), ), - Text( - tag.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB3BDA8), - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), - ), + if (tag != null) + Text( + tag!.toUpperCase(), + style: const TextStyle( + color: Color(0xFFB3BDA8), + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ) + else if (onTap != null) + const Icon(Icons.chevron_right, color: seedMuted), ], ), ); + if (onTap == null) return card; + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(18), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: card, + ), + ); } } diff --git a/apps/app_seeds/lib/ui/intro_screen.dart b/apps/app_seeds/lib/ui/intro_screen.dart index 40864ae..b56d82f 100644 --- a/apps/app_seeds/lib/ui/intro_screen.dart +++ b/apps/app_seeds/lib/ui/intro_screen.dart @@ -26,7 +26,7 @@ class _Slide { } /// The first-run intro carousel (also reachable later from the drawer): a few -/// swipeable cards explaining what Tanemaki is, how privacy works, how sharing +/// swipeable cards explaining what Tane is, how privacy works, how sharing /// works and the Plantare. [onDone] fires when the user skips or finishes, and /// is where the caller marks the intro as seen and leaves the screen. class IntroScreen extends StatefulWidget { diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index ac8282e..11584ba 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -9,8 +9,11 @@ import '../domain/seed_viability.dart'; import '../i18n/strings.g.dart'; import '../services/share_catalog_service.dart'; import '../state/inventory_cubit.dart'; -import 'app_drawer.dart'; +import 'category_palette.dart'; import 'draft_triage.dart'; +import 'edge_fade.dart'; +import 'filter_chips.dart'; +import 'label_print_sheet.dart'; import 'quantity_kind_l10n.dart'; import 'quantity_picker.dart'; import 'quick_add_sheet.dart'; @@ -26,89 +29,170 @@ class InventoryListScreen extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; - return Scaffold( - appBar: AppBar( - title: Text(t.inventory.title), - actions: [ - // The paper bridge: only offered once something is marked to share. - BlocSelector( - selector: (state) => state.hasShared, - builder: (context, hasShared) => hasShared - ? IconButton( - key: const Key('inventory.printCatalog'), - icon: const Icon(Icons.print_outlined), - tooltip: t.share.printCatalog, - onPressed: () => _printCatalog(context), - ) - : const SizedBox.shrink(), - ), - IconButton( - key: const Key('inventory.captureBurst'), - icon: const Icon(Icons.add_a_photo_outlined), - tooltip: t.draft.capture, - onPressed: () => _captureBurst(context), - ), - ], - ), - drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton( - key: const Key('inventory.addFab'), - tooltip: t.quickAdd.title, - onPressed: () => showQuickAddSheet( - context, - repository: context.read(), - ), - child: const Icon(Icons.add), - ), - body: BlocBuilder( - builder: (context, state) { - if (state.loading) { - return const Center(child: CircularProgressIndicator()); - } - return Column( - children: [ - if (state.drafts.isNotEmpty) - _TriageBanner(count: state.drafts.length), - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), - child: TextField( - key: const Key('inventory.search'), - decoration: InputDecoration( - hintText: t.inventory.searchHint, - prefixIcon: const Icon(Icons.search, color: seedMuted), - hintStyle: const TextStyle(color: seedMuted), - filled: true, - fillColor: Colors.white, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, - ), + return BlocBuilder( + builder: (context, state) { + return Scaffold( + appBar: state.selectionMode + ? _selectionAppBar(context, state) + : _defaultAppBar(context, state), + // Inventory is a spoke off the home hub (no drawer — the app bar's + // back arrow returns there). In selection mode the bars are + // contextual and the add FAB steps aside. + floatingActionButton: state.selectionMode + ? null + : FloatingActionButton( + key: const Key('inventory.addFab'), + tooltip: t.quickAdd.title, + onPressed: () => showQuickAddSheet( + context, + repository: context.read(), ), - onChanged: context.read().search, + child: const Icon(Icons.add), ), - ), - _FilterBar(state: state), - Expanded( - child: _InventoryBody( - items: state.visibleItems, - // Distinguish "no seeds at all" from "filters hid them all". - filtered: - state.categoryFilter.isNotEmpty || - state.typeFilter.isNotEmpty || - state.organicOnly || - state.needsReproductionOnly || - state.sharingOnly, + body: state.error != null + ? _LoadError(onRetry: context.read().retry) + : state.loading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + if (state.drafts.isNotEmpty && !state.selectionMode) + _TriageBanner(count: state.drafts.length), + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: TextField( + key: const Key('inventory.search'), + decoration: InputDecoration( + hintText: t.inventory.searchHint, + prefixIcon: const Icon( + Icons.search, + color: seedMuted, + ), + hintStyle: const TextStyle(color: seedMuted), + filled: true, + fillColor: Colors.white, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + ), + onChanged: context.read().search, + ), + ), + _FilterBar(state: state), + Expanded( + child: _InventoryBody( + items: state.visibleItems, + selectionMode: state.selectionMode, + selectedIds: state.selectedIds, + // Distinguish "no seeds at all" from "search/filters + // hid them" — so an empty result reads "no matches". + filtered: + state.query.trim().isNotEmpty || + state.categoryFilter.isNotEmpty || + state.typeFilter.isNotEmpty || + state.organicOnly || + state.needsReproductionOnly || + state.sharingOnly || + state.sowThisMonthOnly, + ), + ), + ], ), - ), - ], - ); - }, - ), + ); + }, ); } + /// The normal top bar: print-what-I-share, enter label selection, capture. + PreferredSizeWidget _defaultAppBar( + BuildContext context, + InventoryState state, + ) { + final t = context.t; + return AppBar( + title: Text(t.inventory.title), + actions: [ + // The paper bridge: only offered once something is marked to share. + if (state.hasShared) + IconButton( + key: const Key('inventory.printCatalog'), + icon: const Icon(Icons.print_outlined), + tooltip: t.share.printCatalog, + onPressed: () => _printCatalog(context), + ), + // Enter multi-select to print labels — only once there's something to + // label. + if (state.items.isNotEmpty) + IconButton( + key: const Key('inventory.selectLabels'), + icon: const Icon(Icons.label_outline), + tooltip: t.printLabels.action, + onPressed: context.read().startSelection, + ), + IconButton( + key: const Key('inventory.captureBurst'), + icon: const Icon(Icons.add_a_photo_outlined), + tooltip: t.draft.capture, + onPressed: () => _captureBurst(context), + ), + ], + ); + } + + /// The contextual bar shown while picking seeds to print labels for. + PreferredSizeWidget _selectionAppBar( + BuildContext context, + InventoryState state, + ) { + final t = context.t; + final cubit = context.read(); + return AppBar( + leading: IconButton( + key: const Key('inventory.selection.close'), + icon: const Icon(Icons.close), + tooltip: t.common.cancel, + onPressed: cubit.exitSelection, + ), + title: Text(t.printLabels.selected(n: state.selectedIds.length)), + actions: [ + TextButton( + key: const Key('inventory.selection.selectAll'), + onPressed: cubit.selectAllVisible, + child: Text(t.printLabels.selectAll), + ), + IconButton( + key: const Key('inventory.selection.print'), + icon: const Icon(Icons.label_outline), + tooltip: t.printLabels.action, + onPressed: state.selectedIds.isEmpty + ? null + : () => _printLabels(context, state.selectedIds), + ), + ], + ); + } + + /// Gathers the label rows for the selection and opens the print sheet. + Future _printLabels(BuildContext context, Set ids) async { + final t = context.t; + final repository = context.read(); + final messenger = ScaffoldMessenger.of(context); + final entries = await repository.labelRows( + ids, + languageCode: LocaleSettings.currentLocale.languageCode, + ); + if (!context.mounted) return; + if (entries.isEmpty) { + messenger.showSnackBar(SnackBar(content: Text(t.printLabels.none))); + return; + } + await showLabelPrintSheet(context, entries); + } + /// Assembles the localized rows of the "what I share" catalog and saves it /// as a PDF wherever the user chooses. Future _printCatalog(BuildContext context) async { @@ -131,7 +215,7 @@ class InventoryListScreen extends StatelessWidget { title: t.share.catalogTitle, date: dateLabel, suggestedName: - 'tanemaki-catalog-${date.toIso8601String().substring(0, 10)}.pdf', + 'tane-catalog-${date.toIso8601String().substring(0, 10)}.pdf', rows: rows, ); messenger.showSnackBar( @@ -164,6 +248,38 @@ class InventoryListScreen extends StatelessWidget { /// A tappable banner announcing how many photo-first captures are waiting to be /// named. Opens the "to catalogue" tray. +/// Shown when the inventory stream fails to open — instead of an endless +/// spinner, offer a clear message and a way to try again. +class _LoadError extends StatelessWidget { + const _LoadError({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off_outlined, size: 48, color: seedGreen), + const SizedBox(height: 16), + Text(t.inventory.loadError, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + key: const Key('inventory.retry'), + onPressed: onRetry, + child: Text(t.inventory.retry), + ), + ], + ), + ), + ); + } +} + class _TriageBanner extends StatelessWidget { const _TriageBanner({required this.count}); @@ -212,64 +328,73 @@ class _FilterBar extends StatelessWidget { final hasNeedsReproduction = state.hasNeedsReproduction; // Only offer the "I share" chip when some lot is marked to share. final hasShared = state.hasShared; + // Only offer the "to sow this month" chip when some variety has a calendar. + final hasSowCalendar = state.hasSowCalendar; if (categories.isEmpty && forms.isEmpty && !hasOrganic && !hasNeedsReproduction && - !hasShared) { + !hasShared && + !hasSowCalendar) { return const SizedBox.shrink(); } - final chips = [ + // Filters read as three colour-coded groups, separated by a thin rule: + // attributes (green icons) · forms (per-form tonality) · families (each a + // stable earthy tonality). Grouping + colour makes a long row scannable. + final attrChips = [ + if (hasSowCalendar) + PlainFilterChip( + key: const Key('inventory.filter.sowThisMonth'), + icon: Icons.calendar_month, + label: t.calendar.filterChip, + selected: state.sowThisMonthOnly, + onSelected: (_) => cubit.toggleSowThisMonth(), + ), if (hasShared) - FilterChip( + PlainFilterChip( key: const Key('inventory.filter.sharing'), - avatar: Icon( - Icons.volunteer_activism_outlined, - size: 18, - color: state.sharingOnly ? null : seedGreen, - ), - label: Text(t.share.filterChip), + icon: Icons.volunteer_activism_outlined, + label: t.share.filterChip, selected: state.sharingOnly, onSelected: (_) => cubit.toggleSharingOnly(), ), if (hasOrganic) - FilterChip( + PlainFilterChip( key: const Key('inventory.filter.organic'), - avatar: Icon( - Icons.eco, - size: 18, - color: state.organicOnly ? null : seedGreen, - ), - label: Text(t.editVariety.organic), + icon: Icons.eco, + label: t.editVariety.organic, selected: state.organicOnly, onSelected: (_) => cubit.toggleOrganicOnly(), ), if (hasNeedsReproduction) - FilterChip( + PlainFilterChip( key: const Key('inventory.filter.needsReproduction'), - avatar: Icon( - Icons.autorenew, - size: 18, - color: state.needsReproductionOnly ? null : seedGreen, - ), - label: Text(t.inventory.needsReproductionFilter), + icon: Icons.autorenew, + label: t.inventory.needsReproductionFilter, selected: state.needsReproductionOnly, onSelected: (_) => cubit.toggleNeedsReproductionOnly(), ), - for (final category in categories) - FilterChip( - key: Key('inventory.filter.category.$category'), - label: Text(category), - selected: state.categoryFilter.contains(category), - onSelected: (_) => cubit.toggleCategory(category), - ), + ]; + final formChips = [ for (final form in forms) - FilterChip( + _SwatchFilterChip( key: Key('inventory.filter.type.${form.name}'), - label: Text(lotTypeLabel(t, form)), + swatch: lotTypeSwatch(form), + label: lotTypeLabel(t, form), + icon: iconForLotType(form), selected: state.typeFilter.contains(form), - onSelected: (_) => cubit.toggleType(form), + onSelected: () => cubit.toggleType(form), + ), + ]; + final categoryChips = [ + for (final category in categories) + _SwatchFilterChip( + key: Key('inventory.filter.category.$category'), + swatch: categorySwatch(category), + label: category, + selected: state.categoryFilter.contains(category), + onSelected: () => cubit.toggleCategory(category), ), ]; final hasActiveFilter = @@ -277,33 +402,120 @@ class _FilterBar extends StatelessWidget { state.typeFilter.isNotEmpty || state.organicOnly || state.needsReproductionOnly || - state.sharingOnly; + state.sharingOnly || + state.sowThisMonthOnly; - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - for (final chip in chips) - Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: chip, - ), - if (hasActiveFilter) - TextButton.icon( - key: const Key('inventory.filter.clear'), - onPressed: cubit.clearFilters, - icon: const Icon(Icons.clear, size: 18), - label: Text(t.inventory.clearFilters), - ), - ], + // Lay out the groups in order, dropping a hairline divider between any two + // that both have chips. + final groups = [ + attrChips, + formChips, + categoryChips, + ].where((g) => g.isNotEmpty); + final row = []; + for (final group in groups) { + if (row.isNotEmpty) row.add(const _FilterGroupDivider()); + for (final chip in group) { + row.add( + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: chip, + ), + ); + } + } + final scroller = EdgeFade( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row(children: row), ), ); + if (!hasActiveFilter) return scroller; + + // Give the chips the full width, and drop "clear filters" onto its own + // line below — pinning it beside the scroll squeezed the chips and clipped + // them under the edge fade. + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + scroller, + Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: TextButton.icon( + key: const Key('inventory.filter.clear'), + onPressed: cubit.clearFilters, + icon: const Icon(Icons.filter_alt_off_outlined, size: 18), + label: Text(t.inventory.clearFilters), + ), + ), + ], + ); + } +} + +/// A thin vertical rule separating filter groups in the horizontal chip row. +class _FilterGroupDivider extends StatelessWidget { + const _FilterGroupDivider(); + + @override + Widget build(BuildContext context) => Container( + width: 1, + height: 22, + margin: const EdgeInsetsDirectional.only(end: 8), + color: seedOutline, + ); +} + +/// A [FilterChip] tinted with a [Swatch]: a soft fill when idle, a stronger +/// same-hue fill when selected, with matching ink for the label, border, +/// checkmark and optional leading [icon]. +class _SwatchFilterChip extends StatelessWidget { + const _SwatchFilterChip({ + required this.swatch, + required this.label, + required this.selected, + required this.onSelected, + this.icon, + super.key, + }); + + final Swatch swatch; + final String label; + final bool selected; + final VoidCallback onSelected; + final IconData? icon; + + @override + Widget build(BuildContext context) { + // Idle: a soft same-hue fill with dark ink. Selected: the ink becomes the + // fill and the content turns white, so the chip clearly reads as "on" while + // keeping its family/form hue. White on every ink meets WCAG AA — see + // test/ui/category_palette_contrast_test.dart. + final ink = selected ? Colors.white : swatch.ink; + return FilterChip( + label: Text(label), + avatar: icon == null ? null : Icon(icon, size: 18, color: ink), + selected: selected, + onSelected: (_) => onSelected(), + showCheckmark: false, + backgroundColor: swatch.fill, + selectedColor: swatch.ink, + side: BorderSide( + color: swatch.ink.withValues(alpha: selected ? 0 : 0.35), + ), + labelStyle: TextStyle(color: ink, fontWeight: FontWeight.w500), + ); } } class _InventoryBody extends StatelessWidget { - const _InventoryBody({required this.items, this.filtered = false}); + const _InventoryBody({ + required this.items, + this.filtered = false, + this.selectionMode = false, + this.selectedIds = const {}, + }); final List items; @@ -311,6 +523,12 @@ class _InventoryBody extends StatelessWidget { /// than "no seeds yet". final bool filtered; + /// Whether the list is in multi-select mode (tiles show a checkbox). + final bool selectionMode; + + /// Ids currently selected, so each tile can reflect its checkbox state. + final Set selectedIds; + @override Widget build(BuildContext context) { final t = context.t; @@ -348,7 +566,13 @@ class _InventoryBody extends StatelessWidget { currentCategory = category; rows.add(_CategoryHeader(title: category)); } - rows.add(_VarietyTile(item: item)); + rows.add( + _VarietyTile( + item: item, + selectionMode: selectionMode, + selected: selectedIds.contains(item.id), + ), + ); } return ListView(children: rows); } @@ -361,30 +585,68 @@ class _CategoryHeader extends StatelessWidget { @override Widget build(BuildContext context) { + // Same tonality as this family's filter chip, so header and chip read as a + // pair. The uncategorised bucket keeps the plain green. + final swatch = categorySwatch(title); return Padding( padding: const EdgeInsets.fromLTRB(16, 18, 16, 6), - // Base on the text theme so it honours the system font-scale factor. - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, - color: seedGreen, - ), + child: Row( + children: [ + Container( + width: 10, + height: 10, + margin: const EdgeInsetsDirectional.only(end: 8), + decoration: BoxDecoration( + color: swatch.ink, + shape: BoxShape.circle, + ), + ), + Flexible( + // Base on the text theme so it honours the system font-scale factor. + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: swatch.ink, + ), + ), + ), + ], ), ); } } class _VarietyTile extends StatelessWidget { - const _VarietyTile({required this.item}); + const _VarietyTile({ + required this.item, + this.selectionMode = false, + this.selected = false, + }); final VarietyListItem item; + /// Whether the list is picking seeds to print labels for: tap toggles the + /// selection instead of opening the detail, and the tile shows a checkbox. + final bool selectionMode; + final bool selected; + @override Widget build(BuildContext context) { + final cubit = context.read(); void open() => context.push('/variety/${item.id}'); + void toggle() => cubit.toggleSelection(item.id); + return ListTile( - leading: _Avatar(item: item), + key: Key('inventory.tile.${item.id}'), + selected: selectionMode && selected, + leading: selectionMode + ? Checkbox( + key: Key('inventory.select.${item.id}'), + value: selected, + onChanged: (_) => toggle(), + ) + : _Avatar(item: item), title: Text( item.label, style: const TextStyle(fontWeight: FontWeight.bold), @@ -395,42 +657,47 @@ class _VarietyTile extends StatelessWidget { item.scientificName!, style: const TextStyle(fontStyle: FontStyle.italic), ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (item.isShared) - Padding( - padding: const EdgeInsetsDirectional.only(end: 4), - child: Tooltip( - key: Key('inventory.shared.${item.id}'), - message: context.t.share.filterChip, - child: const Icon( - Icons.volunteer_activism_outlined, - size: 20, + // In selection mode the trailing actions (which navigate away) give way to + // the checkbox flow. + trailing: selectionMode + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.isShared) + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Tooltip( + key: Key('inventory.shared.${item.id}'), + message: context.t.share.filterChip, + child: const Icon( + Icons.volunteer_activism_outlined, + size: 20, + color: seedGreen, + ), + ), + ), + if (item.isOrganic) + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Tooltip( + key: Key('inventory.organic.${item.id}'), + message: context.t.editVariety.organic, + child: const Icon(Icons.eco, size: 20, color: seedGreen), + ), + ), + _ViabilityDot(item.viability), + IconButton( + icon: const Icon(Icons.edit_outlined), + // Action colour: this is a tap target, not secondary text. color: seedGreen, + tooltip: context.t.common.edit, + onPressed: open, ), - ), + ], ), - if (item.isOrganic) - Padding( - padding: const EdgeInsetsDirectional.only(end: 4), - child: Tooltip( - key: Key('inventory.organic.${item.id}'), - message: context.t.editVariety.organic, - child: const Icon(Icons.eco, size: 20, color: seedGreen), - ), - ), - _ViabilityDot(item.viability), - IconButton( - icon: const Icon(Icons.edit_outlined), - // Action colour: this is a tap target, not secondary text. - color: seedGreen, - tooltip: context.t.common.edit, - onPressed: open, - ), - ], - ), - onTap: open, + onTap: selectionMode ? toggle : open, + onLongPress: selectionMode ? null : () => cubit.enterSelection(item.id), ); } } @@ -485,7 +752,7 @@ class _Avatar extends StatelessWidget { final trimmed = item.label.trim(); final initial = trimmed.isEmpty ? '?' - : trimmed.substring(0, 1).toUpperCase(); + : trimmed.characters.first.toUpperCase(); return ExcludeSemantics( child: CircleAvatar( backgroundColor: seedAvatar, diff --git a/apps/app_seeds/lib/ui/label_print_sheet.dart b/apps/app_seeds/lib/ui/label_print_sheet.dart new file mode 100644 index 0000000..5459cfc --- /dev/null +++ b/apps/app_seeds/lib/ui/label_print_sheet.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../data/export_import/seed_label_codec.dart'; +import '../data/variety_repository.dart'; +import '../di/injector.dart'; +import '../i18n/strings.g.dart'; +import '../services/label_sheet_service.dart'; +import 'quantity_kind_l10n.dart'; + +/// Opens the print-labels sheet for [entries] (already gathered from the +/// current selection): choose how many copies of each label (prefilled from the +/// stored container count) and a label size, then save a PDF sheet of labels — +/// each with a QR that carries the seed's data. +Future showLabelPrintSheet( + BuildContext context, + List entries, +) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _LabelPrintSheet(entries: entries), + ); +} + +/// Upper bound on copies per label — a printed sheet, not a print run. +const _maxCopies = 99; + +class _LabelPrintSheet extends StatefulWidget { + const _LabelPrintSheet({required this.entries}); + + final List entries; + + @override + State<_LabelPrintSheet> createState() => _LabelPrintSheetState(); +} + +class _LabelPrintSheetState extends State<_LabelPrintSheet> { + LabelSheetFormat _format = LabelSheetFormat.stickers; + bool _saving = false; + + /// One editable copy-count per entry, prefilled from its suggested count. + late final List _copies = [ + for (final e in widget.entries) e.suggestedCopies.clamp(0, _maxCopies), + ]; + late final List _controllers = [ + for (final n in _copies) TextEditingController(text: '$n'), + ]; + + int get _total => _copies.fold(0, (sum, n) => sum + n); + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.printLabels.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + t.printLabels.count(n: _total), + key: const Key('printLabels.total'), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + // The per-seed copy list can be long; keep the format chooser and + // the save button always visible by scrolling only this section. + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.entries.length, + itemBuilder: (context, i) => _CopyRow( + entry: widget.entries[i], + controller: _controllers[i], + onMinus: _copies[i] > 0 ? () => _bump(i, -1) : null, + onPlus: _copies[i] < _maxCopies ? () => _bump(i, 1) : null, + onChanged: (value) => _setCopies(i, value), + ), + ), + ), + ), + const Divider(height: 20), + RadioGroup( + groupValue: _format, + onChanged: _pick, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + key: const Key('printLabels.format.stickers'), + value: LabelSheetFormat.stickers, + contentPadding: EdgeInsets.zero, + title: Text(t.printLabels.formatStickers), + subtitle: Text(t.printLabels.formatStickersHint), + ), + RadioListTile( + key: const Key('printLabels.format.cards'), + value: LabelSheetFormat.cards, + contentPadding: EdgeInsets.zero, + title: Text(t.printLabels.formatCards), + subtitle: Text(t.printLabels.formatCardsHint), + ), + ], + ), + ), + const SizedBox(height: 12), + Align( + alignment: AlignmentDirectional.centerEnd, + child: FilledButton.icon( + key: const Key('printLabels.save'), + onPressed: (_saving || _total == 0) ? null : _save, + icon: const Icon(Icons.save_alt_outlined), + label: Text(t.printLabels.save), + ), + ), + ], + ), + ), + ); + } + + void _pick(LabelSheetFormat? value) { + if (value != null) setState(() => _format = value); + } + + /// Steps the count for entry [i] by [delta], syncing the text field. + void _bump(int i, int delta) { + final next = (_copies[i] + delta).clamp(0, _maxCopies); + _controllers[i].text = '$next'; + setState(() => _copies[i] = next); + } + + /// Applies a typed value (empty → 0), clamped to the allowed range. + void _setCopies(int i, String value) { + final parsed = int.tryParse(value) ?? 0; + setState(() => _copies[i] = parsed.clamp(0, _maxCopies)); + } + + Future _save() async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final rtl = Directionality.of(context) == TextDirection.rtl; + setState(() => _saving = true); + + // Expand each entry into its chosen number of identical labels. + final labels = [ + for (var i = 0; i < widget.entries.length; i++) + for (var c = 0; c < _copies[i]; c++) _toLabel(t, widget.entries[i]), + ]; + final saved = await getIt().saveLabels( + labels: labels, + format: _format, + suggestedName: 'tane-labels-${_today()}.pdf', + rtl: rtl, + ); + + navigator.pop(); + messenger.showSnackBar( + SnackBar( + content: Text(saved ? t.printLabels.saved : t.printLabels.cancelled), + ), + ); + } + + /// Builds one printable label from a [SeedLabelEntry]: the visible fields plus + /// the QR payload (structured, so a future scan can read it). + LabelSheetLabel _toLabel(Translations t, SeedLabelEntry e) { + final origin = _origin(e); + final details = [ + if (e.harvestYear != null) '${e.harvestYear}', + ?origin, + if (e.quantity != null) quantityDisplay(t, e.quantity!), + ]; + return LabelSheetLabel( + varietyLabel: e.varietyLabel, + commonName: e.commonName, + scientificName: e.scientificName, + details: details.isEmpty ? null : details.join(' · '), + qrData: SeedLabelCodec.encode( + SeedLabelData( + varietyLabel: e.varietyLabel, + scientificName: e.scientificName, + commonName: e.commonName, + year: e.harvestYear, + origin: origin, + ), + ), + ); + } + + String? _origin(SeedLabelEntry e) { + final parts = [ + if (e.originName != null && e.originName!.trim().isNotEmpty) + e.originName!.trim(), + if (e.originPlace != null && e.originPlace!.trim().isNotEmpty) + e.originPlace!.trim(), + ]; + return parts.isEmpty ? null : parts.join(' · '); + } + + String _today() => DateTime.now().toIso8601String().substring(0, 10); +} + +/// One row of the copy chooser: the seed's name plus a −/number/+ stepper. The +/// number is directly editable so "12 jars" is a quick type, not twelve taps. +class _CopyRow extends StatelessWidget { + const _CopyRow({ + required this.entry, + required this.controller, + required this.onMinus, + required this.onPlus, + required this.onChanged, + }); + + final SeedLabelEntry entry; + final TextEditingController controller; + final VoidCallback? onMinus; + final VoidCallback? onPlus; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtitle = [ + if (entry.commonName != null && entry.commonName!.trim().isNotEmpty) + entry.commonName!, + if (entry.harvestYear != null) '${entry.harvestYear}', + ].join(' · '); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.varietyLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge, + ), + if (subtitle.isNotEmpty) + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + key: Key('printLabels.copies.minus.${entry.varietyLabel}'), + onPressed: onMinus, + icon: const Icon(Icons.remove_circle_outline), + visualDensity: VisualDensity.compact, + ), + SizedBox( + width: 44, + child: TextField( + key: Key('printLabels.copies.field.${entry.varietyLabel}'), + controller: controller, + onChanged: onChanged, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + IconButton( + key: Key('printLabels.copies.plus.${entry.varietyLabel}'), + onPressed: onPlus, + icon: const Icon(Icons.add_circle_outline), + visualDensity: VisualDensity.compact, + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/legal_screen.dart b/apps/app_seeds/lib/ui/legal_screen.dart new file mode 100644 index 0000000..5915c01 --- /dev/null +++ b/apps/app_seeds/lib/ui/legal_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../i18n/strings.g.dart'; +import 'theme.dart'; + +/// Where the full legal documents are published. The masters live in the +/// repository under `docs/legal/` and must stay in sync with these pages. +const String legalBaseUrl = 'https://tane.comunes.org/legal'; + +/// "Privacy & rules": in-app, human-language summaries of the privacy policy, +/// the terms + community rules, and the seed-legality notice, each linking to +/// the full published text. +class LegalScreen extends StatelessWidget { + const LegalScreen({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + return Scaffold( + appBar: AppBar(title: Text(t.legal.title)), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + _Section( + icon: Icons.lock_outline, + title: t.legal.privacyTitle, + body: t.legal.privacyBody, + url: '$legalBaseUrl/privacy', + ), + _Section( + icon: Icons.handshake_outlined, + title: t.legal.rulesTitle, + body: t.legal.rulesBody, + url: '$legalBaseUrl/rules', + ), + _Section( + icon: Icons.grass_outlined, + title: t.legal.seedsTitle, + body: t.legal.seedsBody, + url: '$legalBaseUrl/seeds', + ), + ], + ), + ); + } +} + +/// One titled summary block with its "read the full text" link. +class _Section extends StatelessWidget { + const _Section({ + required this.icon, + required this.title, + required this.body, + required this.url, + }); + + final IconData icon; + final String title; + final String body; + final String url; + + @override + Widget build(BuildContext context) { + final t = context.t; + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: seedGreen), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + color: seedOnSurface, + height: 1.5, + ), + ), + TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + foregroundColor: seedGreen, + ), + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(t.legal.readFull), + onPressed: () => launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/market_gate.dart b/apps/app_seeds/lib/ui/market_gate.dart new file mode 100644 index 0000000..b43edb1 --- /dev/null +++ b/apps/app_seeds/lib/ui/market_gate.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import 'theme.dart'; + +/// Makes sure the community rules have been accepted once before the user +/// joins the market or publishes anything. Returns true when the rules are +/// (or become) accepted; false when the user declines. Play/App Store UGC +/// policies require this acceptance before content can be created. +Future ensureMarketRulesAccepted( + BuildContext context, + OnboardingStore store, +) async { + if (await store.marketRulesAccepted()) return true; + if (!context.mounted) return false; + final accepted = await showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + enableDrag: false, + builder: (_) => const MarketGateSheet(), + ); + if (accepted == true) { + await store.markMarketRulesAccepted(); + return true; + } + return false; +} + +/// The one-time "before you join the market" sheet: the community rules in +/// a few bullets, the publish-is-public note, and a link to the full texts. +class MarketGateSheet extends StatelessWidget { + const MarketGateSheet({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + final theme = Theme.of(context); + return SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 24, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.marketGate.title, + style: theme.textTheme.titleLarge?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + Text( + t.marketGate.intro, + style: theme.textTheme.bodyMedium?.copyWith( + color: seedOnSurface, + height: 1.4, + ), + ), + const SizedBox(height: 12), + _Rule(t.marketGate.ruleHonest), + _Rule(t.marketGate.ruleLegal), + _Rule(t.marketGate.ruleRespect), + const SizedBox(height: 12), + Text( + t.marketGate.publicNote, + style: theme.textTheme.bodySmall?.copyWith( + color: seedMuted, + height: 1.4, + ), + ), + TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + foregroundColor: seedGreen, + ), + icon: const Icon(Icons.privacy_tip_outlined, size: 16), + label: Text(t.marketGate.viewLegal), + onPressed: () => context.push('/legal'), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.marketGate.decline), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.marketGate.accept), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _Rule extends StatelessWidget { + const _Rule(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.check_circle_outline, size: 18, color: seedGreen), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: seedOnSurface, + height: 1.35, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart new file mode 100644 index 0000000..11501e1 --- /dev/null +++ b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart @@ -0,0 +1,485 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +import '../di/injector.dart'; +import '../i18n/strings.g.dart'; +import '../services/profile_cache.dart'; +import '../services/saved_offers_store.dart'; +import '../services/social_connection.dart'; +import '../services/social_service.dart'; +import '../services/social_settings.dart'; +import '../state/peer_rating_cubit.dart'; +import 'market_widgets.dart'; +import 'peer_avatar.dart'; +import 'rating_sheet.dart'; +import 'report_sheet.dart'; +import 'theme.dart'; + +/// Read-only "product page" for one discovered [Offer] (the Wallapop-style +/// detail behind a market card). Shows only what the author elected to publish — +/// summary, reciprocity mode, category, coarse area, price, and the eco flag — +/// plus who's sharing it and a way to message them. It never fetches inventory +/// or an exact address: the privacy seam means the network simply has no more. +class MarketOfferDetailScreen extends StatefulWidget { + const MarketOfferDetailScreen({ + required this.offer, + required this.connection, + this.mine = false, + this.profileCache, + this.selfPubkey, + this.savedOffers, + this.settings, + super.key, + }); + + final Offer offer; + + /// The shared relay connection, for fetching the author's published profile. + final SocialConnection connection; + + /// Whether this is the current user's own listing (hide the message button). + final bool mine; + + /// Store of the user's saved ("favorite") offers, for the save toggle. Null in + /// tests / when there's no social layer → the heart action is hidden. + final SavedOffersStore? savedOffers; + + /// Optional cache of peers' published display names; null in tests. + final ProfileCache? profileCache; + + /// This user's key (hex), to weigh the author's ratings by your own circle; + /// null in tests (circle weighting then stays off). + final String? selfPubkey; + + /// Social settings holding the local blocklist; falls back to the app-wide + /// instance. Null in tests → the block action is hidden. + final SocialSettings? settings; + + @override + State createState() => + _MarketOfferDetailScreenState(); +} + +class _MarketOfferDetailScreenState extends State { + String? _sellerName; + String? _sellerAbout; + String? _sellerG1; + String? _sellerPicture; + bool _profileLoading = true; + PeerRatingState? _sellerRating; + + /// Whether this offer is in the user's favorites; null while loading (the + /// heart action stays hidden until we know, to avoid a flicker). + bool? _saved; + + @override + void initState() { + super.initState(); + _loadSeller(); + _loadSaved(); + } + + Future _loadSaved() async { + final store = widget.savedOffers; + if (store == null) return; + final saved = await store.isSaved(SavedOffersStore.keyOf(widget.offer)); + if (mounted) setState(() => _saved = saved); + } + + Future _toggleSaved() async { + final store = widget.savedOffers; + if (store == null) return; + final nowSaved = await store.toggle( + widget.offer, + savedAt: DateTime.now().millisecondsSinceEpoch, + ); + if (mounted) setState(() => _saved = nowSaved); + } + + /// Best-effort fetch of the author's published profile (name/about/Ğ1), the + /// same path the chat header uses. Degrades silently when offline. + Future _loadSeller() async { + final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex); + final cachedPic = + await widget.profileCache?.picture(widget.offer.authorPubkeyHex); + if (mounted) { + setState(() { + if (cachedName != null) _sellerName = cachedName; + if (cachedPic != null) _sellerPicture = cachedPic; + }); + } + + final session = await widget.connection.session(); + if (!mounted) return; + if (session == null) { + setState(() => _profileLoading = false); + return; + } + unawaited(_loadSellerRating(session)); + try { + final profile = await session.profile.fetch(widget.offer.authorPubkeyHex); + if (profile != null && profile.name.isNotEmpty) { + unawaited(widget.profileCache + ?.setName(widget.offer.authorPubkeyHex, profile.name)); + } + if (profile != null && profile.picture.isNotEmpty) { + unawaited(widget.profileCache + ?.setPicture(widget.offer.authorPubkeyHex, profile.picture)); + } + if (mounted) { + setState(() { + if (profile != null && profile.name.isNotEmpty) { + _sellerName = profile.name; + } + if (profile != null && profile.picture.isNotEmpty) { + _sellerPicture = profile.picture; + } + _sellerAbout = profile?.about.isNotEmpty == true ? profile!.about : null; + _sellerG1 = profile?.g1.isNotEmpty == true ? profile!.g1 : null; + _profileLoading = false; + }); + } + } catch (_) { + if (mounted) setState(() => _profileLoading = false); + } + } + + /// Best-effort summary of the author's ratings, circle-weighted when we know + /// who this user is. Reuses the cubit's aggregation without providing it. + Future _loadSellerRating(SocialSession session) async { + final cubit = PeerRatingCubit( + session.ratings, + peerPubkey: widget.offer.authorPubkeyHex, + selfPubkey: widget.selfPubkey ?? '', + trust: widget.selfPubkey == null ? null : session.trust, + ); + try { + await cubit.load(); + if (mounted) setState(() => _sellerRating = cubit.state); + } catch (_) { + // offline / unreachable — the seller section simply shows no stars. + } finally { + await cubit.close(); + } + } + + // No dispose of the session — it belongs to the shared connection. + + SocialSettings? get _settings => + widget.settings ?? + (getIt.isRegistered() ? getIt() : null); + + /// Reports this offer (a standard report event to the community servers), + /// hides it locally, and goes back to the market. + Future _reportOffer() async { + final offer = widget.offer; + final outcome = await showReportSheet( + context, + connection: widget.connection, + subjectPubkey: offer.authorPubkeyHex, + offerAddress: + '${Nip99Codec.kindActive}:${offer.authorPubkeyHex}:${offer.id}', + settings: _settings, + ); + if (outcome == null || !outcome.sent || !mounted) return; + await _settings?.hideOffer( + authorPubkeyHex: offer.authorPubkeyHex, + offerId: offer.id, + ); + if (mounted && context.canPop()) context.pop(); + } + + /// Blocks the offer's author after confirmation and goes back to the market + /// (which reloads the blocklist and drops their offers). + Future _blockAuthor() async { + final settings = _settings; + if (settings == null) return; + final t = context.t; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.block.confirmTitle), + content: Text(t.block.confirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.common.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.block.confirm), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await settings.block(widget.offer.authorPubkeyHex); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(t.block.blockedToast))); + if (context.canPop()) context.pop(); + } + + Future _copyId() async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + await Clipboard.setData( + ClipboardData(text: widget.offer.authorPubkeyHex)); + messenger.showSnackBar(SnackBar(content: Text(t.market.idCopied))); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final o = widget.offer; + final saved = _saved; + return Scaffold( + appBar: AppBar( + title: Text(o.summary), + actions: [ + // Save other people's offers (Wallapop-style). Hidden on your own + // listing and until the saved state is known. + if (!widget.mine && widget.savedOffers != null && saved != null) + IconButton( + key: const Key('offerDetail.save'), + icon: Icon(saved ? Icons.favorite : Icons.favorite_border), + color: saved ? seedFavorite : null, + tooltip: saved ? t.favorites.remove : t.favorites.save, + onPressed: _toggleSaved, + ), + if (!widget.mine) + PopupMenuButton( + key: const Key('offer.menu'), + onSelected: (value) { + if (value == 'report') _reportOffer(); + if (value == 'block') _blockAuthor(); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'report', + child: Text(t.report.offer), + ), + if (_settings != null) + PopupMenuItem( + value: 'block', + child: Text(t.block.action), + ), + ], + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (o.imageUrl != null) ...[ + OfferHeroImage(url: o.imageUrl!, semanticLabel: t.market.photo), + const SizedBox(height: 16), + ], + Row( + children: [ + Expanded( + child: Text( + o.summary, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: seedTitle, + ), + ), + ), + const SizedBox(width: 8), + OfferTypeChip(label: offerTypeLabel(t, o.type)), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (o.isOrganic) + _Badge( + icon: Icons.eco, + label: t.editVariety.organic, + ), + if (o.category != null && o.category!.isNotEmpty) + _Badge(icon: Icons.folder_outlined, label: o.category!), + _Badge(icon: Icons.place_outlined, label: t.market.near), + ], + ), + if (o.type == OfferType.sale && o.priceAmount != null) ...[ + const SizedBox(height: 16), + Text( + '${o.priceAmount} ${o.priceCurrency ?? ''}'.trim(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: seedOnSurface, + ), + ), + ], + if (o.type == OfferType.exchange && + o.exchangeTerms != null && + o.exchangeTerms!.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + o.exchangeTerms!, + style: const TextStyle(color: seedOnSurface, fontSize: 15), + ), + ], + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 12), + _SellerSection( + title: t.market.sharedBy, + pubkey: o.authorPubkeyHex, + name: _sellerName ?? shortPubkey(o.authorPubkeyHex), + picture: _sellerPicture, + about: _sellerAbout, + rating: _sellerRating, + loading: _profileLoading, + hasProfile: + _sellerName != null || _sellerAbout != null || _sellerG1 != null, + noProfileLabel: t.market.noProfile, + onCopyId: _copyId, + copyIdTooltip: t.market.copyId, + ), + const SizedBox(height: 24), + if (!widget.mine) + FilledButton.icon( + key: const Key('offerDetail.contact'), + onPressed: () => context.push('/chat/${o.authorPubkeyHex}'), + icon: const Icon(Icons.chat_bubble_outline), + label: Text(t.market.contact), + ), + ], + ), + ); + } +} + +/// A small pill with an icon + label (eco, category, "near you"). +class _Badge extends StatelessWidget { + const _Badge({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: seedPrimaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: seedGreen), + const SizedBox(width: 5), + Text(label, + style: const TextStyle(color: seedOnSurface, fontSize: 13)), + ], + ), + ); + } +} + +/// "Shared by" card: who's offering this, their bio, and a copy-code affordance. +class _SellerSection extends StatelessWidget { + const _SellerSection({ + required this.title, + required this.pubkey, + required this.name, + required this.about, + required this.loading, + required this.hasProfile, + required this.noProfileLabel, + required this.onCopyId, + required this.copyIdTooltip, + this.picture, + this.rating, + }); + + final String title; + final String pubkey; + final String name; + final String? picture; + final String? about; + + /// Circle-weighted summary of the author's ratings; stars show only when + /// someone actually rated them (absence is never a reproach). + final PeerRatingState? rating; + final bool loading; + final bool hasProfile; + final String noProfileLabel; + final VoidCallback onCopyId; + final String copyIdTooltip; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + color: seedMuted, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + PeerAvatar(pubkey: pubkey, name: name, picture: picture, radius: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: seedOnSurface, + ), + ), + if (rating != null && rating!.hasRatings) ...[ + const SizedBox(height: 2), + RatingStars( + average: rating!.average, + count: rating!.count, + circleCount: rating!.circleCount, + ), + ], + ], + ), + ), + IconButton( + key: const Key('offerDetail.copyId'), + icon: const Icon(Icons.copy, size: 18, color: seedMuted), + tooltip: copyIdTooltip, + onPressed: onCopyId, + ), + ], + ), + if (about != null) ...[ + const SizedBox(height: 8), + Text(about!, + style: const TextStyle(color: seedOnSurface, fontSize: 14)), + ] else if (!loading && !hasProfile) ...[ + const SizedBox(height: 8), + Text(noProfileLabel, + style: const TextStyle(color: seedMuted, fontSize: 13)), + ], + ], + ); + } +} diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart new file mode 100644 index 0000000..ffe310a --- /dev/null +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -0,0 +1,985 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/variety_repository.dart'; +import '../i18n/strings.g.dart'; +import '../services/coarse_location.dart'; +import '../services/discovery_area.dart'; +import '../services/offer_outbox.dart'; +import '../services/social_connection.dart'; +import '../services/social_service.dart'; +import '../services/social_settings.dart'; +import '../services/onboarding_store.dart'; +import '../state/offers_cubit.dart'; +import 'blocked_people.dart'; +import 'edge_fade.dart'; +import 'filter_chips.dart'; +import 'market_gate.dart'; +import 'market_widgets.dart'; +import 'theme.dart'; + +/// The market (redesign screens 04/05): discover seeds shared near you. Opens +/// an offer transport lazily; local-first, so it degrades to a "set up sharing" +/// prompt when no relay/area is configured or the network is unreachable. +class MarketScreen extends StatefulWidget { + const MarketScreen({ + required this.social, + required this.settings, + required this.connection, + this.location, + this.outbox, + this.onboarding, + super.key, + }); + + final SocialService social; + final SocialSettings settings; + + /// When set, joining the market is gated on a one-time acceptance of the + /// community rules (store UGC policies require it before content is + /// created). Null in tests → no gate. + final OnboardingStore? onboarding; + + /// The shared relay connection (one per identity), reused for discovery. + final SocialConnection connection; + + /// Optional device-location source for the "use my location" shortcut; when + /// null (tests, or a platform without it) the shortcut is hidden. + final CoarseLocationProvider? location; + + /// Optional offline outbox: parks shares attempted with no network and flushes + /// them on reconnect. Null in tests → sharing offline is simply a no-op. + final OfferOutbox? outbox; + + @override + State createState() => _MarketScreenState(); +} + +class _MarketScreenState extends State { + OffersCubit? _cubit; + bool _loading = true; + bool _hasArea = false; + + @override + void initState() { + super.initState(); + _start(); + } + + /// Runs the one-time community-rules gate (when wired) before touching the + /// network; declining leaves the market. + Future _start() async { + final store = widget.onboarding; + if (store != null && !await store.marketRulesAccepted()) { + // Wait for the first frame so the sheet has a surface to attach to. + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final ok = await ensureMarketRulesAccepted(context, store); + if (!ok) { + if (mounted) context.pop(); + return; + } + } + await _init(); + } + + Future _init() async { + // Only needed to flush the outbox; read here (while mounted) to avoid using + // context across the awaits below. Null when there's no outbox (e.g. tests). + final repo = + widget.outbox != null ? context.read() : null; + setState(() => _loading = true); + final cubit = + await createOffersCubit(widget.connection, repository: repo); + cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); + cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys()); + final area = await widget.settings.areaGeohash(); + if (!mounted) { + await cubit.close(); + return; + } + final previous = _cubit; + setState(() { + _cubit = cubit; + _hasArea = area.isNotEmpty; + _loading = false; + }); + await previous?.close(); + if (area.isNotEmpty && cubit.isOnline) { + // Flush anything parked while offline, then show what's out there. + final outbox = widget.outbox; + if (outbox != null && repo != null) { + await flushOutbox( + outbox: outbox, + cubit: cubit, + shareableLots: await repo.shareableLots(), + authorPubkeyHex: widget.social.publicKeyHex, + areaGeohash: area, + ); + } + if (mounted) { + final precision = await widget.settings.searchPrecision(); + if (mounted) await cubit.discover(searchPrefix(area, precision)); + } + } + } + + /// Re-reads the blocklist and hidden (reported) offers — the offer detail + /// can change both — so they drop out of the visible list at once. + Future _reloadBlocked() async { + final cubit = _cubit; + if (cubit == null) return; + cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); + cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys()); + } + + Future _openConfig() async { + final changed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => + _ConfigSheet(settings: widget.settings, location: widget.location), + ); + if (changed == true) await _init(); + } + + /// Publishes the user's shared lots as offers to the network, tagged with the + /// coarse area. Prompts for setup when the area isn't set. + Future _shareMine() async { + // Defense in depth: publishing is what actually creates content, so the + // rules gate is re-checked here even though market entry already ran it. + final store = widget.onboarding; + if (store != null) { + final ok = await ensureMarketRulesAccepted(context, store); + if (!ok || !mounted) return; + } + final cubit = _cubit; + if (cubit == null) return; + final repo = context.read(); + final messenger = ScaffoldMessenger.of(context); + final t = context.t; + final area = await widget.settings.areaGeohash(); + if (!mounted) return; + if (area.isEmpty) { + await _openConfig(); + return; + } + final lots = await repo.shareableLots(); + if (!mounted) return; + if (lots.isEmpty) { + messenger.showSnackBar(SnackBar(content: Text(t.market.nothingToShare))); + return; + } + final ids = lots.map((l) => l.lotId).toList(); + + // Offline: park it and tell the person we'll share it later. + if (!cubit.isOnline) { + await widget.outbox?.enqueue(ids); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(t.market.queued))); + return; + } + + final count = await cubit.publishLots( + lots, + authorPubkeyHex: widget.social.publicKeyHex, + areaGeohash: area, + ); + if (!mounted) return; + await widget.outbox?.remove(ids); // published now, so unpark any duplicates + // count == 0 with lots to share means every relay refused — say so plainly + // rather than "shared 0", which reads like success. + messenger.showSnackBar(SnackBar( + content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)), + )); + final precision = await widget.settings.searchPrecision(); + if (!mounted) return; + await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared + } + + @override + void dispose() { + _cubit?.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final cubit = _cubit; + return Scaffold( + appBar: AppBar( + title: Text(t.market.title), + actions: [ + IconButton( + key: const Key('market.config'), + icon: const Icon(Icons.tune), + tooltip: t.market.configTitle, + onPressed: _openConfig, + ), + ], + ), + floatingActionButton: + cubit != null && (cubit.isOnline || widget.outbox != null) + ? FloatingActionButton.extended( + key: const Key('market.shareMine'), + onPressed: _shareMine, + icon: const Icon(Icons.campaign_outlined), + label: Text(t.market.shareMine), + ) + : null, + body: _loading || cubit == null + ? const Center(child: CircularProgressIndicator()) + : BlocProvider.value( + value: cubit, + child: MarketBody( + onConfigure: _openConfig, + onRetry: _init, + hasArea: _hasArea, + selfPubkey: widget.social.publicKeyHex, + onOfferClosed: _reloadBlocked, + ), + ), + ); + } +} + +class MarketBody extends StatelessWidget { + const MarketBody({ + required this.onConfigure, + this.onRetry, + this.hasArea = true, + this.selfPubkey, + this.onOfferClosed, + super.key, + }); + + final VoidCallback onConfigure; + + /// Re-opens the connection (for the "can't reach servers" retry). + final VoidCallback? onRetry; + + /// Whether the user has set their coarse area yet. + final bool hasArea; + + /// This user's own pubkey, so we don't offer to message our own listings. + final String? selfPubkey; + + /// Called after coming back from an offer's detail (which can block its + /// author), so the visible list refreshes against the blocklist. + final Future Function()? onOfferClosed; + + @override + Widget build(BuildContext context) { + final t = context.t; + return BlocBuilder( + builder: (context, state) { + if (!context.read().isOnline) { + // Default community servers exist, so "offline" means unreachable — + // offer a retry rather than "set up sharing". + return _EmptyState( + icon: Icons.wifi_tethering_off_outlined, + title: t.market.cantReach, + body: t.market.cantReachBody, + actionLabel: onRetry != null ? t.market.retry : null, + onAction: onRetry, + ); + } + if (!hasArea) { + return _EmptyState( + icon: Icons.place_outlined, + title: t.market.setArea, + body: t.market.setAreaBody, + actionLabel: t.market.setUp, + onAction: onConfigure, + ); + } + if (state.searching && state.offers.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(t.market.searching, + style: const TextStyle(color: seedMuted)), + ], + ), + ); + } + final cubit = context.read(); + // Pull-to-refresh re-runs discovery for the current area (keeping the + // text filter), so the list is never stuck on a stale scan. + Future refresh() => cubit.discover(state.areaGeohash); + + // Nothing discovered at all: keep the friendly empty state, but make it + // pull-to-refreshable so a swipe re-scans. + if (state.offers.isEmpty) { + return RefreshIndicator( + onRefresh: refresh, + child: ListView( + // AlwaysScrollable so the pull gesture works with a single child. + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: _EmptyState( + icon: Icons.grass_outlined, + title: t.market.empty, + body: '', + ), + ), + ], + ), + ); + } + + final visible = state.visibleOffers; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: TextField( + key: const Key('market.search'), + decoration: InputDecoration( + hintText: t.market.searchHint, + prefixIcon: const Icon(Icons.search, color: seedMuted), + hintStyle: const TextStyle(color: seedMuted), + filled: true, + fillColor: Colors.white, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + ), + onChanged: cubit.search, + ), + ), + _MarketFilterBar(state: state), + Expanded( + child: RefreshIndicator( + onRefresh: refresh, + child: visible.isEmpty + // Discovered offers exist, but the search filtered them out. + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: _EmptyState( + icon: Icons.search_off, + title: t.market.noMatches, + body: '', + ), + ), + ], + ) + : ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: visible.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final o = visible[i]; + final mine = o.authorPubkeyHex == selfPubkey; + return _OfferCard( + offer: o, + mine: mine, + onTap: () async { + await context.push('/market/offer', extra: o); + await onOfferClosed?.call(); + }, + ); + }, + ), + ), + ), + ], + ); + }, + ); + } +} + +/// One discovered offer. Human words for the reciprocity mode; coarse "near you" +/// instead of any precise location; price only when it's a sale. +class _OfferCard extends StatelessWidget { + const _OfferCard({required this.offer, this.mine = false, this.onTap}); + + final Offer offer; + + /// Whether this is the current user's own listing (shown with a "You" badge). + final bool mine; + + /// Opens the offer's detail ("product") page. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = context.t; + final radius = BorderRadius.circular(14); + return Material( + color: Colors.white, + borderRadius: radius, + child: InkWell( + key: Key('market.offer.${offer.authorPubkeyHex}.${offer.id}'), + onTap: onTap, + borderRadius: radius, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: seedOutline), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (offer.imageUrl != null) ...[ + OfferThumbnail( + url: offer.imageUrl!, + semanticLabel: t.market.photo, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + offer.summary, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: seedOnSurface, + ), + ), + ), + if (mine) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: seedGreen, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + t.market.mine, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + ], + OfferTypeChip(label: offerTypeLabel(t, offer.type)), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.place_outlined, size: 15, color: seedMuted), + const SizedBox(width: 4), + Text(t.market.near, + style: const TextStyle(color: seedMuted, fontSize: 13)), + if (offer.isOrganic) ...[ + const SizedBox(width: 10), + const Icon(Icons.eco, size: 15, color: seedGreen), + ], + const Spacer(), + if (offer.type == OfferType.sale && offer.priceAmount != null) + Text( + '${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(), + style: const TextStyle( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 18, color: seedMuted), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Optional filters over the discovered offers, mirroring the inventory filter +/// bar: reciprocity mode (give/swap/sell/wanted), category, and the eco flag. +/// Each chip group only appears when some discovered offer can match it. +class _MarketFilterBar extends StatelessWidget { + const _MarketFilterBar({required this.state}); + + final OffersState state; + + @override + Widget build(BuildContext context) { + final t = context.t; + final cubit = context.read(); + final categories = state.categories; + // Only offer type chips for modes some discovered offer actually holds. + final types = [ + for (final type in const [ + OfferType.gift, + OfferType.exchange, + OfferType.sale, + OfferType.wanted, + ]) + if (state.offers.any((o) => o.type == type)) type, + ]; + final hasOrganic = state.hasOrganic; + if (categories.isEmpty && types.isEmpty && !hasOrganic) { + return const SizedBox.shrink(); + } + + final chips = [ + if (hasOrganic) + PlainFilterChip( + key: const Key('market.filter.organic'), + icon: Icons.eco, + label: t.editVariety.organic, + selected: state.organicOnly, + onSelected: (_) => cubit.toggleOrganicOnly(), + ), + for (final type in types) + PlainFilterChip( + key: Key('market.filter.type.${type.name}'), + label: offerTypeLabel(t, type), + selected: state.typeFilter.contains(type), + onSelected: (_) => cubit.toggleType(type), + ), + for (final category in categories) + PlainFilterChip( + key: Key('market.filter.category.$category'), + label: category, + selected: state.categoryFilter.contains(category), + onSelected: (_) => cubit.toggleCategory(category), + ), + ]; + + return EdgeFade( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + for (final chip in chips) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: chip, + ), + if (state.hasActiveFilter) + TextButton.icon( + key: const Key('market.filter.clear'), + onPressed: cubit.clearFilters, + icon: const Icon(Icons.clear, size: 18), + label: Text(t.inventory.clearFilters), + ), + ], + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({ + required this.icon, + required this.title, + required this.body, + this.actionLabel, + this.onAction, + }); + + final IconData icon; + final String title; + final String body; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 56, color: seedMuted), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: seedOnSurface, + ), + ), + if (body.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + body, + textAlign: TextAlign.center, + style: const TextStyle(color: seedMuted, fontSize: 14), + ), + ], + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 20), + FilledButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ), + ); + } +} + +/// Coarse-area + community-server setup. Kept behind progressive disclosure — a +/// power-user surface — with human-worded labels. +class _ConfigSheet extends StatefulWidget { + const _ConfigSheet({required this.settings, this.location}); + + final SocialSettings settings; + final CoarseLocationProvider? location; + + @override + State<_ConfigSheet> createState() => _ConfigSheetState(); +} + +class _ConfigSheetState extends State<_ConfigSheet> { + final _area = TextEditingController(); + + /// Servers offered as toggles: the built-in defaults plus any the user has + /// saved or added. [_selectedRelays] is the subset currently switched on. + List _knownRelays = const []; + Set _selectedRelays = {}; + bool _loading = true; + bool _locationBusy = false; + String? _locationError; + int _precision = SocialSettings.defaultSearchPrecision; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + _area.text = await widget.settings.areaGeohash(); + final saved = await widget.settings.relayUrls(); + _selectedRelays = saved.toSet(); + // Show the defaults plus anything the user saved, defaults first. + _knownRelays = {...SocialSettings.defaultRelays, ...saved}.toList(); + _precision = await widget.settings.searchPrecision(); + if (mounted) setState(() => _loading = false); + } + + Future _save() async { + await widget.settings.setAreaGeohash(_area.text); + await widget.settings.setRelayUrls(_selectedRelays.toList()); + await widget.settings.setSearchPrecision(_precision); + if (mounted) Navigator.of(context).pop(true); + } + + /// The human-friendly server name — just the host, no `wss://` scheme or + /// trailing slash (jargon stays out of the UI). + String _relayLabel(String url) => url + .trim() + .replaceFirst(RegExp(r'^wss?://'), '') + .replaceFirst(RegExp(r'/+$'), ''); + + /// Coerces user input into a `wss://` address, or null if it can't be one. + /// Bare hosts get a `wss://` prefix so people needn't type the scheme. + String? _normalizeRelay(String raw) { + var s = raw.trim(); + if (s.isEmpty) return null; + if (!s.contains('://')) s = 'wss://$s'; + final uri = Uri.tryParse(s); + if (uri == null || + (uri.scheme != 'wss' && uri.scheme != 'ws') || + uri.host.isEmpty) { + return null; + } + return s.replaceFirst(RegExp(r'/+$'), ''); + } + + Future _addServer() async { + final t = context.t; + final controller = TextEditingController(); + final url = await showDialog( + context: context, + builder: (dialogContext) { + String? error; + return StatefulBuilder( + builder: (dialogContext, setLocal) => AlertDialog( + title: Text(t.market.serversAdvanced), + content: TextField( + key: const Key('market.serverField'), + controller: controller, + autofocus: true, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: t.market.serverAddress, + hintText: 'wss://…', + errorText: error, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(t.common.cancel), + ), + FilledButton( + key: const Key('market.serverConfirm'), + onPressed: () { + final value = _normalizeRelay(controller.text); + if (value == null) { + setLocal(() => error = t.market.serverInvalid); + } else { + Navigator.pop(dialogContext, value); + } + }, + child: Text(t.market.save), + ), + ], + ), + ); + }, + ); + if (url == null || !mounted) return; + setState(() { + if (!_knownRelays.contains(url)) _knownRelays = [..._knownRelays, url]; + _selectedRelays.add(url); + }); + } + + /// Fills the area from the device's approximate location, reduced to a coarse + /// geohash so no precise fix is stored. + Future _useLocation() async { + final provider = widget.location; + if (provider == null || _locationBusy) return; + setState(() { + _locationBusy = true; + _locationError = null; + }); + final loc = await provider.currentCoarseLatLon(); + if (!mounted) return; + setState(() { + _locationBusy = false; + if (loc == null) { + _locationError = context.t.market.locationFailed; + } else { + _area.text = Geohash.encode(loc.lat, loc.lon, precision: 5); + } + }); + } + + @override + void dispose() { + _area.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final hasArea = _area.text.trim().isNotEmpty; + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: _loading + ? const SizedBox( + height: 120, child: Center(child: CircularProgressIndicator())) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.market.configTitle, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: seedTitle, + ), + ), + const SizedBox(height: 8), + Text( + t.market.setupIntro, + style: const TextStyle( + color: seedMuted, fontSize: 13, height: 1.4), + ), + const SizedBox(height: 18), + // Setting your area from device location is the human path; + // typing a code is the advanced fallback. + if (widget.location != null) + FilledButton.tonalIcon( + key: const Key('market.useLocation'), + onPressed: _locationBusy ? null : _useLocation, + icon: _locationBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location, size: 18), + label: Text(t.market.useLocation), + ), + if (_locationError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _locationError!, + style: const TextStyle( + color: Color(0xFFB3261E), fontSize: 12), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + hasArea + ? Icons.check_circle + : Icons.pending_outlined, + size: 18, + color: hasArea ? seedGreen : seedMuted, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + hasArea ? t.market.areaSet : t.market.areaNotSet, + style: TextStyle( + color: hasArea ? seedOnSurface : seedMuted, + fontSize: 13, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + t.market.rangeLabel, + style: const TextStyle(fontSize: 13, color: seedMuted), + ), + ), + const SizedBox(height: 8), + SegmentedButton( + key: const Key('market.range'), + segments: [ + ButtonSegment(value: 5, label: Text(t.market.rangeNear)), + ButtonSegment(value: 4, label: Text(t.market.rangeArea)), + ButtonSegment(value: 3, label: Text(t.market.rangeRegion)), + ], + selected: {_precision}, + showSelectedIcon: false, + onSelectionChanged: (s) => + setState(() => _precision = s.first), + ), + const SizedBox(height: 8), + Theme( + data: Theme.of(context) + .copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + key: const Key('market.advanced'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + title: Text( + t.market.advanced, + style: const TextStyle(fontSize: 13, color: seedMuted), + ), + children: [ + TextField( + key: const Key('market.area'), + controller: _area, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: t.market.areaCodeLabel, + helperText: t.market.areaCodeHint, + helperMaxLines: 2, + ), + ), + const SizedBox(height: 14), + Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + t.market.serversLabel, + style: const TextStyle( + fontSize: 13, color: seedMuted), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only( + top: 2, bottom: 4), + child: Text( + t.market.serversHelp, + style: const TextStyle( + fontSize: 12, color: seedMuted, height: 1.3), + ), + ), + for (final url in _knownRelays) + CheckboxListTile( + key: Key('market.server.$url'), + contentPadding: EdgeInsets.zero, + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: _selectedRelays.contains(url), + title: Text(_relayLabel(url)), + onChanged: (on) => setState(() { + if (on ?? false) { + _selectedRelays.add(url); + } else { + _selectedRelays.remove(url); + } + }), + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + key: const Key('market.addServer'), + onPressed: _addServer, + icon: const Icon(Icons.add, size: 18), + label: Text(t.market.serversAdvanced), + ), + ), + ListTile( + key: const Key('market.blockedPeople'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.block, color: seedMuted), + title: Text(t.block.manageTitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => + BlockedPeopleSheet(settings: widget.settings), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + FilledButton( + key: const Key('market.save'), + onPressed: _save, + child: Text(t.market.save), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/market_widgets.dart b/apps/app_seeds/lib/ui/market_widgets.dart new file mode 100644 index 0000000..20c9f31 --- /dev/null +++ b/apps/app_seeds/lib/ui/market_widgets.dart @@ -0,0 +1,158 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import '../services/offer_thumbnail.dart'; +import 'theme.dart'; + +/// Human words for an offer's reciprocity mode. Shared by the market list and +/// the offer detail page so both read the same way. +String offerTypeLabel(Translations t, OfferType type) => switch (type) { + OfferType.gift => t.share.gift, + OfferType.exchange => t.share.exchange, + OfferType.sale => t.share.sell, + OfferType.wanted => t.market.wanted, + OfferType.lend => t.share.exchange, + }; + +/// A small square thumbnail of an offer's photo for the market list card. +/// The image usually rides inline in the offer as a `data:` URI (decentralized, +/// no server), so it renders straight from memory; an `http(s)` URL is still +/// supported for interop and degrades gracefully (spinner, then placeholder). +class OfferThumbnail extends StatelessWidget { + const OfferThumbnail({ + required this.url, + required this.semanticLabel, + this.size = 56, + super.key, + }); + + final String url; + final String semanticLabel; + final double size; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: _offerImage( + url, + semanticLabel: semanticLabel, + width: size, + height: size, + ), + ); + } +} + +/// Full-width "hero" image atop the offer detail page. Same source handling as +/// [OfferThumbnail]; hidden entirely when the offer has no photo. +class OfferHeroImage extends StatelessWidget { + const OfferHeroImage({ + required this.url, + required this.semanticLabel, + super.key, + }); + + final String url; + final String semanticLabel; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(14), + child: AspectRatio( + aspectRatio: 4 / 3, + child: _offerImage(url, semanticLabel: semanticLabel), + ), + ); + } +} + +/// Renders an offer image from either an inline `data:` URI (via memory) or a +/// remote URL (via network), always cropping to fill and falling back to a +/// neutral placeholder — a broken image must never block the card. +Widget _offerImage( + String url, { + required String semanticLabel, + double? width, + double? height, +}) { + final inline = decodeDataUri(url); + if (inline != null) { + return Image.memory( + inline, + width: width, + height: height, + fit: BoxFit.cover, + semanticLabel: semanticLabel, + errorBuilder: (context, _, _) => + _OfferImagePlaceholder(width: width, height: height), + ); + } + return Image.network( + url, + width: width, + height: height, + fit: BoxFit.cover, + semanticLabel: semanticLabel, + loadingBuilder: (context, child, progress) => progress == null + ? child + : _OfferImagePlaceholder(width: width, height: height, loading: true), + errorBuilder: (context, _, _) => + _OfferImagePlaceholder(width: width, height: height), + ); +} + +/// Neutral fill shown while a remote offer image loads or when it can't be +/// decoded/fetched — a marketplace is useless if a broken image blocks the card. +class _OfferImagePlaceholder extends StatelessWidget { + const _OfferImagePlaceholder({this.width, this.height, this.loading = false}); + + final double? width; + final double? height; + final bool loading; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + color: seedPrimaryContainer.withValues(alpha: 0.4), + alignment: Alignment.center, + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.image_not_supported_outlined, color: seedMuted), + ); + } +} + +/// The small pill showing an offer's reciprocity mode (give/swap/sell/wanted). +class OfferTypeChip extends StatelessWidget { + const OfferTypeChip({required this.label, super.key}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: seedPrimaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: const TextStyle( + color: seedOnPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/offline_banner.dart b/apps/app_seeds/lib/ui/offline_banner.dart new file mode 100644 index 0000000..f2a896c --- /dev/null +++ b/apps/app_seeds/lib/ui/offline_banner.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; + +/// Wraps the app and shows a slim strip at the very top when there's no network, +/// so it's clear WHY the social layer (market, chat, profile) is paused — the +/// inventory keeps working offline. Wrap the whole app once (MaterialApp builder). +class OfflineBanner extends StatefulWidget { + const OfflineBanner({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _OfflineBannerState(); +} + +class _OfflineBannerState extends State { + bool _offline = false; + StreamSubscription>? _sub; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + try { + final current = await Connectivity().checkConnectivity(); + if (mounted) setState(() => _offline = _isOffline(current)); + _sub = Connectivity().onConnectivityChanged.listen((results) { + if (mounted) setState(() => _offline = _isOffline(results)); + }); + } catch (_) { + // Platform without connectivity support → assume online, hide the banner. + } + } + + bool _isOffline(List results) => + results.isEmpty || + results.every((r) => r == ConnectivityResult.none); + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (_offline) + Material( + color: const Color(0xFFE8A200), // amber — not an error, just paused + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.cloud_off, size: 16, color: Colors.white), + const SizedBox(width: 8), + Flexible( + child: Text( + context.t.common.offline, + style: const TextStyle( + color: Colors.white, + fontSize: 12.5, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + Expanded(child: widget.child), + ], + ); + } +} diff --git a/apps/app_seeds/lib/ui/peer_avatar.dart b/apps/app_seeds/lib/ui/peer_avatar.dart new file mode 100644 index 0000000..d501b9d --- /dev/null +++ b/apps/app_seeds/lib/ui/peer_avatar.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../services/offer_thumbnail.dart' show decodeDataUri; +import '../services/profile_cache.dart'; +import 'avatar.dart'; +import 'seed_glyph.dart'; + +/// A small round avatar for a person. When they've set an avatar ([picture] — a +/// `data:` photo thumbnail, or a legacy `tane:seed:` illustration) it's +/// shown; otherwise it falls back to a standard disc coloured deterministically +/// from their [pubkey], carrying the first letter of their [name] (a person icon +/// when unknown). Same pubkey → same colour on every device, so a person stays +/// visually recognizable without sharing anything. +class PeerAvatar extends StatelessWidget { + const PeerAvatar({ + required this.pubkey, + this.name, + this.picture, + this.radius = 14, + super.key, + }); + + final String pubkey; + final String? name; + + /// The person's chosen avatar value; null/empty → the coloured-initial disc. + final String? picture; + final double radius; + + @override + Widget build(BuildContext context) { + final pic = picture?.trim() ?? ''; + + if (pic.startsWith('data:')) { + final bytes = decodeDataUri(pic); + if (bytes != null) { + return CircleAvatar(radius: radius, backgroundImage: MemoryImage(bytes)); + } + } else if (pic.startsWith(avatarGlyphPrefix)) { + final glyph = avatarGlyphChar(pic.substring(avatarGlyphPrefix.length)); + if (glyph != null) { + return CircleAvatar( + radius: radius, + backgroundColor: peerAvatarColor(pubkey), + child: SeedGlyph(glyph, size: radius * 1.15, color: Colors.white), + ); + } + } + + final letter = _initial(name); + return CircleAvatar( + radius: radius, + backgroundColor: peerAvatarColor(pubkey), + child: letter == null + ? Icon(Icons.person_outline, size: radius, color: Colors.white) + : Text( + letter, + style: TextStyle( + color: Colors.white, + fontSize: radius, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + static String? _initial(String? name) { + if (name == null) return null; + final trimmed = name.trim(); + if (trimmed.isEmpty) return null; + // characters.first handles emoji/combining marks safely. + return trimmed.characters.first.toUpperCase(); + } +} + +/// A [PeerAvatar] that looks the person's published avatar up from the +/// [ProfileCache] (their kind:0 `picture`), falling back to the coloured-initial +/// disc while it loads or when none is cached / no cache is available. Use at +/// list/row sites (one avatar each); for many avatars of the same few people +/// (chat bubbles) resolve the picture once into state instead. +class CachedAvatar extends StatelessWidget { + const CachedAvatar({ + required this.pubkey, + this.name, + this.cache, + this.radius = 14, + super.key, + }); + + final String pubkey; + final String? name; + final ProfileCache? cache; + final double radius; + + @override + Widget build(BuildContext context) { + final c = cache; + if (c == null) { + return PeerAvatar(pubkey: pubkey, name: name, radius: radius); + } + return FutureBuilder( + future: c.picture(pubkey), + builder: (context, snap) => PeerAvatar( + pubkey: pubkey, + name: name, + picture: snap.data, + radius: radius, + ), + ); + } +} + +/// A deterministic, readable avatar colour derived from [pubkey]. Hashes the +/// key to a hue, then fixes saturation/lightness so white text stays legible on +/// top. Pure and stable — exposed for testing. +Color peerAvatarColor(String pubkey) { + // FNV-1a over the code units — cheap, stable, well-spread across hues. + var hash = 0x811c9dc5; + for (final unit in pubkey.codeUnits) { + hash = (hash ^ unit) * 0x01000193; + hash &= 0xffffffff; + } + final hue = (hash % 360).toDouble(); + return HSLColor.fromAHSL(1, hue, 0.45, 0.42).toColor(); +} diff --git a/apps/app_seeds/lib/ui/photo_crop.dart b/apps/app_seeds/lib/ui/photo_crop.dart new file mode 100644 index 0000000..a9e55d4 --- /dev/null +++ b/apps/app_seeds/lib/ui/photo_crop.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +import 'package:crop_your_image/crop_your_image.dart'; +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// Lets the user square-crop the picked [bytes] before it's saved as an avatar. +/// Returns the cropped image bytes, or null if cancelled. Pure Flutter (works on +/// every platform, desktop included) — no native cropper, no plaintext on disk. +Future cropToSquare(BuildContext context, Uint8List bytes) { + return Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => _CropPage(bytes: bytes), + ), + ); +} + +class _CropPage extends StatefulWidget { + const _CropPage({required this.bytes}); + + final Uint8List bytes; + + @override + State<_CropPage> createState() => _CropPageState(); +} + +class _CropPageState extends State<_CropPage> { + final _controller = CropController(); + var _cropping = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + leading: IconButton( + key: const Key('crop.cancel'), + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + if (_cropping) + const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + IconButton( + key: const Key('crop.confirm'), + icon: const Icon(Icons.check), + onPressed: () { + setState(() => _cropping = true); + _controller.crop(); + }, + ), + ], + ), + body: Crop( + image: widget.bytes, + controller: _controller, + aspectRatio: 1, + withCircleUi: true, + baseColor: Colors.black, + maskColor: Colors.black.withValues(alpha: 0.6), + cornerDotBuilder: (size, edgeAlignment) => + const DotControl(color: seedGreen), + onCropped: (result) { + if (!mounted) return; + switch (result) { + case CropSuccess(:final croppedImage): + Navigator.of(context).pop(croppedImage); + case CropFailure(): + setState(() => _cropping = false); + } + }, + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/plantare_propose_sheet.dart b/apps/app_seeds/lib/ui/plantare_propose_sheet.dart new file mode 100644 index 0000000..d317e8e --- /dev/null +++ b/apps/app_seeds/lib/ui/plantare_propose_sheet.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import '../i18n/strings.g.dart'; +import '../services/plantare_service.dart'; +import '../services/profile_cache.dart' show shortPubkey; +import 'theme.dart'; + +/// Opens the "propose a signed Plantaré" sheet against a specific peer, whose +/// [peerPubkey] is already in hand (the flow starts from a chat or a closed +/// offer). The seed is picked from the inventory ([repository]) so the promise +/// is tied to a real `Variety`; a new name creates one. Fills the terms, +/// self-signs, and sends via [PlantareService]; the peer counter-signs to close +/// it. When [varietyId]/[seedLabel] are given (opened from a seed) that seed is +/// preselected. Returns true if a proposal was sent. +Future showProposePlantareSheet( + BuildContext context, { + required PlantareService service, + required VarietyRepository repository, + required String peerPubkey, + String? peerName, + String? varietyId, + String? seedLabel, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _ProposeSheet( + service: service, + repository: repository, + peerPubkey: peerPubkey, + peerName: peerName, + varietyId: varietyId, + seedLabel: seedLabel, + ), + ); +} + +class _ProposeSheet extends StatefulWidget { + const _ProposeSheet({ + required this.service, + required this.repository, + required this.peerPubkey, + this.peerName, + this.varietyId, + this.seedLabel, + }); + + final PlantareService service; + final VarietyRepository repository; + final String peerPubkey; + final String? peerName; + final String? varietyId; + final String? seedLabel; + + @override + State<_ProposeSheet> createState() => _ProposeSheetState(); +} + +class _ProposeSheetState extends State<_ProposeSheet> { + // Default: I gave the seed and they'll return some (I'm the creditor) — the + // common "I'm sharing seed" case. + PlantareDirection _direction = PlantareDirection.owedToMe; + PlantareReturnKind _returnKind = PlantareReturnKind.similar; + final _owed = TextEditingController(); + final _hours = TextEditingController(); + DateTime? _dueBy; + bool _saving = false; + + /// The user's catalogued seeds (id + label), for the picker (empty until + /// loaded). A one-shot load, not a live stream — a transient sheet must not + /// hold a Drift subscription. + List<({String id, String label})> _varieties = []; + + /// The picked seed's id — set when the typed name matches (or is chosen from) + /// the inventory, cleared when the text is edited to something else. Null → + /// the name will create a new Variety on send, so the promise is always tied + /// to a real seed. + String? _varietyId; + + /// Autocomplete owns the field's controller; captured here to read on send + /// and to seed the initial text. + TextEditingController? _seedController; + + @override + void initState() { + super.initState(); + _varietyId = widget.varietyId; + _loadVarieties(); + } + + Future _loadVarieties() async { + final items = await widget.repository.varietyLabels(); + if (!mounted) return; + setState(() => _varieties = items); + } + + @override + void dispose() { + _owed.dispose(); + _hours.dispose(); + super.dispose(); + } + + String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); + + /// Resolves the typed seed name to a real Variety id: an existing one when the + /// name matches the inventory, otherwise a freshly created quick variety — so + /// the Plantaré is always linked to a seed. + Future _resolveVarietyId(String label) async { + if (_varietyId != null) return _varietyId!; + final match = _varieties.where( + (v) => v.label.trim().toLowerCase() == label.toLowerCase(), + ); + if (match.isNotEmpty) return match.first.id; + return widget.repository.addQuickVariety(label: label); + } + + Future _pickDueBy() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _dueBy ?? DateTime(now.year + 1, now.month, now.day), + firstDate: DateTime(now.year - 1), + lastDate: DateTime(now.year + 10), + ); + if (picked != null) setState(() => _dueBy = picked); + } + + Future _send() async { + final label = (_seedController?.text ?? widget.seedLabel ?? '').trim(); + if (label.isEmpty) return; // the seed name is the one thing we need + setState(() => _saving = true); + final varietyId = await _resolveVarietyId(label); + await widget.service.propose( + direction: _direction, + counterpartyKey: widget.peerPubkey, + counterpartyName: widget.peerName, + varietyId: varietyId, + label: label, + owedDescription: _nullIfBlank(_owed.text), + returnKind: _returnKind, + workHours: _returnKind == PlantareReturnKind.workHours + ? double.tryParse(_hours.text.trim().replaceAll(',', '.')) + : null, + dueBy: _dueBy, + ); + if (mounted) Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final peer = widget.peerName ?? shortPubkey(widget.peerPubkey); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(t.plantare.propose, + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 4), + Text(t.plantare.proposeTo(name: peer), + style: const TextStyle(color: seedGreen, fontSize: 13)), + const SizedBox(height: 8), + Text(t.plantare.proposeHelp, + style: const TextStyle(color: seedMuted, fontSize: 13)), + const SizedBox(height: 16), + Text(t.plantare.direction, + style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + for (final d in PlantareDirection.values) + ListTile( + key: Key('propose.direction.${d.name}'), + contentPadding: EdgeInsets.zero, + leading: Icon( + _direction == d + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: _direction == d ? seedGreen : seedMuted, + ), + title: Text(d == PlantareDirection.iReturn + ? t.plantare.iReturn + : t.plantare.owedToMe), + onTap: () => setState(() => _direction = d), + ), + const SizedBox(height: 8), + // Pick the seed from the inventory so the promise is tied to a real + // Variety; typing a new name creates one on send. + Autocomplete<({String id, String label})>( + initialValue: TextEditingValue(text: widget.seedLabel ?? ''), + displayStringForOption: (v) => v.label, + optionsBuilder: (value) { + final q = value.text.trim().toLowerCase(); + if (q.isEmpty) return _varieties; + return _varieties + .where((v) => v.label.toLowerCase().contains(q)); + }, + onSelected: (v) => setState(() => _varietyId = v.id), + fieldViewBuilder: + (context, controller, focusNode, onFieldSubmitted) { + _seedController = controller; + return TextField( + key: const Key('propose.seed'), + controller: controller, + focusNode: focusNode, + textCapitalization: TextCapitalization.sentences, + onChanged: (_) { + // Editing away from a picked seed unlinks it (a new name + // will create its own Variety). + if (_varietyId != null) setState(() => _varietyId = null); + }, + onSubmitted: (_) => onFieldSubmitted(), + decoration: InputDecoration( + labelText: t.plantare.seedLabel, + helperText: t.plantare.seedHint, + border: const OutlineInputBorder(), + ), + ); + }, + ), + const SizedBox(height: 16), + Text(t.plantare.returnKindLabel, + style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + _ReturnChoice( + value: PlantareReturnKind.similar, + group: _returnKind, + title: t.plantare.returnSimilar, + note: t.plantare.returnSimilarNote, + onTap: (v) => setState(() => _returnKind = v), + ), + _ReturnChoice( + value: PlantareReturnKind.workHours, + group: _returnKind, + title: t.plantare.returnWork, + onTap: (v) => setState(() => _returnKind = v), + ), + _ReturnChoice( + value: PlantareReturnKind.other, + group: _returnKind, + title: t.plantare.returnOther, + onTap: (v) => setState(() => _returnKind = v), + ), + if (_returnKind == PlantareReturnKind.workHours) ...[ + const SizedBox(height: 8), + TextField( + key: const Key('propose.hours'), + controller: _hours, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: t.plantare.workHoursLabel, + border: const OutlineInputBorder(), + ), + ), + ], + const SizedBox(height: 12), + TextField( + key: const Key('propose.owed'), + controller: _owed, + decoration: InputDecoration( + labelText: t.plantare.owed, + helperText: t.plantare.owedHint, + helperMaxLines: 2, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + InputDecorator( + decoration: InputDecoration( + labelText: t.plantare.dueByLabel, + helperText: t.plantare.dueByHint, + border: const OutlineInputBorder(), + ), + child: Row( + children: [ + Expanded( + child: Text( + _dueBy == null + ? t.plantare.pickDate + : MaterialLocalizations.of(context) + .formatShortDate(_dueBy!), + style: TextStyle( + color: _dueBy == null ? seedMuted : seedOnSurface, + ), + ), + ), + if (_dueBy != null) + IconButton( + icon: const Icon(Icons.clear, size: 20), + tooltip: t.plantare.clearDate, + onPressed: () => setState(() => _dueBy = null), + ), + IconButton( + key: const Key('propose.dueBy.pick'), + icon: const Icon(Icons.event_outlined), + tooltip: t.plantare.pickDate, + onPressed: _pickDueBy, + ), + ], + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + key: const Key('propose.send'), + onPressed: _saving ? null : _send, + icon: const Icon(Icons.draw_outlined), + label: Text(t.plantare.propose), + ), + ], + ), + ), + ); + } +} + +class _ReturnChoice extends StatelessWidget { + const _ReturnChoice({ + required this.value, + required this.group, + required this.title, + required this.onTap, + this.note, + }); + + final PlantareReturnKind value; + final PlantareReturnKind group; + final String title; + final String? note; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final selected = value == group; + return ListTile( + key: Key('propose.return.${value.name}'), + contentPadding: EdgeInsets.zero, + leading: Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: selected ? seedGreen : seedMuted, + ), + title: Text(title), + subtitle: note == null + ? null + : Text(note!, style: const TextStyle(color: seedMuted, fontSize: 12)), + onTap: () => onTap(value), + ); + } +} diff --git a/apps/app_seeds/lib/ui/plantare_sheet.dart b/apps/app_seeds/lib/ui/plantare_sheet.dart new file mode 100644 index 0000000..7f88be6 --- /dev/null +++ b/apps/app_seeds/lib/ui/plantare_sheet.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import '../i18n/strings.g.dart'; +import 'theme.dart'; + +/// Opens the "add a Plantare" sheet — a reproduction commitment. Optionally +/// pre-attached to [varietyId] (when opened from a variety's detail). +Future showPlantareSheet( + BuildContext context, { + required VarietyRepository repository, + String? varietyId, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _PlantareSheet(repository: repository, varietyId: varietyId), + ); +} + +class _PlantareSheet extends StatefulWidget { + const _PlantareSheet({required this.repository, this.varietyId}); + + final VarietyRepository repository; + final String? varietyId; + + @override + State<_PlantareSheet> createState() => _PlantareSheetState(); +} + +class _PlantareSheetState extends State<_PlantareSheet> { + PlantareDirection _direction = PlantareDirection.iReturn; + final _counterparty = TextEditingController(); + final _owed = TextEditingController(); + final _note = TextEditingController(); + DateTime? _dueBy; + bool _saving = false; + + @override + void dispose() { + _counterparty.dispose(); + _owed.dispose(); + _note.dispose(); + super.dispose(); + } + + String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); + + Future _pickDueBy() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _dueBy ?? DateTime(now.year + 1, now.month, now.day), + firstDate: DateTime(now.year - 1), + lastDate: DateTime(now.year + 10), + ); + if (picked != null) setState(() => _dueBy = picked); + } + + Future _save() async { + setState(() => _saving = true); + await widget.repository.createPlantare( + direction: _direction, + varietyId: widget.varietyId, + counterparty: _nullIfBlank(_counterparty.text), + owedDescription: _nullIfBlank(_owed.text), + dueBy: _dueBy?.millisecondsSinceEpoch, + note: _nullIfBlank(_note.text), + ); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(t.plantare.add, + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 4), + Text(t.plantare.help, + style: const TextStyle(color: seedMuted, fontSize: 13)), + const SizedBox(height: 16), + Text(t.plantare.direction, + style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + // Two big, human choices — no jargon. + for (final d in PlantareDirection.values) + ListTile( + key: Key('plantare.direction.${d.name}'), + contentPadding: EdgeInsets.zero, + leading: Icon( + _direction == d + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: _direction == d ? seedGreen : seedMuted, + ), + title: Text(d == PlantareDirection.iReturn + ? t.plantare.iReturn + : t.plantare.owedToMe), + onTap: () => setState(() => _direction = d), + ), + const SizedBox(height: 8), + TextField( + key: const Key('plantare.counterparty'), + controller: _counterparty, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: t.plantare.counterparty, + helperText: t.plantare.counterpartyHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + key: const Key('plantare.owed'), + controller: _owed, + decoration: InputDecoration( + labelText: t.plantare.owed, + helperText: t.plantare.owedHint, + helperMaxLines: 2, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + // Optional return-by date — a gentle reminder, never enforced. + InputDecorator( + decoration: InputDecoration( + labelText: t.plantare.dueByLabel, + helperText: t.plantare.dueByHint, + border: const OutlineInputBorder(), + ), + child: Row( + children: [ + Expanded( + child: Text( + _dueBy == null + ? t.plantare.pickDate + : MaterialLocalizations.of(context) + .formatShortDate(_dueBy!), + style: TextStyle( + color: _dueBy == null ? seedMuted : seedOnSurface, + ), + ), + ), + if (_dueBy != null) + IconButton( + key: const Key('plantare.dueBy.clear'), + icon: const Icon(Icons.clear, size: 20), + tooltip: t.plantare.clearDate, + onPressed: () => setState(() => _dueBy = null), + ), + IconButton( + key: const Key('plantare.dueBy.pick'), + icon: const Icon(Icons.event_outlined), + tooltip: t.plantare.pickDate, + onPressed: _pickDueBy, + ), + ], + ), + ), + const SizedBox(height: 12), + TextField( + key: const Key('plantare.note'), + controller: _note, + minLines: 1, + maxLines: 3, + decoration: InputDecoration( + labelText: t.plantare.note, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 20), + FilledButton( + key: const Key('plantare.save'), + onPressed: _saving ? null : _save, + child: Text(t.plantare.save), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/plantares_screen.dart b/apps/app_seeds/lib/ui/plantares_screen.dart new file mode 100644 index 0000000..5140802 --- /dev/null +++ b/apps/app_seeds/lib/ui/plantares_screen.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import '../di/injector.dart'; +import '../i18n/strings.g.dart'; +import '../services/plantare_service.dart'; +import 'plantare_sheet.dart'; +import 'theme.dart'; + +/// The Plantares screen — your reproduction commitments (data-model §2.7). +/// Lists what's open (still to grow out & return) and what's done, and lets you +/// add or settle one. Local-first; reads the encrypted inventory DB. +class PlantaresScreen extends StatelessWidget { + const PlantaresScreen({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + final repo = context.read(); + final service = getIt.isRegistered() + ? getIt() + : null; + return Scaffold( + appBar: AppBar(title: Text(t.plantare.title)), + floatingActionButton: FloatingActionButton.extended( + key: const Key('plantares.add'), + onPressed: () => showPlantareSheet(context, repository: repo), + icon: const Icon(Icons.add), + label: Text(t.plantare.add), + ), + body: StreamBuilder>( + stream: repo.watchPlantareViews(), + builder: (context, snapshot) { + final all = snapshot.data; + if (all == null) { + return const Center(child: CircularProgressIndicator()); + } + if (all.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + t.plantare.empty, + textAlign: TextAlign.center, + style: const TextStyle(color: seedMuted, fontSize: 15), + ), + ), + ); + } + final open = + all.where((v) => v.plantare.status == PlantareStatus.open); + final settled = + all.where((v) => v.plantare.status != PlantareStatus.open); + return ListView( + padding: const EdgeInsets.only(bottom: 88), + children: [ + if (open.isNotEmpty) _SectionHeader(t.plantare.openSection), + for (final v in open) _PlantareTile(v, repo, service), + if (settled.isNotEmpty) _SectionHeader(t.plantare.settledSection), + for (final v in settled) _PlantareTile(v, repo, service), + ], + ); + }, + ), + ); + } +} + +/// A small chip for the bilateral handshake state — "awaiting signature", +/// "signed by both", or "declined". Absent for a plain v1 local note. +class _RemoteStateBadge extends StatelessWidget { + const _RemoteStateBadge(this.state); + final PlantareRemoteState state; + + @override + Widget build(BuildContext context) { + final t = context.t; + final (label, color, icon) = switch (state) { + PlantareRemoteState.proposed => ( + t.plantare.badgeAwaiting, + seedMuted, + Icons.hourglass_empty, + ), + PlantareRemoteState.accepted => ( + t.plantare.badgeSigned, + seedGreen, + Icons.verified_outlined, + ), + PlantareRemoteState.declined => ( + t.plantare.badgeDeclined, + seedWarning, + Icons.do_not_disturb_on_outlined, + ), + }; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + color: color, fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.label); + final String label; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + label.toUpperCase(), + style: const TextStyle( + color: seedMuted, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); +} + +/// One commitment row. An open one shows a big "mark returned"; a settled one +/// reads muted. It names the seed and (when linked) taps through to its detail, +/// shows when it was made and — for a promise with a return-by date — whether +/// it's still ahead or overdue. The overflow menu carries the rest (let go / +/// reopen / remove). +class _PlantareTile extends StatelessWidget { + const _PlantareTile(this.view, this.repo, this.service); + + final PlantareView view; + final VarietyRepository repo; + final PlantareService? service; + + /// A proposal is "awaiting me" (I still have to accept/decline) when the + /// handshake is `proposed` and MY stub is missing — my slot is the debtor one + /// if I'm the one who returns, else the creditor one. + bool get _awaitingMe { + final p = view.plantare; + if (p.remoteState != PlantareRemoteState.proposed) return false; + final mySig = p.direction == PlantareDirection.iReturn + ? p.debtorSignature + : p.creditorSignature; + return mySig == null; + } + + Future _accept(BuildContext context) async { + final id = view.plantare.pledgeId; + if (service == null || id == null) return; + final t = context.t; + try { + await service!.accept(id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.plantare.acceptedToast)), + ); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.plantare.offline)), + ); + } + } + } + + Future _decline(BuildContext context) async { + final id = view.plantare.pledgeId; + if (service == null || id == null) return; + await service!.decline(id); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final p = view.plantare; + final varietyLabel = view.varietyLabel; + final done = p.status != PlantareStatus.open; + final iReturn = p.direction == PlantareDirection.iReturn; + final title = (p.owedDescription?.trim().isNotEmpty ?? false) + ? p.owedDescription!.trim() + : (iReturn ? t.plantare.iReturn : t.plantare.owedToMe); + final locals = MaterialLocalizations.of(context); + final made = locals + .formatShortDate(DateTime.fromMillisecondsSinceEpoch(p.madeOn)); + final subtitleParts = [ + iReturn ? t.plantare.iReturn : t.plantare.owedToMe, + ?varietyLabel, + if (p.counterparty?.trim().isNotEmpty ?? false) p.counterparty!.trim(), + made, + if (done) + p.status == PlantareStatus.returned + ? t.plantare.statusReturned + : t.plantare.statusForgiven, + ]; + // A return-by date is a gentle nudge, not a debt: only an open, past-due + // promise reads as overdue. + final overdue = !done && + p.dueBy != null && + p.dueBy! < DateTime.now().millisecondsSinceEpoch; + final returnBy = p.dueBy == null + ? null + : t.plantare.returnBy( + date: locals.formatShortDate( + DateTime.fromMillisecondsSinceEpoch(p.dueBy!), + ), + ); + final linkedId = varietyLabel == null ? null : p.varietyId; + return ListTile( + onTap: linkedId == null ? null : () => context.push('/variety/$linkedId'), + leading: Icon( + iReturn ? Icons.volunteer_activism_outlined : Icons.eco_outlined, + color: done ? seedMuted : seedGreen, + ), + title: Text( + title, + style: TextStyle( + decoration: done ? TextDecoration.lineThrough : null, + color: done ? seedMuted : seedOnSurface, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitleParts.join(' · '), + maxLines: 2, overflow: TextOverflow.ellipsis), + if (returnBy != null) + Text( + overdue ? '$returnBy · ${t.plantare.overdue}' : returnBy, + style: TextStyle( + color: overdue ? seedWarning : seedMuted, + fontWeight: overdue ? FontWeight.w600 : null, + ), + ), + if (p.remoteState != null) _RemoteStateBadge(p.remoteState!), + // An incoming proposal awaiting my signature: sign it or turn it down, + // right here. + if (_awaitingMe && service != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + FilledButton.icon( + key: Key('plantare.accept.${p.id}'), + onPressed: () => _accept(context), + icon: const Icon(Icons.draw_outlined, size: 18), + label: Text(t.plantare.accept), + ), + const SizedBox(width: 8), + TextButton( + key: Key('plantare.decline.${p.id}'), + onPressed: () => _decline(context), + child: Text(t.plantare.declineAction), + ), + ], + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // A returned/mark button only makes sense once the deal is agreed + // (or for a plain local note). Hide it while a proposal is pending. + if (!done && !_awaitingMe) + IconButton( + key: Key('plantare.return.${p.id}'), + icon: const Icon(Icons.check_circle_outline), + tooltip: t.plantare.markReturned, + color: seedGreen, + onPressed: () => + repo.setPlantareStatus(p.id, PlantareStatus.returned), + ), + PopupMenuButton( + key: Key('plantare.menu.${p.id}'), + onSelected: (v) async { + switch (v) { + case 'forgiven': + await repo.setPlantareStatus(p.id, PlantareStatus.forgiven); + case 'reopen': + await repo.setPlantareStatus(p.id, PlantareStatus.open); + case 'delete': + await repo.deletePlantare(p.id); + } + }, + itemBuilder: (context) => [ + if (!done) + PopupMenuItem( + value: 'forgiven', child: Text(t.plantare.markForgiven)), + if (done) + PopupMenuItem( + value: 'reopen', child: Text(t.plantare.reopen)), + PopupMenuItem(value: 'delete', child: Text(t.plantare.delete)), + ], + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/profile_screen.dart b/apps/app_seeds/lib/ui/profile_screen.dart new file mode 100644 index 0000000..8efb586 --- /dev/null +++ b/apps/app_seeds/lib/ui/profile_screen.dart @@ -0,0 +1,402 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +import '../di/injector.dart' show switchSocialAccount; +import '../i18n/strings.g.dart'; +import '../services/profile_cache.dart' show shortPubkey; +import '../services/social_account_store.dart'; +import '../services/profile_store.dart'; +import '../services/social_connection.dart'; +import '../services/social_service.dart'; +import 'avatar_edit.dart'; +import 'peer_avatar.dart'; +import 'qr_view.dart'; +import 'restart_widget.dart'; +import 'theme.dart'; + +/// Your own profile: the shareable identity (npub) plus a display name and short +/// "about" others see instead of a raw key. Saving persists locally and, when +/// online, publishes NIP-01 kind:0 metadata so peers can recognise you. +class ProfileScreen extends StatefulWidget { + const ProfileScreen({ + required this.social, + required this.connection, + required this.profileStore, + required this.accounts, + this.yourPeopleEnabled = false, + super.key, + }); + + final SocialService social; + + /// The shared relay connection, for publishing your profile. + final SocialConnection connection; + final ProfileStore profileStore; + final SocialAccountStore accounts; + + /// Whether to offer the "Your people" entry (needs the social layer). + final bool yourPeopleEnabled; + + @override + State createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends State { + final _name = TextEditingController(); + final _about = TextEditingController(); + final _g1 = TextEditingController(); + String _avatar = ''; + bool _loading = true; + bool _saving = false; + bool _switching = false; + + /// The identities of this root seed (account index + its npub), for the + /// switcher. Always includes the active one. + List<({int account, String npub})> _identities = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + _name.text = await widget.profileStore.name(); + _about.text = await widget.profileStore.about(); + _g1.text = await widget.profileStore.g1(); + _avatar = await widget.profileStore.avatar(); + final max = await widget.accounts.maxCreated(); + final ids = <({int account, String npub})>[]; + for (var i = 0; i <= max; i++) { + ids.add((account: i, npub: await widget.social.npubForAccount(i) ?? '')); + } + if (mounted) { + setState(() { + _identities = ids; + _loading = false; + }); + } + } + + /// Switches to (or creates) an identity and rebuilds the app on the new one. + Future _switchTo(int account, {bool confirm = true}) async { + if (account == widget.social.account || _switching) return; + final t = context.t; + if (confirm) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.profile.switchTitle), + content: Text(t.profile.switchBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.common.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.profile.switchAction)), + ], + ), + ); + if (ok != true) return; + } + if (!mounted) return; + setState(() => _switching = true); + await switchSocialAccount(account); + if (mounted) RestartWidget.restart(context); + } + + Future _newIdentity() async { + final next = await widget.accounts.maxCreated() + 1; + await _switchTo(next, confirm: false); // creating one is already explicit + } + + Future _editAvatar() async { + final result = await showAvatarPicker(context, current: _avatar); + if (result == null || !mounted) return; + setState(() => _avatar = result); + // Persist + publish right away: an avatar change shouldn't depend on + // finding the Save button further down the screen. + await _save(); + } + + Future _copyId() async { + final messenger = ScaffoldMessenger.of(context); + final t = context.t; + await Clipboard.setData(ClipboardData(text: widget.social.npub)); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(t.profile.copied))); + } + + Future _save() async { + final messenger = ScaffoldMessenger.of(context); + final t = context.t; + setState(() => _saving = true); + await widget.profileStore.save( + name: _name.text, about: _about.text, g1: _g1.text, avatar: _avatar); + + // Best-effort publish over the shared connection so peers see the name. + try { + final session = await widget.connection.session(); + await session?.profile.publish( + name: _name.text.trim(), + about: _about.text.trim(), + picture: _avatar, + g1: _g1.text.trim(), + ); + } catch (_) { + // offline / unreachable — the local copy is saved regardless. + } + if (!mounted) return; + setState(() => _saving = false); + messenger.showSnackBar(SnackBar(content: Text(t.profile.saved))); + } + + @override + void dispose() { + _name.dispose(); + _about.dispose(); + _g1.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + return Scaffold( + appBar: AppBar(title: Text(t.profile.title)), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(20), + children: [ + Center( + child: GestureDetector( + key: const Key('profile.avatar'), + onTap: _editAvatar, + child: Stack( + alignment: AlignmentDirectional.bottomEnd, + children: [ + PeerAvatar( + pubkey: widget.social.publicKeyHex, + name: _name.text, + picture: _avatar, + radius: 44, + ), + Container( + padding: const EdgeInsets.all(5), + decoration: const BoxDecoration( + color: seedGreen, + shape: BoxShape.circle, + ), + child: const Icon(Icons.edit, + size: 15, color: Colors.white), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + _IdentityCard(npub: widget.social.npub, onCopy: _copyId), + const SizedBox(height: 24), + TextField( + key: const Key('profile.name'), + controller: _name, + decoration: InputDecoration( + labelText: t.profile.name, + helperText: t.profile.nameHint, + ), + ), + const SizedBox(height: 16), + TextField( + key: const Key('profile.about'), + controller: _about, + minLines: 2, + maxLines: 4, + decoration: InputDecoration( + labelText: t.profile.about, + helperText: t.profile.aboutHint, + ), + ), + const SizedBox(height: 16), + TextField( + key: const Key('profile.g1'), + controller: _g1, + decoration: InputDecoration( + labelText: t.profile.g1, + helperText: t.profile.g1Hint, + helperMaxLines: 2, + ), + ), + const SizedBox(height: 24), + FilledButton( + key: const Key('profile.save'), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.profile.save), + ), + const SizedBox(height: 28), + const Divider(), + const SizedBox(height: 12), + _IdentitiesSection( + identities: _identities, + activeAccount: widget.social.account, + busy: _switching, + onSwitch: (a) => _switchTo(a), + onNew: _newIdentity, + ), + if (widget.yourPeopleEnabled) ...[ + const SizedBox(height: 12), + const Divider(), + ListTile( + key: const Key('profile.yourPeople'), + contentPadding: EdgeInsets.zero, + leading: + const Icon(Icons.people_alt_outlined, color: seedGreen), + title: Text(t.yourPeople.title), + trailing: const Icon(Icons.chevron_right, color: seedMuted), + onTap: () => context.push('/your-people'), + ), + ], + ], + ), + ); + } +} + +/// The identity switcher: every pseudonymous identity of this root seed, with +/// the active one marked, plus "new identity". Switching keeps chats/contacts +/// separate per identity; all regenerate from the single backup. +class _IdentitiesSection extends StatelessWidget { + const _IdentitiesSection({ + required this.identities, + required this.activeAccount, + required this.busy, + required this.onSwitch, + required this.onNew, + }); + + final List<({int account, String npub})> identities; + final int activeAccount; + final bool busy; + final ValueChanged onSwitch; + final VoidCallback onNew; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.profile.identities, + style: const TextStyle( + fontWeight: FontWeight.w600, color: seedOnSurface), + ), + const SizedBox(height: 4), + Text( + t.profile.identitiesHelp, + style: const TextStyle(color: seedMuted, fontSize: 12), + ), + const SizedBox(height: 8), + for (final id in identities) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + id.account == activeAccount + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: id.account == activeAccount ? seedGreen : seedMuted, + ), + title: Text(t.profile.identityLabel(n: id.account + 1)), + subtitle: id.npub.isEmpty + ? null + : Text(shortPubkey(id.npub), + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12)), + trailing: id.account == activeAccount + ? Text(t.profile.current, + style: const TextStyle(color: seedGreen, fontSize: 12)) + : null, + onTap: busy || id.account == activeAccount + ? null + : () => onSwitch(id.account), + ), + const SizedBox(height: 4), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + key: const Key('profile.newIdentity'), + onPressed: busy ? null : onNew, + icon: const Icon(Icons.add, size: 18), + label: Text(t.profile.newIdentity), + ), + ), + ], + ); + } +} + +class _IdentityCard extends StatelessWidget { + const _IdentityCard({required this.npub, required this.onCopy}); + + final String npub; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: seedPrimaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: seedOutline), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.profile.yourId, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: seedOnSurface, + ), + ), + const SizedBox(height: 4), + Text( + t.profile.idHelp, + style: const TextStyle(color: seedMuted, fontSize: 12), + ), + const SizedBox(height: 14), + Center(child: QrView(data: npub, size: 200)), + const SizedBox(height: 12), + SelectableText( + npub, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: seedOnSurface, + ), + ), + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton.icon( + key: const Key('profile.copyId'), + onPressed: onCopy, + icon: const Icon(Icons.copy, size: 16), + label: Text(t.profile.copy), + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/qr_view.dart b/apps/app_seeds/lib/ui/qr_view.dart new file mode 100644 index 0000000..970f4ea --- /dev/null +++ b/apps/app_seeds/lib/ui/qr_view.dart @@ -0,0 +1,54 @@ +import 'package:barcode/barcode.dart'; +import 'package:flutter/material.dart'; + +/// Renders [data] as a QR code, painted from the pure-Dart `barcode` matrix (no +/// native plugin). Used for the profile's shareable identity — hand your npub to +/// someone at a fair with a scan. +class QrView extends StatelessWidget { + const QrView({required this.data, this.size = 200, super.key}); + + final String data; + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + color: Colors.white, + padding: const EdgeInsets.all(8), + child: CustomPaint( + size: Size.square(size), + painter: _QrPainter(data), + ), + ); + } +} + +class _QrPainter extends CustomPainter { + _QrPainter(this.data); + + final String data; + + @override + void paint(Canvas canvas, Size size) { + final black = Paint()..color = Colors.black; + for (final element in Barcode.qrCode() + .make(data, width: size.width, height: size.height)) { + if (element is BarcodeBar && element.black) { + canvas.drawRect( + Rect.fromLTWH( + element.left, + element.top, + element.width, + element.height, + ), + black, + ); + } + } + } + + @override + bool shouldRepaint(_QrPainter oldDelegate) => oldDelegate.data != data; +} diff --git a/apps/app_seeds/lib/ui/quantity_picker.dart b/apps/app_seeds/lib/ui/quantity_picker.dart index 7a88cc9..848940f 100644 --- a/apps/app_seeds/lib/ui/quantity_picker.dart +++ b/apps/app_seeds/lib/ui/quantity_picker.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../db/enums.dart'; import '../i18n/strings.g.dart'; +import 'category_palette.dart'; import 'quantity_kind_l10n.dart'; import 'seed_glyph.dart'; import 'theme.dart'; @@ -76,18 +77,28 @@ class LotTypeSelector extends StatelessWidget { runSpacing: 8, children: [ for (final type in LotType.values) - ChoiceChip( - key: Key('lotType.${type.name}'), - selected: value == type, - onSelected: (_) => onChanged(type), - avatar: type == LotType.seed - ? const SeedGlyph(SeedGlyphs.jars, size: 18) - : Icon(iconForLotType(type), size: 18), - label: Text(lotTypeLabel(t, type)), - ), + _buildTypeChip(t, type), ], ); } + + Widget _buildTypeChip(Translations t, LotType type) { + final swatch = lotTypeSwatch(type); + final selected = value == type; + return ChoiceChip( + key: Key('lotType.${type.name}'), + selected: selected, + onSelected: (_) => onChanged(type), + avatar: type == LotType.seed + ? const SeedGlyph(SeedGlyphs.jars, size: 18) + : Icon(iconForLotType(type), size: 18, color: swatch.ink), + label: Text(lotTypeLabel(t, type)), + backgroundColor: swatch.fill, + selectedColor: swatch.ink.withValues(alpha: 0.24), + side: BorderSide(color: swatch.ink.withValues(alpha: 0.35)), + labelStyle: TextStyle(color: swatch.ink, fontWeight: FontWeight.w500), + ); + } } /// Optional packaging selector for living lots. Tapping the selected chip again @@ -189,19 +200,29 @@ class _QuantityPickerState extends State { widget.onChanged(null); return; } - final n = num.tryParse(_countController.text.trim().replaceAll(',', '.')); - widget.onChanged(Quantity(kind: kind, count: kind.countable ? n : null)); + // A countable unit always carries a number ≥ 1 — "3 cobs", never an empty + // "cob". Blank/zero/garbage while typing falls back to 1, so you can never + // save a countable amount with no figure. + final parsed = num.tryParse(_countController.text.trim().replaceAll(',', '.')); + final count = kind.countable ? ((parsed == null || parsed < 1) ? 1 : parsed) : null; + widget.onChanged(Quantity(kind: kind, count: count)); } void _select(QuantityKind kind) { - setState(() => _kind = kind); + setState(() { + _kind = kind; + // Selecting a countable unit with no number yet defaults it to 1. + if (kind.countable && (num.tryParse(_countController.text.trim()) == null)) { + _countController.text = '1'; + } + }); _emit(); } void _bump(int delta) { - final current = int.tryParse(_countController.text.trim()) ?? 0; - final next = (current + delta).clamp(0, 9999); - _countController.text = next == 0 ? '' : '$next'; + final current = int.tryParse(_countController.text.trim()) ?? 1; + final next = (current + delta).clamp(1, 9999); + _countController.text = '$next'; _emit(); } @@ -219,6 +240,7 @@ class _QuantityPickerState extends State { children: [ for (final kind in kinds) _ScaleItem( + key: Key('quantity.kind.${kind.name}'), kind: kind, label: unitLabel(t, kind), selected: _kind == kind, @@ -273,6 +295,7 @@ class _ScaleItem extends StatelessWidget { required this.label, required this.selected, required this.onTap, + super.key, }); final QuantityKind kind; diff --git a/apps/app_seeds/lib/ui/rating_sheet.dart b/apps/app_seeds/lib/ui/rating_sheet.dart new file mode 100644 index 0000000..4c10e48 --- /dev/null +++ b/apps/app_seeds/lib/ui/rating_sheet.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../i18n/strings.g.dart'; +import '../state/peer_rating_cubit.dart'; +import 'theme.dart'; + +/// A compact "★ 4,5 (12)" pill, plus how many ratings come from people you +/// know — the signal that makes strangers' reviews easy to weigh. +class RatingStars extends StatelessWidget { + const RatingStars({ + required this.average, + required this.count, + this.circleCount = 0, + super.key, + }); + + final double average; + final int count; + final int circleCount; + + @override + Widget build(BuildContext context) { + final t = context.t; + final locale = Localizations.localeOf(context).toString(); + final avg = NumberFormat('0.#', locale).format(average); + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + children: [ + const Icon(Icons.star_rounded, size: 18, color: seedGreen), + Text( + '$avg ($count)', + style: const TextStyle( + color: seedOnSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (circleCount > 0) + Text( + t.ratings.fromYourCircle(n: circleCount), + style: const TextStyle(color: seedGreen, fontSize: 13), + ), + ], + ); + } +} + +/// Opens the bottom sheet to leave, edit or take back your rating of the +/// peer behind [cubit]. Pre-filled when you already rated. +Future showRatingSheet(BuildContext context, PeerRatingCubit cubit) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: _RatingForm(cubit: cubit), + ), + ); +} + +class _RatingForm extends StatefulWidget { + const _RatingForm({required this.cubit}); + + final PeerRatingCubit cubit; + + @override + State<_RatingForm> createState() => _RatingFormState(); +} + +class _RatingFormState extends State<_RatingForm> { + late int _stars; + late final TextEditingController _comment; + bool _saving = false; + + @override + void initState() { + super.initState(); + final state = widget.cubit.state; + _stars = state.myStars ?? 0; + _comment = TextEditingController(text: state.myComment); + } + + @override + void dispose() { + _comment.dispose(); + super.dispose(); + } + + Future _save() async { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final saved = context.t.ratings.saved; + setState(() => _saving = true); + await widget.cubit.rate(_stars, comment: _comment.text.trim()); + navigator.pop(); + messenger.showSnackBar(SnackBar(content: Text(saved))); + } + + Future _retract() async { + final navigator = Navigator.of(context); + setState(() => _saving = true); + await widget.cubit.retract(); + navigator.pop(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final iRated = widget.cubit.state.iRated; + return Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + iRated ? t.ratings.edit : t.ratings.rate, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: seedTitle, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var s = 1; s <= 5; s++) + IconButton( + key: Key('rating.star.$s'), + iconSize: 36, + onPressed: + _saving ? null : () => setState(() => _stars = s), + icon: Icon( + s <= _stars + ? Icons.star_rounded + : Icons.star_outline_rounded, + color: s <= _stars ? seedGreen : seedMuted, + ), + ), + ], + ), + const SizedBox(height: 8), + TextField( + key: const Key('rating.comment'), + controller: _comment, + enabled: !_saving, + maxLength: 280, + maxLines: 2, + decoration: InputDecoration( + hintText: t.ratings.commentHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + if (iRated) + TextButton( + key: const Key('rating.retract'), + onPressed: _saving ? null : _retract, + child: Text(t.ratings.retract), + ), + const Spacer(), + FilledButton( + key: const Key('rating.save'), + onPressed: _saving || _stars == 0 ? null : _save, + child: Text(t.common.save), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/report_sheet.dart b/apps/app_seeds/lib/ui/report_sheet.dart new file mode 100644 index 0000000..ca4ae05 --- /dev/null +++ b/apps/app_seeds/lib/ui/report_sheet.dart @@ -0,0 +1,193 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import '../services/social_connection.dart'; +import '../services/social_settings.dart'; +import 'theme.dart'; + +/// What a submitted report did, so the caller can react (hide the offer, +/// leave the chat when the person was also blocked). +typedef ReportOutcome = ({bool sent, bool blocked}); + +/// Opens the report sheet for a person (and optionally one of their offers, +/// via [offerAddress] `kind:pubkey:d`). Publishes a standard report to the +/// user's community servers and, when ticked, also blocks the person locally. +/// Returns null when dismissed without sending. +Future showReportSheet( + BuildContext context, { + required SocialConnection connection, + required String subjectPubkey, + String? offerAddress, + SocialSettings? settings, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ReportSheet( + connection: connection, + subjectPubkey: subjectPubkey, + offerAddress: offerAddress, + settings: settings, + ), + ); +} + +/// The report form: what's wrong (mapped onto the standard report categories), +/// optional details, and an optional "also block" tick. +class ReportSheet extends StatefulWidget { + const ReportSheet({ + required this.connection, + required this.subjectPubkey, + this.offerAddress, + this.settings, + super.key, + }); + + final SocialConnection connection; + final String subjectPubkey; + + /// When reporting one offer (not just the person): its address. + final String? offerAddress; + + /// Enables the "also block this person" option; null hides it. + final SocialSettings? settings; + + @override + State createState() => _ReportSheetState(); +} + +class _ReportSheetState extends State { + ReportReason _reason = ReportReason.spam; + bool _alsoBlock = false; + bool _sending = false; + final _details = TextEditingController(); + + @override + void dispose() { + _details.dispose(); + super.dispose(); + } + + Future _submit() async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + setState(() => _sending = true); + try { + final session = await widget.connection.session(); + if (session == null) throw StateError('offline'); + await session.reports.report( + subjectPubkey: widget.subjectPubkey, + eventAddress: widget.offerAddress, + reason: _reason, + details: _details.text.trim(), + ); + var blocked = false; + if (_alsoBlock && widget.settings != null) { + await widget.settings!.block(widget.subjectPubkey); + blocked = true; + } + if (!mounted) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(t.report.sentHidden))); + Navigator.of(context).pop((sent: true, blocked: blocked)); + } catch (_) { + if (!mounted) return; + setState(() => _sending = false); + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(t.report.failed))); + } + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final reasons = <(ReportReason, String)>[ + (ReportReason.spam, t.report.reasonSpam), + (ReportReason.profanity, t.report.reasonAbuse), + (ReportReason.illegal, t.report.reasonIllegal), + (ReportReason.other, t.report.reasonOther), + ]; + return SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.report.title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + t.report.prompt, + style: const TextStyle(color: seedMuted, fontSize: 13), + ), + const SizedBox(height: 4), + RadioGroup( + groupValue: _reason, + onChanged: _sending + ? (_) {} + : (v) => setState(() => _reason = v ?? _reason), + child: Column( + children: [ + for (final (value, label) in reasons) + RadioListTile( + value: value, + title: Text(label), + dense: true, + contentPadding: EdgeInsets.zero, + ), + ], + ), + ), + TextField( + controller: _details, + enabled: !_sending, + minLines: 1, + maxLines: 3, + decoration: InputDecoration(hintText: t.report.detailsHint), + ), + if (widget.settings != null) + CheckboxListTile( + value: _alsoBlock, + onChanged: _sending + ? null + : (v) => setState(() => _alsoBlock = v ?? false), + title: Text(t.report.alsoBlock), + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerEnd, + child: FilledButton( + key: const Key('report.send'), + onPressed: _sending ? null : _submit, + child: _sending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.report.send), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/restart_widget.dart b/apps/app_seeds/lib/ui/restart_widget.dart new file mode 100644 index 0000000..5ad7bee --- /dev/null +++ b/apps/app_seeds/lib/ui/restart_widget.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +/// Rebuilds its whole subtree from scratch on demand — used to apply a social +/// identity switch, which re-registers the social singletons in the locator and +/// needs the widget tree (router, screens, sessions) rebuilt to pick them up. +/// +/// [builder] is re-invoked on each restart, so it should read its dependencies +/// fresh (e.g. from the service locator) rather than close over stale ones. +class RestartWidget extends StatefulWidget { + const RestartWidget({required this.builder, super.key}); + + final Widget Function() builder; + + /// Tears down and rebuilds everything under the nearest [RestartWidget]. + static void restart(BuildContext context) => + context.findAncestorStateOfType<_RestartWidgetState>()?.restart(); + + @override + State createState() => _RestartWidgetState(); +} + +class _RestartWidgetState extends State { + Key _key = UniqueKey(); + + void restart() => setState(() => _key = UniqueKey()); + + @override + Widget build(BuildContext context) => + KeyedSubtree(key: _key, child: widget.builder()); +} diff --git a/apps/app_seeds/lib/ui/sale_sheet.dart b/apps/app_seeds/lib/ui/sale_sheet.dart new file mode 100644 index 0000000..c4cd68d --- /dev/null +++ b/apps/app_seeds/lib/ui/sale_sheet.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import '../i18n/strings.g.dart'; +import 'currency_quick_picks.dart'; +import 'theme.dart'; + +/// Opens the "record a sale" sheet. Optionally pre-attached to [varietyId] +/// (when opened from a variety's detail). +Future showSaleSheet( + BuildContext context, { + required VarietyRepository repository, + String? varietyId, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _SaleSheet(repository: repository, varietyId: varietyId), + ); +} + +class _SaleSheet extends StatefulWidget { + const _SaleSheet({required this.repository, this.varietyId}); + + final VarietyRepository repository; + final String? varietyId; + + @override + State<_SaleSheet> createState() => _SaleSheetState(); +} + +class _SaleSheetState extends State<_SaleSheet> { + SaleDirection _direction = SaleDirection.iSold; + final _counterparty = TextEditingController(); + final _amount = TextEditingController(); + final _currency = TextEditingController(); + final _note = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _counterparty.dispose(); + _amount.dispose(); + _currency.dispose(); + _note.dispose(); + super.dispose(); + } + + String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); + + Future _save() async { + setState(() => _saving = true); + await widget.repository.createSale( + direction: _direction, + varietyId: widget.varietyId, + counterparty: _nullIfBlank(_counterparty.text), + amount: double.tryParse(_amount.text.trim().replaceAll(',', '.')), + currency: _nullIfBlank(_currency.text), + note: _nullIfBlank(_note.text), + ); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(t.sale.add, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 4), + Text(t.sale.help, + style: const TextStyle(color: seedMuted, fontSize: 13)), + const SizedBox(height: 16), + Text(t.sale.direction, + style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + for (final d in SaleDirection.values) + ListTile( + key: Key('sale.direction.${d.name}'), + contentPadding: EdgeInsets.zero, + leading: Icon( + _direction == d + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: _direction == d ? seedGreen : seedMuted, + ), + title: Text( + d == SaleDirection.iSold ? t.sale.iSold : t.sale.iBought), + onTap: () => setState(() => _direction = d), + ), + const SizedBox(height: 8), + TextField( + key: const Key('sale.counterparty'), + controller: _counterparty, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: t.sale.counterparty, + helperText: t.sale.counterpartyHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + key: const Key('sale.amount'), + controller: _amount, + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + decoration: InputDecoration( + labelText: t.sale.amount, + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + key: const Key('sale.currency'), + controller: _currency, + decoration: InputDecoration( + labelText: t.sale.currency, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 8), + CurrencyQuickPicks( + controller: _currency, + keyPrefix: 'sale', + onChanged: () => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + key: const Key('sale.note'), + controller: _note, + minLines: 1, + maxLines: 3, + decoration: InputDecoration( + labelText: t.sale.note, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 20), + FilledButton( + key: const Key('sale.save'), + onPressed: _saving ? null : _save, + child: Text(t.sale.save), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/sales_screen.dart b/apps/app_seeds/lib/ui/sales_screen.dart new file mode 100644 index 0000000..957bc89 --- /dev/null +++ b/apps/app_seeds/lib/ui/sales_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../data/variety_repository.dart'; +import '../db/database.dart'; +import '../db/enums.dart'; +import '../i18n/strings.g.dart'; +import 'sale_sheet.dart'; +import 'theme.dart'; + +/// The Sales screen — a local ledger of seed sold or bought, in any currency. +/// A model separate from a gift or a Plantare. Reads the encrypted inventory DB. +class SalesScreen extends StatelessWidget { + const SalesScreen({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + final repo = context.read(); + return Scaffold( + appBar: AppBar(title: Text(t.sale.title)), + floatingActionButton: FloatingActionButton.extended( + key: const Key('sales.add'), + onPressed: () => showSaleSheet(context, repository: repo), + icon: const Icon(Icons.add), + label: Text(t.sale.add), + ), + body: StreamBuilder>( + stream: repo.watchSales(), + builder: (context, snapshot) { + final sales = snapshot.data; + if (sales == null) { + return const Center(child: CircularProgressIndicator()); + } + if (sales.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + t.sale.empty, + textAlign: TextAlign.center, + style: const TextStyle(color: seedMuted, fontSize: 15), + ), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.only(bottom: 88), + itemCount: sales.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) => _SaleTile(sales[i], repo), + ); + }, + ), + ); + } +} + +class _SaleTile extends StatelessWidget { + const _SaleTile(this.s, this.repo); + + final Sale s; + final VarietyRepository repo; + + String _money() { + final amount = s.amount; + if (amount == null) return s.currency?.trim() ?? ''; + // Drop a trailing .0 so "5.0 €" reads "5 €". + final n = amount == amount.roundToDouble() + ? amount.toInt().toString() + : amount.toString(); + return [n, s.currency?.trim()].where((e) => e != null && e.isNotEmpty).join(' '); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final iSold = s.direction == SaleDirection.iSold; + final money = _money(); + final date = MaterialLocalizations.of(context) + .formatShortDate(DateTime.fromMillisecondsSinceEpoch(s.soldOn)); + final subtitle = [ + iSold ? t.sale.iSold : t.sale.iBought, + if (s.counterparty?.trim().isNotEmpty ?? false) s.counterparty!.trim(), + date, + ].join(' · '); + return ListTile( + leading: Icon( + iSold ? Icons.sell_outlined : Icons.shopping_bag_outlined, + color: seedGreen, + ), + title: Text( + money.isEmpty + ? (s.note?.trim().isNotEmpty ?? false + ? s.note!.trim() + : (iSold ? t.sale.iSold : t.sale.iBought)) + : money, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), + trailing: PopupMenuButton( + key: Key('sale.menu.${s.id}'), + onSelected: (v) { + if (v == 'delete') repo.deleteSale(s.id); + }, + itemBuilder: (context) => + [PopupMenuItem(value: 'delete', child: Text(t.sale.delete))], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/settings_screen.dart b/apps/app_seeds/lib/ui/settings_screen.dart index cdd8f7c..ff619ef 100644 --- a/apps/app_seeds/lib/ui/settings_screen.dart +++ b/apps/app_seeds/lib/ui/settings_screen.dart @@ -1,48 +1,31 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../di/injector.dart'; import '../i18n/strings.g.dart'; import '../services/export_import_service.dart'; +import '../services/locale_store.dart'; import 'backup_section.dart'; import 'theme.dart'; /// Basic settings: app language, backup (export/import) and an "about" -/// section. [exportImport] is injectable so widget tests can pass a fake; -/// the app resolves it lazily from the service locator. +/// section. [exportImport] and [localeStore] are injectable so widget tests can +/// pass fakes; the app resolves them lazily from the service locator. class SettingsScreen extends StatelessWidget { - const SettingsScreen({this.exportImport, super.key}); + const SettingsScreen({this.exportImport, this.localeStore, super.key}); final ExportImportService? exportImport; + final LocaleStore? localeStore; @override Widget build(BuildContext context) { final t = context.t; - final current = TranslationProvider.of(context).flutterLocale.languageCode; return Scaffold( appBar: AppBar(title: Text(t.menu.settings)), body: ListView( children: [ _SectionHeader(t.settings.language), - _LanguageTile( - label: t.settings.langEs, - selected: current == 'es', - onTap: () => LocaleSettings.setLocale(AppLocale.es), - ), - _LanguageTile( - label: t.settings.langEn, - selected: current == 'en', - onTap: () => LocaleSettings.setLocale(AppLocale.en), - ), - _LanguageTile( - label: t.settings.langPt, - selected: current == 'pt', - onTap: () => LocaleSettings.setLocale(AppLocale.pt), - ), - ListTile( - leading: const Icon(Icons.smartphone_outlined), - title: Text(t.settings.systemLanguage), - onTap: () => LocaleSettings.useDeviceLocale(), - ), + _LanguageSelector(localeStore: localeStore), const Divider(), _SectionHeader(t.backup.title), BackupSection(service: exportImport), @@ -55,12 +38,149 @@ class SettingsScreen extends StatelessWidget { trailing: const Icon(Icons.chevron_right), onTap: () => context.push('/about'), ), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined, color: seedGreen), + title: Text(t.legal.title), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/legal'), + ), ], ), ); } } +/// The full ordered list of app languages, paired with their endonym label. +/// One source of truth for both the current-choice subtitle and the picker. +List<(AppLocale, String)> _languages(Translations t) => [ + (AppLocale.es, t.settings.langEs), + (AppLocale.ast, t.settings.langAst), + (AppLocale.en, t.settings.langEn), + (AppLocale.pt, t.settings.langPt), + (AppLocale.fr, t.settings.langFr), + (AppLocale.de, t.settings.langDe), + (AppLocale.ja, t.settings.langJa), +]; + +/// A single tile showing the active language; tapping opens a picker with all +/// languages plus "System language" (follow the device). A `null` saved value +/// means the user follows the device language — the app still resolves to some +/// concrete locale, but the picker must show "System language" as the choice. +class _LanguageSelector extends StatefulWidget { + const _LanguageSelector({this.localeStore}); + + final LocaleStore? localeStore; + + @override + State<_LanguageSelector> createState() => _LanguageSelectorState(); +} + +class _LanguageSelectorState extends State<_LanguageSelector> { + /// Prefer the injected store; otherwise fall back to the locator, but only if + /// it actually has one registered — widget tests may pump this screen with no + /// DI set up, and reading the current language must not crash there. + LocaleStore? get _store => + widget.localeStore ?? + (getIt.isRegistered() ? getIt() : null); + + /// The user's explicit choice, or null when following the device language. + AppLocale? _saved; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _store?.saved().then((locale) { + if (!mounted) return; + setState(() { + _saved = locale; + _loaded = true; + }); + }); + } + + Future _select(AppLocale? locale) async { + final store = _store; + if (store == null) return; + if (locale == null) { + await store.useDeviceLocale(); + } else { + await store.setLocale(locale); + } + if (mounted) setState(() => _saved = locale); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + // The tile shows the current language by its endonym. When following the + // device language, the title is still the resolved language and a subtitle + // makes the "System language" mode explicit. + final resolved = TranslationProvider.of(context).locale; + final isDevice = _loaded && _saved == null; + final label = _labelFor(t, _loaded && _saved != null ? _saved! : resolved); + return ListTile( + key: const Key('settings.language'), + leading: const Icon(Icons.translate, color: seedGreen), + title: Text(label), + subtitle: isDevice ? Text(t.settings.systemLanguage) : null, + trailing: const Icon(Icons.chevron_right), + onTap: () => _openPicker(context), + ); + } + + String _labelFor(Translations t, AppLocale locale) { + for (final (l, label) in _languages(t)) { + if (l == locale) return label; + } + return locale.languageCode; + } + + Future _openPicker(BuildContext context) async { + final t = context.t; + final selection = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final (locale, label) in _languages(t)) + ListTile( + title: Text(label), + trailing: _saved == locale + ? const Icon(Icons.check, color: seedGreen) + : null, + onTap: () => Navigator.pop(context, locale), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.smartphone_outlined), + title: Text(t.settings.systemLanguage), + trailing: _saved == null + ? const Icon(Icons.check, color: seedGreen) + : null, + // Use a sentinel so "System language" (null) is distinct from + // "nothing tapped" (also null from a dismissed sheet). + onTap: () => Navigator.pop(context, _deviceSentinel), + ), + ], + ), + ); + }, + ); + if (selection == null) return; // dismissed without choosing + await _select( + identical(selection, _deviceSentinel) ? null : selection as AppLocale, + ); + } +} + +/// Distinguishes an explicit "System language" pick from a dismissed sheet, +/// since both would otherwise arrive as `null`. +const Object _deviceSentinel = Object(); + class _SectionHeader extends StatelessWidget { const _SectionHeader(this.text); @@ -81,24 +201,3 @@ class _SectionHeader extends StatelessWidget { ); } } - -class _LanguageTile extends StatelessWidget { - const _LanguageTile({ - required this.label, - required this.selected, - required this.onTap, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(label), - trailing: selected ? const Icon(Icons.check, color: seedGreen) : null, - onTap: onTap, - ); - } -} diff --git a/apps/app_seeds/lib/ui/theme.dart b/apps/app_seeds/lib/ui/theme.dart index 392e37a..170a6c2 100644 --- a/apps/app_seeds/lib/ui/theme.dart +++ b/apps/app_seeds/lib/ui/theme.dart @@ -25,6 +25,8 @@ const seedOnSurfaceVariant = Color(0xFF5C6B52); // scientific names, secondary // see test/ui/theme_contrast_test.dart. const seedMuted = Color(0xFF617057); const seedRating = Color(0xFFE8A200); // stars +const seedFavorite = Color(0xFFE53935); // saved/favorite heart (pops on green) +const seedWarning = Color(0xFFA84600); // overdue nudges (AA on white & canvas) ThemeData buildTaneTheme() { final scheme = @@ -50,6 +52,11 @@ ThemeData buildTaneTheme() { return ThemeData( useMaterial3: true, colorScheme: scheme, + // Per-glyph script fallbacks so Arabic (RTL) and CJK text render even where + // the platform's default font lacks them (notably desktop). The primary + // family stays the system default for Latin; these only fill the gaps. The + // ranges are disjoint, so order is immaterial. Fonts bundled in pubspec. + fontFamilyFallback: const ['Noto Sans JP', 'Noto Sans Arabic'], scaffoldBackgroundColor: seedCanvas, // Green app bars — aligned with the v2 mockups and the real app. appBarTheme: const AppBarTheme( diff --git a/apps/app_seeds/lib/ui/unread_badge.dart b/apps/app_seeds/lib/ui/unread_badge.dart new file mode 100644 index 0000000..7a6e54b --- /dev/null +++ b/apps/app_seeds/lib/ui/unread_badge.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../di/injector.dart'; +import '../services/unread_service.dart'; + +/// Wraps [child] with a small live unread-count badge. It listens to the +/// app-wide [UnreadService] and rebuilds whenever a count changes: on a new +/// message, or when a chat is opened (marked read). Shows the total across all +/// conversations, or — when [peer] is set — just that peer's count. +/// +/// When no unread tracker is available (widget tests, inventory-only builds) or +/// the count is zero, it renders [child] unadorned. Positioning is via Flutter's +/// [Badge], which is directional (top-`end`), so it's correct under RTL. +class UnreadBadge extends StatefulWidget { + const UnreadBadge({ + required this.child, + this.peer, + this.service, + super.key, + }); + + final Widget child; + + /// A specific conversation's count; null means the app-wide total. + final String? peer; + + /// Overrides the [UnreadService] lookup (for tests). Falls back to `getIt`. + final UnreadService? service; + + @override + State createState() => _UnreadBadgeState(); +} + +class _UnreadBadgeState extends State { + UnreadService? _service; + StreamSubscription? _sub; + int _count = 0; + + @override + void initState() { + super.initState(); + _service = widget.service ?? + (getIt.isRegistered() ? getIt() : null); + final service = _service; + if (service != null) { + _refresh(); + _sub = service.changes.listen((_) => _refresh()); + } + } + + @override + void didUpdateWidget(UnreadBadge old) { + super.didUpdateWidget(old); + // A recycled row may now point at a different peer. + if (old.peer != widget.peer) _refresh(); + } + + Future _refresh() async { + final service = _service; + if (service == null) return; + final peer = widget.peer; + final count = peer == null + ? await service.totalUnreadCount() + : await service.unreadCount(peer); + if (mounted) setState(() => _count = count); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Badge( + isLabelVisible: _count > 0, + label: Text('$_count'), + backgroundColor: Theme.of(context).colorScheme.error, + child: widget.child, + ); + } +} diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 2442092..6eeeead 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -6,14 +6,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../data/seed_saving_catalog.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; +import '../db/database.dart'; import '../db/enums.dart'; import '../domain/crop_calendar.dart'; +import '../domain/seed_saving.dart'; import '../domain/seed_viability.dart'; import '../domain/species_reference_links.dart'; import '../i18n/strings.g.dart'; import '../state/variety_detail_cubit.dart'; +import 'currency_quick_picks.dart'; +import 'handover_sheet.dart'; import 'harvest_date_picker.dart'; import 'photo_pick.dart'; import 'quantity_kind_l10n.dart'; @@ -108,6 +113,27 @@ class _DetailView extends StatelessWidget { ), ], ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // ONE door for "seeds changed hands": gift, swap or sale, with + // or without money or a return promise. The sales/plantares + // screens keep their own add buttons for off-app records. + OutlinedButton.icon( + key: const Key('detail.handover'), + icon: const Icon(Icons.handshake_outlined, size: 18), + onPressed: () => showHandoverSheet( + context, + repository: context.read(), + varietyId: detail.id, + lots: detail.lots, + ), + label: Text(t.handover.title), + ), + ], + ), if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[ const SizedBox(height: 16), _SectionTitle(t.detail.notes), @@ -118,6 +144,28 @@ class _DetailView extends StatelessWidget { _SectionTitle(t.cropCalendar.title), _CropCalendarView(detail: detail), ], + if (SeedSavingCatalog.guideFor( + scientificName: detail.scientificName, + family: detail.family ?? detail.category, + ) + case final guide? when guide.hasAny) ...[ + const SizedBox(height: 8), + // Reference data (facts about the species, not the grower's own + // records) — collapsed by default so it's opt-in depth, not noise. + ExpansionTile( + key: const Key('detail.seedSaving'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + leading: const Icon(Icons.spa_outlined, color: seedGreen), + title: Text(t.seedSaving.title), + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: _SeedSavingView(guide: guide), + ), + ], + ), + ], if (_referenceLinks(context, detail) case final references when references.isNotEmpty) ...[ const SizedBox(height: 16), @@ -189,6 +237,107 @@ class _DetailView extends StatelessWidget { lot: lot, viabilityYears: detail.viabilityYears, ), + _PlantaresSection(varietyId: detail.id), + ], + ), + ); + } +} + +/// The reproduction commitments (Plantarés) recorded against this variety — so +/// a promise to grow it out and pass it on lives next to the seed it's about, +/// not only on the separate Plantares screen. Read-only: new ones are recorded +/// through the single hand-over door (or the Plantares screen), and the section +/// stays hidden until there's something to show, like the other detail blocks. +class _PlantaresSection extends StatelessWidget { + const _PlantaresSection({required this.varietyId}); + + final String varietyId; + + @override + Widget build(BuildContext context) { + final t = context.t; + final repo = context.read(); + return StreamBuilder>( + stream: repo.watchPlantaresForVariety(varietyId), + builder: (context, snapshot) { + final list = snapshot.data ?? const []; + if (list.isEmpty) return const SizedBox.shrink(); + return Column( + key: const Key('detail.plantares'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + _SectionTitle(t.plantare.sectionTitle), + for (final p in list) _PlantareLine(p), + ], + ); + }, + ); + } +} + +/// A compact, read-only commitment row for the variety detail: direction, +/// counterparty and when — with a return-by line (amber when overdue). +class _PlantareLine extends StatelessWidget { + const _PlantareLine(this.p); + + final Plantare p; + + @override + Widget build(BuildContext context) { + final t = context.t; + final locals = MaterialLocalizations.of(context); + final done = p.status != PlantareStatus.open; + final iReturn = p.direction == PlantareDirection.iReturn; + final title = (p.owedDescription?.trim().isNotEmpty ?? false) + ? p.owedDescription!.trim() + : (iReturn ? t.plantare.iReturn : t.plantare.owedToMe); + final parts = [ + iReturn ? t.plantare.iReturn : t.plantare.owedToMe, + if (p.counterparty?.trim().isNotEmpty ?? false) p.counterparty!.trim(), + locals.formatShortDate(DateTime.fromMillisecondsSinceEpoch(p.madeOn)), + if (done) + p.status == PlantareStatus.returned + ? t.plantare.statusReturned + : t.plantare.statusForgiven, + ]; + final overdue = + !done && + p.dueBy != null && + p.dueBy! < DateTime.now().millisecondsSinceEpoch; + final returnBy = p.dueBy == null + ? null + : t.plantare.returnBy( + date: locals.formatShortDate( + DateTime.fromMillisecondsSinceEpoch(p.dueBy!), + ), + ); + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + iReturn ? Icons.volunteer_activism_outlined : Icons.eco_outlined, + color: done ? seedMuted : seedGreen, + ), + title: Text( + title, + style: TextStyle( + decoration: done ? TextDecoration.lineThrough : null, + color: done ? seedMuted : seedOnSurface, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(parts.join(' · '), maxLines: 2, overflow: TextOverflow.ellipsis), + if (returnBy != null) + Text( + overdue ? '$returnBy · ${t.plantare.overdue}' : returnBy, + style: TextStyle( + color: overdue ? seedWarning : seedMuted, + fontWeight: overdue ? FontWeight.w600 : null, + ), + ), ], ), ); @@ -201,10 +350,7 @@ String _lotSubtitle(Translations t, VarietyLot lot) { harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth) else t.detail.noYear, - if (lot.quantity != null) - quantityDisplay(t, lot.quantity!) - else if (lot.abundance != null) - abundanceLabel(t, lot.abundance!), + if (lot.quantity != null) quantityDisplay(t, lot.quantity!), if (lot.presentation != null) presentationLabel(t, lot.presentation!), if (lot.offerStatus != OfferStatus.private) shareStatusLabel(t, lot.offerStatus), @@ -283,22 +429,32 @@ class _LotTile extends StatelessWidget { ?warning, ], ), - // Germination only applies to seed lots, not to plantel. - trailing: lot.type != LotType.seed - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (lot.latestGerminationRate != null) - _GerminationBadge(rate: lot.latestGerminationRate!), - IconButton( - key: Key('lot.germination.${lot.id}'), - icon: const Icon(Icons.grass_outlined), - tooltip: t.germination.title, - onPressed: () => _showGerminationSheet(context, cubit, lot), - ), - ], + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (lot.type == LotType.seed && lot.latestGerminationRate != null) + _GerminationBadge(rate: lot.latestGerminationRate!), + IconButton( + key: Key('lot.handover.${lot.id}'), + icon: const Icon(Icons.handshake_outlined), + tooltip: t.handover.title, + onPressed: () => showHandoverSheet( + context, + repository: context.read(), + varietyId: cubit.varietyId, + lots: [lot], ), + ), + // Germination only applies to seed lots, not to plantel. + if (lot.type == LotType.seed) + IconButton( + key: Key('lot.germination.${lot.id}'), + icon: const Icon(Icons.grass_outlined), + tooltip: t.germination.title, + onPressed: () => _showGerminationSheet(context, cubit, lot), + ), + ], + ), ); } } @@ -448,87 +604,91 @@ Future _showGerminationSheet( top: 16, bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - t.germination.title, - style: Theme.of(sheetContext).textTheme.titleLarge, - ), - const SizedBox(height: 8), - if (lot.germinationTests.isEmpty) - Text(t.germination.none) - else - for (final test in lot.germinationTests) - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.grass_outlined), - title: Text( - test.rate == null ? '—' : _germinationPercent(t, test.rate!), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.germination.title, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + const SizedBox(height: 8), + if (lot.germinationTests.isEmpty) + Text(t.germination.none) + else + for (final test in lot.germinationTests) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.grass_outlined), + title: Text( + test.rate == null + ? '—' + : _germinationPercent(t, test.rate!), + ), + subtitle: + (test.sampleSize != null && test.germinatedCount != null) + ? Text('${test.germinatedCount}/${test.sampleSize}') + : null, ), - subtitle: - (test.sampleSize != null && test.germinatedCount != null) - ? Text('${test.germinatedCount}/${test.sampleSize}') - : null, - ), - const Divider(), - Row( - children: [ - Expanded( - child: TextField( - key: const Key('germination.germinated'), - controller: germinated, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.germination.germinated, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), + const Divider(), + Row( + children: [ + Expanded( + child: TextField( + key: const Key('germination.germinated'), + controller: germinated, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: t.germination.germinated, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), ), - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - key: const Key('germination.sampleSize'), - controller: sample, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.germination.sampleSize, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), + const SizedBox(width: 12), + Expanded( + child: TextField( + key: const Key('germination.sampleSize'), + controller: sample, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: t.germination.sampleSize, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - TextButton( - onPressed: () => Navigator.of(sheetContext).pop(), - child: Text(t.common.cancel), - ), - const Spacer(), - FilledButton( - key: const Key('germination.save'), - onPressed: () { - cubit.addGerminationTest( - lotId: lot.id, - testedOn: DateTime.now().millisecondsSinceEpoch, - sampleSize: int.tryParse(sample.text.trim()), - germinatedCount: int.tryParse(germinated.text.trim()), - ); - Navigator.of(sheetContext).pop(); - }, - child: Text(t.germination.add), - ), - ], - ), - ], + ], + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: Text(t.common.cancel), + ), + const Spacer(), + FilledButton( + key: const Key('germination.save'), + onPressed: () { + cubit.addGerminationTest( + lotId: lot.id, + testedOn: DateTime.now().millisecondsSinceEpoch, + sampleSize: int.tryParse(sample.text.trim()), + germinatedCount: int.tryParse(germinated.text.trim()), + ); + Navigator.of(sheetContext).pop(); + }, + child: Text(t.germination.add), + ), + ], + ), + ], + ), ), ), ); @@ -832,6 +992,19 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { title: Text(t.cropCalendar.title), onExpansionChanged: (v) => _calendarOpen = v, children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + t.cropCalendar.editorHint, + style: const TextStyle( + color: seedMuted, + fontSize: 12, + ), + ), + ), + ), _MonthMultiSelect( label: t.cropCalendar.sow, mask: _sowMonths, @@ -930,9 +1103,13 @@ class _MonthMultiSelect extends StatelessWidget { } } -/// A short (≤3-char) label for a month name, for the compact calendar chips. -String _monthAbbrev(String name) => - name.length <= 3 ? name : name.substring(0, 3); +/// A short (≤3-grapheme) label for a month name, for the compact calendar chips. +/// Counts by grapheme (not UTF-16 units) so CJK/complex scripts don't truncate +/// mid-character. +String _monthAbbrev(String name) { + final chars = name.characters; + return chars.length <= 3 ? name : chars.take(3).toString(); +} /// Read-only crop calendar: one line per recorded phase ("Sow · Mar, Apr, Sep"). /// Renders only the phases that have months set. @@ -979,13 +1156,182 @@ class _CropCalendarView extends StatelessWidget { } } -/// Human label for a coarse-abundance level. -String abundanceLabel(Translations t, Abundance a) => switch (a) { - Abundance.plentyToShare => t.abundance.plentyToShare, - Abundance.enoughToShare => t.abundance.enoughToShare, - Abundance.enoughForMe => t.abundance.enoughForMe, - Abundance.runningLow => t.abundance.runningLow, -}; +/// "How to save this seed" — the bundled seed-saving guidance for the crop +/// (pollination, isolation distance, how many plants, wet/dry, difficulty, a +/// tip), in human words. Shown only when a guide resolves for the variety. +class _SeedSavingView extends StatelessWidget { + const _SeedSavingView({required this.guide}); + + final SeedSavingGuide guide; + + @override + Widget build(BuildContext context) { + final t = context.t; + final rows = []; + + final cycle = switch (guide.lifeCycle) { + LifeCycle.annual => t.seedSaving.cycleAnnual, + LifeCycle.biennial => t.seedSaving.cycleBiennial, + LifeCycle.perennial => t.seedSaving.cyclePerennial, + null => null, + }; + if (cycle != null) { + rows.add(_line(Icons.event_repeat, t.seedSaving.lifeCycle, cycle)); + } + + if (guide.pollination case final poll?) { + final base = switch (poll) { + Pollination.self => t.seedSaving.pollSelf, + Pollination.cross => t.seedSaving.pollCross, + Pollination.mixed => t.seedSaving.pollMixed, + }; + final via = poll == Pollination.self + ? '' + : switch (guide.vector) { + PollinationVector.insect => ' · ${t.seedSaving.byInsect}', + PollinationVector.wind => ' · ${t.seedSaving.byWind}', + _ => '', + }; + final icon = switch (guide.vector) { + PollinationVector.wind => Icons.air, + PollinationVector.insect => Icons.emoji_nature, + _ => Icons.lock_outline, + }; + rows.add(_line(icon, t.seedSaving.pollination, '$base$via')); + } + + if (guide.isolationMinM case final min?) { + final max = guide.isolationMaxM; + final value = (max != null && max != min) + ? t.seedSaving.isolationRange(min: min, max: max) + : t.seedSaving.isolationSingle(min: min); + rows.add(_line(Icons.social_distance, t.seedSaving.isolation, value)); + } + + if ((guide.recommendedPlants ?? guide.minPlants) case final n?) { + rows.add( + _line( + Icons.groups_outlined, + t.seedSaving.plants, + t.seedSaving.plantsValue(n: n), + ), + ); + } + + if (guide.processing case final proc?) { + final wet = proc == SeedProcessing.wet; + rows.add( + _line( + wet ? Icons.water_drop_outlined : Icons.grass, + t.seedSaving.processing, + wet ? t.seedSaving.procWet : t.seedSaving.procDry, + ), + ); + } + + final note = guide.noteFor(LocaleSettings.currentLocale.languageCode); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...rows, + if (guide.difficulty case final d?) ...[ + const SizedBox(height: 4), + _DifficultyChip(difficulty: d), + ], + if (note != null) ...[ + const SizedBox(height: 8), + Text(note, style: const TextStyle(color: seedMuted, height: 1.35)), + ], + // Advisory + a light source credit — the figures are general, not local + // gospel, and drawn from the seed-saving literature. + const SizedBox(height: 10), + Text( + t.seedSaving.advisory, + style: const TextStyle( + color: seedMuted, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + if (SeedSavingCatalog.sources case final sources + when sources.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '${t.seedSaving.sourcePrefix}: ' + '${sources.map((s) => s.title).join(' · ')}', + style: const TextStyle(color: seedMuted, fontSize: 11), + ), + ), + ], + ); + } + + Widget _line(IconData icon, String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: seedGreen), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: '$label · ', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + TextSpan(text: value), + ], + ), + ), + ), + ], + ), + ); +} + +class _DifficultyChip extends StatelessWidget { + const _DifficultyChip({required this.difficulty}); + + final SavingDifficulty difficulty; + + @override + Widget build(BuildContext context) { + final t = context.t; + final (label, color) = switch (difficulty) { + SavingDifficulty.easy => (t.seedSaving.diffEasy, const Color(0xFF3B6D11)), + SavingDifficulty.medium => ( + t.seedSaving.diffMedium, + const Color(0xFF8A6D1E), + ), + SavingDifficulty.hard => (t.seedSaving.diffHard, const Color(0xFFA14234)), + }; + return Row( + children: [ + Icon(Icons.insights, size: 18, color: seedGreen), + const SizedBox(width: 10), + Text( + '${t.seedSaving.difficulty} · ', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + ), + ), + ], + ); + } +} /// Human label for how (whether) a lot is offered to others. String shareStatusLabel(Translations t, OfferStatus s) => switch (s) { @@ -1013,33 +1359,6 @@ String desiccantLabel(Translations t, DesiccantState d) => switch (d) { DesiccantState.fresh => t.desiccant.fresh, }; -/// Single-select chips for the coarse-abundance level; tapping the current one -/// clears it (null). -class _AbundanceSelector extends StatelessWidget { - const _AbundanceSelector({required this.value, required this.onChanged}); - - final Abundance? value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final t = context.t; - return Wrap( - spacing: 8, - runSpacing: 4, - children: [ - for (final a in Abundance.values) - ChoiceChip( - key: Key('abundance.${a.name}'), - label: Text(abundanceLabel(t, a)), - selected: value == a, - onSelected: (sel) => onChanged(sel ? a : null), - ), - ], - ); - } -} - /// Single-select chips for the share terms. Gift and swap come first and sale /// last, never preselected — the gift is first-class, the sale a choice /// (sharing-model §4.1). There is always a selection ("just for me" default). @@ -1116,7 +1435,7 @@ class _ConditionChecksBlock extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Align( - alignment: Alignment.centerLeft, + alignment: AlignmentDirectional.centerStart, child: Text( t.conditionCheck.title, style: Theme.of(context).textTheme.labelLarge, @@ -1143,7 +1462,7 @@ class _ConditionChecksBlock extends StatelessWidget { ), ), Align( - alignment: Alignment.centerLeft, + alignment: AlignmentDirectional.centerStart, child: TextButton.icon( key: const Key('conditionCheck.open'), icon: const Icon(Icons.add), @@ -1175,69 +1494,72 @@ Future _showConditionSheet( top: 16, bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - t.conditionCheck.title, - style: Theme.of(sheetContext).textTheme.titleLarge, - ), - const SizedBox(height: 12), - TextField( - key: const Key('conditionCheck.containers'), - controller: containers, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.conditionCheck.containers, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.conditionCheck.title, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + const SizedBox(height: 12), + TextField( + key: const Key('conditionCheck.containers'), + controller: containers, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: t.conditionCheck.containers, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), - ), - const SizedBox(height: 16), - Text( - t.conditionCheck.desiccant, - style: Theme.of(sheetContext).textTheme.labelLarge, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - for (final d in DesiccantState.values) - ChoiceChip( - key: Key('desiccant.${d.name}'), - label: Text(desiccantLabel(t, d)), - selected: state == d, - onSelected: (sel) => setState(() => state = sel ? d : null), + const SizedBox(height: 16), + Text( + t.conditionCheck.desiccant, + style: Theme.of(sheetContext).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final d in DesiccantState.values) + ChoiceChip( + key: Key('desiccant.${d.name}'), + label: Text(desiccantLabel(t, d)), + selected: state == d, + onSelected: (sel) => + setState(() => state = sel ? d : null), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: Text(t.common.cancel), ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - TextButton( - onPressed: () => Navigator.of(sheetContext).pop(), - child: Text(t.common.cancel), - ), - const Spacer(), - FilledButton( - key: const Key('conditionCheck.save'), - onPressed: () { - cubit.addConditionCheck( - lotId: lot.id, - checkedOn: DateTime.now().millisecondsSinceEpoch, - containerCount: int.tryParse(containers.text.trim()), - desiccantState: state, - ); - Navigator.of(sheetContext).pop(); - }, - child: Text(t.conditionCheck.add), - ), - ], - ), - ], + const Spacer(), + FilledButton( + key: const Key('conditionCheck.save'), + onPressed: () { + cubit.addConditionCheck( + lotId: lot.id, + checkedOn: DateTime.now().millisecondsSinceEpoch, + containerCount: int.tryParse(containers.text.trim()), + desiccantState: state, + ); + Navigator.of(sheetContext).pop(); + }, + child: Text(t.conditionCheck.add), + ), + ], + ), + ], + ), ), ), ), @@ -1264,13 +1586,23 @@ Future _showLotSheet( final originPlaceCtrl = TextEditingController( text: existing?.originPlace ?? '', ); - var selectedAbundance = existing?.abundance; var selectedPreservation = existing?.preservationFormat; var selectedShare = existing?.offerStatus ?? OfferStatus.private; var showOrigin = existing?.originName != null || existing?.originPlace != null; - var showAbundance = existing?.abundance != null; var showShare = selectedShare != OfferStatus.private; + final existingPrice = existing?.priceAmount; + final priceAmountCtrl = TextEditingController( + // Drop a trailing .0 so the field shows "5", not "5.0". + text: existingPrice == null + ? '' + : (existingPrice == existingPrice.roundToDouble() + ? existingPrice.toInt().toString() + : existingPrice.toString()), + ); + final priceCurrencyCtrl = TextEditingController( + text: existing?.priceCurrency ?? '', + ); return showModalBottomSheet( context: context, isScrollControlled: true, @@ -1373,17 +1705,6 @@ Future _showLotSheet( label: Text(t.provenance.section), onPressed: () => setState(() => showOrigin = true), ), - if (!showAbundance) - ActionChip( - key: const Key('lot.addAbundance'), - avatar: const Icon( - Icons.inventory_2_outlined, - size: 18, - ), - label: Text(t.abundance.add), - onPressed: () => - setState(() => showAbundance = true), - ), if (!showShare) ActionChip( key: const Key('lot.addShare'), @@ -1424,27 +1745,6 @@ Future _showLotSheet( ), ), ], - if (showAbundance) ...[ - const SizedBox(height: 16), - Text( - t.abundance.title, - style: Theme.of(sheetContext).textTheme.labelLarge, - ), - const SizedBox(height: 8), - _AbundanceSelector( - value: selectedAbundance, - onChanged: (a) => setState(() { - selectedAbundance = a; - // The bridge from "how much" to "do you share it": - // declaring plenty reveals the sharing choice. A - // nudge, never a change of the choice itself. - if (a == Abundance.plentyToShare || - a == Abundance.enoughToShare) { - showShare = true; - } - }), - ), - ], if (showShare) ...[ const SizedBox(height: 16), Text( @@ -1456,17 +1756,49 @@ Future _showLotSheet( value: selectedShare, onChanged: (s) => setState(() => selectedShare = s), ), - if (selectedShare == OfferStatus.private && - (selectedAbundance == Abundance.plentyToShare || - selectedAbundance == Abundance.enoughToShare)) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - t.share.nudge, - key: const Key('share.nudge'), - style: Theme.of(sheetContext).textTheme.bodySmall, - ), + // Price only when selling — informational, agreed and + // paid off-app. Empty amount = "to be agreed". + if (selectedShare == OfferStatus.sell) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + key: const Key('lot.priceAmount'), + controller: priceAmountCtrl, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + labelText: t.share.price, + helperText: t.share.priceHint, + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + key: const Key('lot.priceCurrency'), + controller: priceCurrencyCtrl, + decoration: InputDecoration( + labelText: t.sale.currency, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), + ], ), + const SizedBox(height: 8), + CurrencyQuickPicks( + controller: priceCurrencyCtrl, + keyPrefix: 'lot', + onChanged: () => setState(() {}), + ), + ], ], // Layer 2: advanced seed-bank details, collapsed by default. // Only for seed lots, and (condition checks) an existing lot. @@ -1480,7 +1812,7 @@ Future _showLotSheet( children: [ const SizedBox(height: 4), Align( - alignment: Alignment.centerLeft, + alignment: AlignmentDirectional.centerStart, child: Text( t.preservation.title, style: Theme.of( @@ -1517,6 +1849,10 @@ Future _showLotSheet( onPressed: () { final originName = _nullIfBlank(originNameCtrl.text); final originPlace = _nullIfBlank(originPlaceCtrl.text); + final priceAmount = double.tryParse( + priceAmountCtrl.text.replaceAll(',', '.').trim(), + ); + final priceCurrency = _nullIfBlank(priceCurrencyCtrl.text); if (editing) { cubit.updateLot( lotId: existing.id, @@ -1527,9 +1863,10 @@ Future _showLotSheet( presentation: selectedPresentation, originName: originName, originPlace: originPlace, - abundance: selectedAbundance, preservationFormat: selectedPreservation, offerStatus: selectedShare, + priceAmount: priceAmount, + priceCurrency: priceCurrency, ); } else { cubit.addLot( @@ -1540,9 +1877,10 @@ Future _showLotSheet( presentation: selectedPresentation, originName: originName, originPlace: originPlace, - abundance: selectedAbundance, preservationFormat: selectedPreservation, offerStatus: selectedShare, + priceAmount: priceAmount, + priceCurrency: priceCurrency, ); } Navigator.of(sheetContext).pop(); diff --git a/apps/app_seeds/lib/ui/your_people_screen.dart b/apps/app_seeds/lib/ui/your_people_screen.dart new file mode 100644 index 0000000..00eb32a --- /dev/null +++ b/apps/app_seeds/lib/ui/your_people_screen.dart @@ -0,0 +1,251 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../i18n/strings.g.dart'; +import '../services/profile_cache.dart'; +import 'peer_avatar.dart'; +import '../services/social_connection.dart'; +import '../services/social_service.dart'; +import 'theme.dart'; + +/// "Your people": who you vouch for and who vouches for you, in human +/// language. Ego-centric by design — no roots, no parameters, no graph talk. +/// Reached from the profile; rows open the 1:1 chat. +class YourPeopleScreen extends StatefulWidget { + const YourPeopleScreen({ + required this.social, + required this.connection, + this.profileCache, + super.key, + }); + + final SocialService social; + + /// The shared relay connection (one per identity), carrying trust. + final SocialConnection connection; + + /// Optional cache of peer display names; null in tests. + final ProfileCache? profileCache; + + @override + State createState() => _YourPeopleScreenState(); +} + +class _YourPeopleScreenState extends State { + TrustTransport? _transport; + bool _loading = true; + bool _busy = false; + List _youVouchFor = const []; + List _vouchForYou = const []; + final Map _names = {}; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + final session = await widget.connection.session(); + if (!mounted) return; // shared session is owned by the connection, not us + _transport = session?.trust; + await _load(); + } + + Future _load() async { + final transport = _transport; + if (transport == null) { + if (mounted) setState(() => _loading = false); + return; + } + final self = widget.social.publicKeyHex; + final now = DateTime.now(); + final certs = (await transport.allCertifications()) + .where((c) => c.isValidAt(now)) + .toList(); + final given = [ + for (final c in certs) + if (c.issuer == self) c.subject, + ]..sort(); + final received = [ + for (final c in certs) + if (c.subject == self) c.issuer, + ]..sort(); + final cache = widget.profileCache; + if (cache != null) { + for (final peer in {...given, ...received}) { + final name = await cache.name(peer); + if (name != null) _names[peer] = name; + } + } + if (!mounted) return; + setState(() { + _youVouchFor = given; + _vouchForYou = received; + _loading = false; + }); + } + + Future _revoke(String peer) async { + final t = context.t; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.yourPeople.revokeConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(t.yourPeople.revoke), + ), + ], + ), + ); + final transport = _transport; + if (confirmed != true || transport == null || !mounted) return; + setState(() => _busy = true); + await transport.revoke(subjectPubkey: peer); + await _load(); + if (mounted) setState(() => _busy = false); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + return Scaffold( + appBar: AppBar(title: Text(t.yourPeople.title)), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _transport == null + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + t.yourPeople.offline, + textAlign: TextAlign.center, + style: const TextStyle(color: seedMuted, fontSize: 15), + ), + ), + ) + : ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + 16, 8, 16, 8), + child: Text( + t.yourPeople.help, + style: + const TextStyle(color: seedMuted, fontSize: 14), + ), + ), + _SectionHeader(label: t.yourPeople.youVouchFor), + if (_youVouchFor.isEmpty) + _EmptyNote(text: t.yourPeople.youVouchForEmpty) + else + for (final peer in _youVouchFor) + _PersonTile( + key: Key('yourPeople.given.$peer'), + pubkey: peer, + name: _names[peer] ?? shortPubkey(peer), + cache: widget.profileCache, + onOpen: () => context.push('/chat/$peer'), + trailing: TextButton( + onPressed: _busy ? null : () => _revoke(peer), + child: Text(t.yourPeople.revoke), + ), + ), + const SizedBox(height: 12), + _SectionHeader(label: t.yourPeople.vouchesForYou), + if (_vouchForYou.isEmpty) + _EmptyNote(text: t.yourPeople.vouchesForYouEmpty) + else + for (final peer in _vouchForYou) + _PersonTile( + key: Key('yourPeople.received.$peer'), + pubkey: peer, + name: _names[peer] ?? shortPubkey(peer), + cache: widget.profileCache, + onOpen: () => context.push('/chat/$peer'), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 16, 4), + child: Text( + label, + style: const TextStyle( + color: seedGreen, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _EmptyNote extends StatelessWidget { + const _EmptyNote({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 16, 4), + child: Text( + text, + style: const TextStyle(color: seedMuted, fontSize: 14), + ), + ); + } +} + +class _PersonTile extends StatelessWidget { + const _PersonTile({ + required this.name, + required this.onOpen, + required this.pubkey, + this.cache, + this.trailing, + super.key, + }); + + final String pubkey; + final String name; + final ProfileCache? cache; + final VoidCallback onOpen; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CachedAvatar( + pubkey: pubkey, + name: name, + cache: cache, + radius: 20, + ), + title: Text(name), + trailing: trailing, + onTap: onOpen, + ); + } +} diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index e722c3b..fd47a9a 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -1,5 +1,5 @@ name: tane -description: "Tanemaki — local-first, encrypted, decentralized traditional-seed inventory." +description: "Tane — local-first, encrypted, decentralized traditional-seed inventory." publish_to: 'none' version: 0.1.0+1 @@ -37,6 +37,8 @@ dependencies: # i18n (Weblate-friendly JSON per locale). slang: ^4.7.0 slang_flutter: ^4.7.0 + # ICU date/number formatting (locale-aware, never ad-hoc). + intl: ^0.20.2 # Secure key storage (OS keystore). flutter_secure_storage: ^9.2.4 @@ -55,6 +57,9 @@ dependencies: # Pure-Dart PDF generation (Apache-2.0) for the printable "what I share" # catalog. No platform channels, so the whole flow is host-testable. pdf: ^3.11.3 + # Pure-Dart barcode/QR generation (Apache-2.0; already used transitively by + # pdf). Painted on screen for the profile's shareable identity QR. + barcode: ^2.2.9 path: ^1.9.0 path_provider: ^2.1.5 @@ -62,6 +67,18 @@ dependencies: material_symbols_icons: ^4.2951.0 url_launcher: ^6.3.2 package_info_plus: ^9.0.1 + # Optional coarse device location (BSD-3) to set the sharing area without + # typing a code. Low accuracy only; the value is immediately reduced to a + # low-precision geohash. Behind an interface so it's fakeable and pluggable. + geolocator: ^13.0.1 + # Network reachability (BSD-3) to show a global "you're offline" banner — the + # social layer needs a connection; the inventory works regardless. + connectivity_plus: ^6.1.0 + # Local OS notifications (BSD-3) for a heads-up when a private message arrives + # while the app is foregrounded. Mobile/Linux/macOS only; a no-op on web and + # Windows. Foreground-only by design — background/push is a later concern. + flutter_local_notifications: ^18.0.1 + crop_your_image: ^2.0.0 dev_dependencies: flutter_test: @@ -70,10 +87,14 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 + # Virtual clock to fast-forward the inventory auto-retry backoff in tests. + fake_async: ^1.3.1 build_runner: ^2.4.13 drift_dev: ^2.28.0 slang_build_runner: ^4.7.0 mocktail: ^1.0.4 + # Nostr types (Event/Filter) to fake a NostrChannel in the connection test. + nostr: ^2.0.0 flutter_launcher_icons: ^0.14.4 flutter_native_splash: ^2.4.7 @@ -112,6 +133,8 @@ flutter: # Seed strings for slang live under lib/i18n (not a Flutter asset; compiled). assets: - assets/catalog/species.json + # Curated seed-saving guidance (pollination, isolation, difficulty…). + - assets/catalog/seed_saving.json - assets/logo.png # Bundled so the Linux runner can use it as the GTK window icon. - assets/icon.png @@ -123,13 +146,26 @@ flutter: # Remove before shipping if you don't want them in the bundle. - assets/ocr_fixtures/ # Embedded in generated PDFs (printable catalog). DejaVu covers Latin - # (extended), Cyrillic and Greek; like the OCR packs, PDF fonts are a - # per-locale resource — swap in wider coverage (Arabic, CJK) as locales - # land. Free license (Bitstream Vera / public domain additions). + # (extended), Cyrillic and Greek; Noto Arabic + Noto CJK ride alongside as + # per-glyph fallbacks so RTL and CJK text render instead of tofu. Fonts are + # a per-locale resource, extensible to any script. Licenses: DejaVu + # (Bitstream Vera / public domain), Noto (SIL OFL 1.1). See assets/fonts/. - assets/fonts/DejaVuSans.ttf - assets/fonts/DejaVuSans-Bold.ttf + - assets/fonts/NotoSansArabic.ttf + - assets/fonts/NotoSansJP.ttf fonts: # Custom seed-themed icon glyphs (chars a–k). See docs/mockups/seedks-glyphs.png. - family: seedks fonts: - asset: fonts/seedks.ttf + # Script coverage fallbacks for the UI text theme (see ui/theme.dart). Noto + # (SIL OFL 1.1) covers Arabic (RTL) and CJK (Japanese kanji/kana + shared + # Han) so those scripts render on every platform, including desktop where + # the system font may lack them. Variable fonts; Flutter picks the weight. + - family: Noto Sans Arabic + fonts: + - asset: assets/fonts/NotoSansArabic.ttf + - family: Noto Sans JP + fonts: + - asset: assets/fonts/NotoSansJP.ttf diff --git a/apps/app_seeds/test/data/calendar_test.dart b/apps/app_seeds/test/data/calendar_test.dart new file mode 100644 index 0000000..4d81b8a --- /dev/null +++ b/apps/app_seeds/test/data/calendar_test.dart @@ -0,0 +1,65 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/domain/crop_calendar.dart'; +import 'package:tane/state/inventory_cubit.dart'; + +import '../support/test_support.dart'; + +/// The aggregate crop calendar: `watchCalendar` surfaces every variety with a +/// recorded phase, and the inventory carries sow-months for the "this month" +/// filter. +void main() { + late AppDatabase db; + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + test('watchCalendar emits varieties with a recorded phase, masks intact', + () async { + final repo = newTestRepository(db); + final tomato = await repo.addQuickVariety(label: 'Tomate rosa'); + await repo.updateVariety( + id: tomato, + sowMonths: Value(monthsToMask([2, 3])), // Feb, Mar + seedHarvestMonths: Value(monthsToMask([8])), // Aug + ); + // A variety with no calendar at all is excluded. + await repo.addQuickVariety(label: 'Sin calendario'); + + final entries = await repo.watchCalendar().first; + expect(entries, hasLength(1)); + final e = entries.single; + expect(e.label, 'Tomate rosa'); + expect(maskHasMonth(e.sowMonths, 3), isTrue); + expect(maskHasMonth(e.sowMonths, 5), isFalse); + expect(maskHasMonth(e.seedHarvestMonths, 8), isTrue); + }); + + test('the inventory list carries sow-months', () async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Lechuga'); + await repo.updateVariety(id: id, sowMonths: Value(monthsToMask([9]))); + + final items = await repo.watchInventory().first; + expect(items.single.sowMonths, monthsToMask([9])); + }); + + test('the sow-this-month filter keeps only what sows in the pinned month', () { + const march = 3; + final sowsInMarch = _item('a', monthsToMask([3, 4])); + final sowsInJune = _item('b', monthsToMask([6])); + final noCalendar = _item('c', null); + final state = InventoryState( + items: [sowsInMarch, sowsInJune, noCalendar], + sowThisMonthOnly: true, + filterMonth: march, + loading: false, + ); + expect(state.visibleItems.map((i) => i.id), ['a']); + expect(state.hasSowCalendar, isTrue); + }); +} + +VarietyListItem _item(String id, int? sowMonths) => + VarietyListItem(id: id, label: id, sowMonths: sowMonths); diff --git a/apps/app_seeds/test/data/export_import/seed_label_codec_test.dart b/apps/app_seeds/test/data/export_import/seed_label_codec_test.dart new file mode 100644 index 0000000..83b3f10 --- /dev/null +++ b/apps/app_seeds/test/data/export_import/seed_label_codec_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/export_import/seed_label_codec.dart'; + +void main() { + group('SeedLabelCodec.encode', () { + test('encodes all fields as a tane://seed URI', () { + final uri = SeedLabelCodec.encode( + const SeedLabelData( + varietyLabel: 'Acelga de Perales', + scientificName: 'Beta vulgaris', + commonName: 'Acelga', + year: 2006, + origin: 'Perales', + ), + ); + expect(uri, startsWith('tane://seed?')); + final parsed = Uri.parse(uri); + expect(parsed.scheme, 'tane'); + expect(parsed.host, 'seed'); + expect(parsed.queryParameters, { + 'v': 'Acelga de Perales', + 's': 'Beta vulgaris', + 'c': 'Acelga', + 'y': '2006', + 'o': 'Perales', + }); + }); + + test('omits absent fields, keeping the payload small', () { + final uri = SeedLabelCodec.encode( + const SeedLabelData(varietyLabel: 'Alubia de Tolosa'), + ); + final parsed = Uri.parse(uri); + expect(parsed.queryParameters, {'v': 'Alubia de Tolosa'}); + expect(uri, isNot(contains('&s='))); + expect(uri, isNot(contains('y='))); + }); + + test('treats blank optional fields as absent', () { + final uri = SeedLabelCodec.encode( + const SeedLabelData( + varietyLabel: 'Maíz', + scientificName: ' ', + origin: '', + ), + ); + expect(Uri.parse(uri).queryParameters, {'v': 'Maíz'}); + }); + }); + + group('SeedLabelCodec round-trip', () { + test('decode reverses encode for full data', () { + const data = SeedLabelData( + varietyLabel: 'Tomate Rosa', + scientificName: 'Solanum lycopersicum', + commonName: 'Tomate', + year: 2024, + origin: 'Huerta de la abuela, León', + ); + expect(SeedLabelCodec.decode(SeedLabelCodec.encode(data)), data); + }); + + test('survives unicode, spaces and separators', () { + const data = SeedLabelData( + varietyLabel: 'Grelo & nabo "do país"', + origin: 'A Coruña / Galiza', + commonName: 'بامية', // RTL script + ); + final decoded = SeedLabelCodec.decode(SeedLabelCodec.encode(data)); + expect(decoded, data); + }); + }); + + group('SeedLabelCodec.decode', () { + test('rejects a foreign scheme', () { + expect(SeedLabelCodec.decode('https://seed?v=x'), isNull); + expect(SeedLabelCodec.decode('tane://other?v=x'), isNull); + }); + + test('rejects a payload without a variety', () { + expect(SeedLabelCodec.decode('tane://seed?s=Beta%20vulgaris'), isNull); + }); + + test('ignores an unparseable year rather than throwing', () { + final decoded = SeedLabelCodec.decode('tane://seed?v=Maíz&y=old'); + expect(decoded?.varietyLabel, 'Maíz'); + expect(decoded?.year, isNull); + }); + }); +} diff --git a/apps/app_seeds/test/data/handover_test.dart b/apps/app_seeds/test/data/handover_test.dart new file mode 100644 index 0000000..97b54f3 --- /dev/null +++ b/apps/app_seeds/test/data/handover_test.dart @@ -0,0 +1,180 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + late VarietyRepository repo; + late String varietyId; + setUp(() async { + db = newTestDatabase(); + repo = newTestRepository(db); + varietyId = await repo.addQuickVariety(label: 'Tomate rosa'); + }); + tearDown(() => db.close()); + + Future movementById(String id) => + (db.select(db.movements)..where((m) => m.id.equals(id))).getSingle(); + + Future lotById(String id) => + (db.select(db.lots)..where((l) => l.id.equals(id))).getSingle(); + + test('a plain gift records just the given movement', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + ); + + expect(result.saleId, isNull); + expect(result.plantareId, isNull); + final movement = await movementById(result.movementId); + expect(movement.type, MovementType.given); + expect(movement.lotId, lotId); + expect(movement.notes, 'María'); + expect(movement.plantareId, isNull); + expect(await repo.watchSales().first, isEmpty); + expect(await repo.watchPlantares().first, isEmpty); + }); + + test( + 'giving all depletes the lot, privatizes it and drops its price', + () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + abundance: Abundance.plentyToShare, + offerStatus: OfferStatus.sell, + priceAmount: 3, + priceCurrency: '€', + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + gaveAll: true, + ); + + // The movement carries what actually moved: the lot's whole quantity. + final movement = await movementById(result.movementId); + expect(movement.quantityKind, QuantityKind.grams.name); + expect(movement.quantityPrecise, 40); + + // The lot stays as an empty jar, withdrawn from the market. + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 0); + expect(lot.quantityKind, QuantityKind.grams.name); + expect(lot.abundance, isNull); + expect(lot.offerStatus, OfferStatus.private); + expect(lot.priceAmount, isNull); + expect(lot.priceCurrency, isNull); + expect( + (await repo.shareableLots()).where((l) => l.lotId == lotId), + isEmpty, + ); + }, + ); + + test('giving part keeps the lot open and offered', () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + offerStatus: OfferStatus.shared, + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + quantity: const Quantity(kind: QuantityKind.grams, count: 10), + ); + + final movement = await movementById(result.movementId); + expect(movement.quantityPrecise, 10); + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 40); // untouched: the log holds the history + expect(lot.offerStatus, OfferStatus.shared); + expect( + (await repo.shareableLots()).where((l) => l.lotId == lotId), + hasLength(1), + ); + }); + + test('a payment on a give creates the matching sale record', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + withPayment: true, + paymentAmount: 2.5, + paymentCurrency: 'Ğ1', + ); + + final sales = await repo.watchSales().first; + expect(sales, hasLength(1)); + expect(sales.single.id, result.saleId); + expect(sales.single.direction, SaleDirection.iSold); + expect(sales.single.varietyId, varietyId); + expect(sales.single.counterparty, 'María'); + expect(sales.single.amount, 2.5); + expect(sales.single.currency, 'Ğ1'); + }); + + test('a promise on a give creates a plantare owed to me, linked from the ' + 'movement', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + withPromise: true, + promiseOwedDescription: 'un puñado la próxima temporada', + ); + + final plantares = await repo.watchPlantares().first; + expect(plantares, hasLength(1)); + expect(plantares.single.id, result.plantareId); + expect(plantares.single.direction, PlantareDirection.owedToMe); + expect(plantares.single.varietyId, varietyId); + expect(plantares.single.counterparty, 'María'); + expect(plantares.single.owedDescription, 'un puñado la próxima temporada'); + // The link the model reserved: the movement carries the plantare id. + final movement = await movementById(result.movementId); + expect(movement.plantareId, result.plantareId); + }); + + test('receiving with payment and promise mirrors the directions', () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iReceived, + // Meaningless on a receive; must not deplete anything. + gaveAll: true, + withPayment: true, + paymentAmount: 5, + paymentCurrency: '€', + withPromise: true, + ); + + final movement = await movementById(result.movementId); + expect(movement.type, MovementType.received); + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 40); + final sales = await repo.watchSales().first; + expect(sales.single.direction, SaleDirection.iBought); + final plantares = await repo.watchPlantares().first; + expect(plantares.single.direction, PlantareDirection.iReturn); + }); +} diff --git a/apps/app_seeds/test/data/plantare_test.dart b/apps/app_seeds/test/data/plantare_test.dart new file mode 100644 index 0000000..4540547 --- /dev/null +++ b/apps/app_seeds/test/data/plantare_test.dart @@ -0,0 +1,189 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +/// A Plantare is a reproduction commitment (data-model §2.7): create it, list +/// it, mark it returned/forgiven, and remove it (soft-delete). +void main() { + late AppDatabase db; + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + test('records a commitment and lists it (globally and per variety)', () async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Tomate rosa'); + final id = await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: vid, + counterparty: 'Ana', + owedDescription: 'un puñado la próxima temporada', + ); + + final all = await repo.watchPlantares().first; + expect(all, hasLength(1)); + expect(all.single.id, id); + expect(all.single.direction, PlantareDirection.iReturn); + expect(all.single.counterparty, 'Ana'); + expect(all.single.status, PlantareStatus.open); + expect(all.single.madeOn, greaterThan(0)); + + final forVariety = await repo.watchPlantaresForVariety(vid).first; + expect(forVariety.single.id, id); + // A different variety has none. + final other = await repo.addQuickVariety(label: 'Judía'); + expect(await repo.watchPlantaresForVariety(other).first, isEmpty); + }); + + test('marking it returned stamps the settle time; forgiving keeps it settled', + () async { + final repo = newTestRepository(db); + final id = await repo.createPlantare(direction: PlantareDirection.owedToMe); + + await repo.setPlantareStatus(id, PlantareStatus.returned); + var row = (await repo.watchPlantares().first).single; + expect(row.status, PlantareStatus.returned); + expect(row.settledOn, isNotNull); + + // Reopening clears the settle stamp. + await repo.setPlantareStatus(id, PlantareStatus.open); + row = (await repo.watchPlantares().first).single; + expect(row.status, PlantareStatus.open); + expect(row.settledOn, isNull); + }); + + test('deleting a commitment tombstones it (drops from the list)', () async { + final repo = newTestRepository(db); + final id = await repo.createPlantare(direction: PlantareDirection.iReturn); + expect(await repo.watchPlantares().first, hasLength(1)); + await repo.deletePlantare(id); + expect(await repo.watchPlantares().first, isEmpty); + }); + + test('watchPlantareViews joins the linked variety label (null when unlinked)', + () async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Tomate rosa'); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: vid, + ); + await repo.createPlantare(direction: PlantareDirection.owedToMe); + + final views = await repo.watchPlantareViews().first; + expect(views, hasLength(2)); + final linked = views.firstWhere((v) => v.plantare.varietyId == vid); + expect(linked.varietyLabel, 'Tomate rosa'); + final unlinked = views.firstWhere((v) => v.plantare.varietyId == null); + expect(unlinked.varietyLabel, isNull); + }); + + test('a return-by date round-trips through create', () async { + final repo = newTestRepository(db); + final due = DateTime(2027, 3, 1).millisecondsSinceEpoch; + await repo.createPlantare( + direction: PlantareDirection.iReturn, + dueBy: due, + ); + expect((await repo.watchPlantares().first).single.dueBy, due); + }); + + test('a bilateral pledge stores keys/signatures and looks up by pledgeId', + () async { + final repo = newTestRepository(db); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + counterparty: 'Ana', + pledgeId: 'pledge-1', + debtorKey: 'dkey', + creditorKey: 'ckey', + creditorSignature: 'csig', + remoteState: PlantareRemoteState.proposed, + returnKind: PlantareReturnKind.workHours, + workHours: 3, + ); + + final row = await repo.plantareByPledgeId('pledge-1'); + expect(row, isNotNull); + expect(row!.debtorKey, 'dkey'); + expect(row.creditorKey, 'ckey'); + expect(row.creditorSignature, 'csig'); + expect(row.debtorSignature, isNull); + expect(row.remoteState, PlantareRemoteState.proposed); + expect(row.returnKind, PlantareReturnKind.workHours); + expect(row.workHours, 3); + expect(await repo.plantareByPledgeId('nope'), isNull); + }); + + test('accepting a proposal adds the counter-stub and flips to accepted', + () async { + final repo = newTestRepository(db); + await repo.createPlantare( + direction: PlantareDirection.owedToMe, + pledgeId: 'pledge-2', + debtorKey: 'dkey', + creditorKey: 'ckey', + creditorSignature: 'csig', + remoteState: PlantareRemoteState.proposed, + ); + + await repo.applyPlantareSignatures( + pledgeId: 'pledge-2', + debtorSignature: 'dsig', + remoteState: PlantareRemoteState.accepted, + movementId: 'mov-1', + ); + + final row = await repo.plantareByPledgeId('pledge-2'); + expect(row!.debtorSignature, 'dsig'); + expect(row.creditorSignature, 'csig'); // untouched + expect(row.remoteState, PlantareRemoteState.accepted); + expect(row.movementId, 'mov-1'); + }); + + test('a v1 local note keeps the bilateral columns null (defaults)', () async { + final repo = newTestRepository(db); + final id = await repo.createPlantare(direction: PlantareDirection.iReturn); + final row = (await repo.watchPlantares().first).single; + expect(row.id, id); + expect(row.pledgeId, isNull); + expect(row.debtorKey, isNull); + expect(row.remoteState, isNull); + expect(row.returnKind, PlantareReturnKind.similar); // default + }); + + test('commitments survive a backup round-trip (in exportInventory/import)', + () async { + final repoA = newTestRepository(db); + final vid = await repoA.addQuickVariety(label: 'Maíz'); + await repoA.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: vid, + counterparty: 'Colectivo semillero', + owedDescription: 'una mazorca', + pledgeId: 'pledge-3', + debtorKey: 'dkey', + creditorKey: 'ckey', + debtorSignature: 'dsig', + creditorSignature: 'csig', + remoteState: PlantareRemoteState.accepted, + ); + + final snapshot = await repoA.exportInventory(); + expect(snapshot.plantares, hasLength(1)); + + final dbB = newTestDatabase(); + addTearDown(dbB.close); + final repoB = newTestRepository(dbB); + await repoB.importInventory(snapshot); + final onB = await repoB.watchPlantares().first; + expect(onB.single.counterparty, 'Colectivo semillero'); + expect(onB.single.owedDescription, 'una mazorca'); + // The bilateral form rides the backup too. + expect(onB.single.pledgeId, 'pledge-3'); + expect(onB.single.debtorSignature, 'dsig'); + expect(onB.single.creditorSignature, 'csig'); + expect(onB.single.remoteState, PlantareRemoteState.accepted); + }); +} diff --git a/apps/app_seeds/test/data/sales_test.dart b/apps/app_seeds/test/data/sales_test.dart new file mode 100644 index 0000000..4c66dc0 --- /dev/null +++ b/apps/app_seeds/test/data/sales_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +/// A Sale is a recorded seed sale/purchase — a model separate from a gift or a +/// Plantare, with a price in any currency. +void main() { + late AppDatabase db; + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + test('records a sale with amount + currency and lists it', () async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Tomate rosa'); + await repo.createSale( + direction: SaleDirection.iSold, + varietyId: vid, + counterparty: 'Ana', + amount: 5, + currency: '€', + ); + + final all = await repo.watchSales().first; + expect(all.single.direction, SaleDirection.iSold); + expect(all.single.amount, 5); + expect(all.single.currency, '€'); + expect(all.single.counterparty, 'Ana'); + expect(await repo.watchSalesForVariety(vid).first, hasLength(1)); + }); + + test('supports any currency (Ğ1, time…) and a price-less record', () async { + final repo = newTestRepository(db); + await repo.createSale(direction: SaleDirection.iBought, amount: 12, currency: 'Ğ1'); + await repo.createSale(direction: SaleDirection.iSold); // no figure yet + final all = await repo.watchSales().first; + expect(all, hasLength(2)); + expect(all.any((s) => s.currency == 'Ğ1' && s.amount == 12), isTrue); + expect(all.any((s) => s.amount == null), isTrue); + }); + + test('deleting a sale tombstones it', () async { + final repo = newTestRepository(db); + final id = await repo.createSale(direction: SaleDirection.iSold, amount: 3); + expect(await repo.watchSales().first, hasLength(1)); + await repo.deleteSale(id); + expect(await repo.watchSales().first, isEmpty); + }); + + test('sales survive a backup round-trip', () async { + final repoA = newTestRepository(db); + await repoA.createSale( + direction: SaleDirection.iSold, + counterparty: 'Feria de la comarca', + amount: 2.5, + currency: '€', + ); + final snapshot = await repoA.exportInventory(); + expect(snapshot.sales, hasLength(1)); + + final dbB = newTestDatabase(); + addTearDown(dbB.close); + final repoB = newTestRepository(dbB); + await repoB.importInventory(snapshot); + final onB = await repoB.watchSales().first; + expect(onB.single.amount, 2.5); + expect(onB.single.currency, '€'); + expect(onB.single.counterparty, 'Feria de la comarca'); + }); +} diff --git a/apps/app_seeds/test/data/seed_saving_asset_test.dart b/apps/app_seeds/test/data/seed_saving_asset_test.dart new file mode 100644 index 0000000..fb8e049 --- /dev/null +++ b/apps/app_seeds/test/data/seed_saving_asset_test.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/seed_saving.dart'; + +/// Guards the committed bundled seed-saving asset: it must parse, cover the +/// major families, and carry the notable per-species corrections the UI relies +/// on. Reads the file directly — no Flutter asset bundle — so it runs as a +/// plain VM test. +void main() { + final data = + parseSeedSaving(File('assets/catalog/seed_saving.json').readAsStringSync()); + + test('resolves the major families', () { + for (final family in const [ + 'Solanaceae', + 'Fabaceae', + 'Cucurbitaceae', + 'Brassicaceae', + 'Asteraceae', + 'Apiaceae', + 'Amaranthaceae', + 'Poaceae', + ]) { + expect(data.guideFor(family: family)?.hasAny, isTrue, reason: family); + } + }); + + test('faba bean is corrected to insect cross-pollination', () { + // The whole point of species overrides: unlike other legumes, Vicia faba + // crosses — the guidance must not inherit Fabaceae "self". + final g = data.guideFor(scientificName: 'Vicia faba', family: 'Fabaceae'); + expect(g?.pollination, Pollination.cross); + expect(g?.vector, PollinationVector.insect); + expect(g?.noteFor('es'), isNotNull); + }); + + test('tomato self-pollinates and is wet-processed', () { + final g = data.guideFor( + scientificName: 'Solanum lycopersicum', family: 'Solanaceae'); + expect(g?.pollination, Pollination.self); + expect(g?.processing, SeedProcessing.wet); + expect(g?.difficulty, SavingDifficulty.easy); + }); + + test('maize is wind-pollinated with a large population', () { + final g = data.guideFor(scientificName: 'Zea mays', family: 'Poaceae'); + expect(g?.vector, PollinationVector.wind); + expect(g?.recommendedPlants, greaterThanOrEqualTo(50)); + }); + + test('carries source attributions', () { + expect(data.sources, isNotEmpty); + expect(data.sources.map((s) => s.title), contains('Seed to Seed')); + }); +} diff --git a/apps/app_seeds/test/data/shareable_lots_test.dart b/apps/app_seeds/test/data/shareable_lots_test.dart new file mode 100644 index 0000000..8a87e1c --- /dev/null +++ b/apps/app_seeds/test/data/shareable_lots_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + test('shareableLots returns non-private lots with their id and label', + () async { + final repo = newTestRepository(db); + final varietyId = await repo.addQuickVariety( + label: 'Tomate rosa', + category: 'Solanaceae', + ); + final sharedId = await repo.addLot( + varietyId: varietyId, + offerStatus: OfferStatus.exchange, + ); + + final lots = await repo.shareableLots(); + final shared = lots.where((l) => l.lotId == sharedId).toList(); + expect(shared, hasLength(1)); + expect(shared.single.summary, 'Tomate rosa'); + expect(shared.single.offerStatus, OfferStatus.exchange); + expect(shared.single.category, 'Solanaceae'); + }); + + test('private lots are never shareable', () async { + final repo = newTestRepository(db); + final varietyId = await repo.addQuickVariety(label: 'Pimiento'); + final privateId = await repo.addLot( + varietyId: varietyId, + offerStatus: OfferStatus.private, + ); + + final lots = await repo.shareableLots(); + expect(lots.where((l) => l.lotId == privateId), isEmpty); + }); + + test('sell lots expose their asking price; other statuses never do', + () async { + final repo = newTestRepository(db); + final varietyId = await repo.addQuickVariety(label: 'Calabaza'); + final sellId = await repo.addLot( + varietyId: varietyId, + offerStatus: OfferStatus.sell, + priceAmount: 3.5, + priceCurrency: 'Ğ1', + ); + // Price passed on a gift lot is dropped, not stored. + final giftId = await repo.addLot( + varietyId: varietyId, + offerStatus: OfferStatus.shared, + priceAmount: 99, + priceCurrency: '€', + ); + + final lots = await repo.shareableLots(); + final sell = lots.singleWhere((l) => l.lotId == sellId); + expect(sell.priceAmount, 3.5); + expect(sell.priceCurrency, 'Ğ1'); + final gift = lots.singleWhere((l) => l.lotId == giftId); + expect(gift.priceAmount, isNull); + expect(gift.priceCurrency, isNull); + }); + + test('a sell lot without amount is "to be agreed" — shareable, no price', + () async { + final repo = newTestRepository(db); + final varietyId = await repo.addQuickVariety(label: 'Lechuga'); + final sellId = await repo.addLot( + varietyId: varietyId, + offerStatus: OfferStatus.sell, + ); + + final lots = await repo.shareableLots(); + final sell = lots.singleWhere((l) => l.lotId == sellId); + expect(sell.priceAmount, isNull); + }); + + test('moving a lot out of sale clears its stored price', () async { + final repo = newTestRepository(db); + final varietyId = await repo.addQuickVariety(label: 'Haba'); + final lotId = await repo.addLot( + varietyId: varietyId, + offerStatus: OfferStatus.sell, + priceAmount: 2, + priceCurrency: '€', + ); + + await repo.updateLot( + lotId: lotId, + type: LotType.seed, + offerStatus: OfferStatus.shared, + // Price args still sent by the form; must be ignored off-sale. + priceAmount: 2, + priceCurrency: '€', + ); + + final detail = await repo.watchVariety(varietyId).first; + final lot = detail!.lots.singleWhere((l) => l.id == lotId); + expect(lot.priceAmount, isNull); + expect(lot.priceCurrency, isNull); + expect(lot.offerStatus, OfferStatus.shared); + }); +} diff --git a/apps/app_seeds/test/data/species_catalog_asset_test.dart b/apps/app_seeds/test/data/species_catalog_asset_test.dart index 2ea7509..17c3a27 100644 --- a/apps/app_seeds/test/data/species_catalog_asset_test.dart +++ b/apps/app_seeds/test/data/species_catalog_asset_test.dart @@ -17,6 +17,16 @@ void main() { expect(json['version'], 3); }); + test('speciesCatalogVersion mirrors the asset version', () { + // The startup seed guard skips the reseed by comparing this constant to the + // version it last recorded; it must track the asset or a bumped catalog is + // never re-seeded. + expect( + speciesCatalogVersion, + parseSpeciesCatalogVersion(file.readAsStringSync()), + ); + }); + test('is a broad catalog, not the old starter set', () { // The expanded catalog holds ~1200 species (was 14 by hand). The floor // leaves headroom for Wikidata drift across regenerations. diff --git a/apps/app_seeds/test/data/species_repository_test.dart b/apps/app_seeds/test/data/species_repository_test.dart index bb862e4..9c4a0fe 100644 --- a/apps/app_seeds/test/data/species_repository_test.dart +++ b/apps/app_seeds/test/data/species_repository_test.dart @@ -237,4 +237,50 @@ void main() { // No species named → no suggestion. expect(await repo.classifyLabel('Girasol gigante'), isNull); }); + + group('seedBundledIfNeeded', () { + test('seeds on first run and skips the reload once recorded', () async { + String? stored; + var loads = 0; + Future> load() async { + loads++; + return _seeds; + } + + await repo.seedBundledIfNeeded( + version: 3, + readVersion: () async => stored, + writeVersion: (v) async => stored = v, + loadSeeds: load, + ); + expect(loads, 1); + expect(stored, '3'); + expect(await repo.search('Tomato'), isNotEmpty); + + // Same version already recorded → the ~1.5 MB parse is never loaded again. + await repo.seedBundledIfNeeded( + version: 3, + readVersion: () async => stored, + writeVersion: (v) async => stored = v, + loadSeeds: load, + ); + expect(loads, 1); + }); + + test('reseeds when the catalog version changes', () async { + var stored = '3'; + var loads = 0; + await repo.seedBundledIfNeeded( + version: 4, + readVersion: () async => stored, + writeVersion: (v) async => stored = v, + loadSeeds: () async { + loads++; + return _seeds; + }, + ); + expect(loads, 1); + expect(stored, '4'); + }); + }); } diff --git a/apps/app_seeds/test/data/variety_repository_test.dart b/apps/app_seeds/test/data/variety_repository_test.dart index d9903ce..b41f3c5 100644 --- a/apps/app_seeds/test/data/variety_repository_test.dart +++ b/apps/app_seeds/test/data/variety_repository_test.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/species_repository.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; import 'package:tane/db/enums.dart'; @@ -85,4 +86,72 @@ void main() { expect(stamps.toSet().length, 2); expect(() => stamps.map(Hlc.parse).toList(), returnsNormally); }); + + group('labelRows', () { + test('expands one entry per lot and resolves the locale common name', () async { + final speciesRepo = newTestSpeciesRepository(db); + await speciesRepo.seedBundled(const [ + SpeciesSeed( + scientificName: 'Beta vulgaris', + family: 'Amaranthaceae', + commonNames: { + 'en': ['Chard'], + 'es': ['Acelga'], + }, + ), + ]); + final speciesId = (await db.select(db.species).getSingle()).id; + + final v = await repo.addQuickVariety( + label: 'Acelga de Perales', + category: 'Amaranthaceae', + ); + await repo.linkSpecies(v, speciesId); + await repo.addLot(varietyId: v, harvestYear: 2006, originName: 'Perales'); + await repo.addLot(varietyId: v, harvestYear: 2008); + + final rows = await repo.labelRows({v}, languageCode: 'es'); + expect(rows.length, 2); // one per lot + expect(rows.first.commonName, 'Acelga'); // locale-picked, not 'Chard' + expect(rows.first.scientificName, 'Beta vulgaris'); + expect(rows.map((r) => r.harvestYear), [2006, 2008]); + expect(rows.first.originName, 'Perales'); + }); + + test('yields a single name-only entry for a variety with no lots', () async { + final v = await repo.addQuickVariety(label: 'Alubia de Tolosa'); + final rows = await repo.labelRows({v}, languageCode: 'en'); + expect(rows.single.varietyLabel, 'Alubia de Tolosa'); + expect(rows.single.harvestYear, isNull); + expect(rows.single.commonName, isNull); + }); + + test('includes only varieties in the selection', () async { + final selected = await repo.addQuickVariety(label: 'Selected'); + await repo.addQuickVariety(label: 'Not selected'); + final rows = await repo.labelRows({selected}, languageCode: 'en'); + expect(rows.map((r) => r.varietyLabel), ['Selected']); + }); + + test('suggests copies from the latest container count', () async { + final v = await repo.addQuickVariety(label: 'Tomate'); + final withJars = await repo.addLot(varietyId: v, harvestYear: 2024); + final noCheck = await repo.addLot(varietyId: v, harvestYear: 2025); + // Two checks on the same lot: the most recent (3 jars) wins. + await repo.addConditionCheck(lotId: withJars, checkedOn: 1, containerCount: 2); + await repo.addConditionCheck(lotId: withJars, checkedOn: 2, containerCount: 3); + + final rows = await repo.labelRows({v}, languageCode: 'en'); + final byYear = {for (final r in rows) r.harvestYear: r}; + expect(byYear[2024]!.suggestedCopies, 3); // latest check + expect(byYear[2025]!.suggestedCopies, 1); // no check → default 1 + expect(noCheck, isNotEmpty); + }); + + test('a variety with no lots suggests a single copy', () async { + final v = await repo.addQuickVariety(label: 'Alubia'); + final rows = await repo.labelRows({v}, languageCode: 'en'); + expect(rows.single.suggestedCopies, 1); + }); + }); } diff --git a/apps/app_seeds/test/db/migration_test.dart b/apps/app_seeds/test/db/migration_test.dart index 5ad6f0e..e117369 100644 --- a/apps/app_seeds/test/db/migration_test.dart +++ b/apps/app_seeds/test/db/migration_test.dart @@ -11,68 +11,20 @@ void main() { verifier = SchemaVerifier(GeneratedHelper()); }); - test('freshly created database matches the exported schema v8', () async { - final schema = await verifier.schemaAt(8); + test('freshly created database matches the exported schema v12', () async { + final schema = await verifier.schemaAt(12); final db = AppDatabase(schema.newConnection()); - await verifier.migrateAndValidate(db, 8); + await verifier.migrateAndValidate(db, 12); await db.close(); }); - test('upgrades v1 → v8 (Lot.type, harvestMonth, presentation, ' - 'Attachment.sortOrder, Variety.isDraft/isOrganic, Species.viabilityYears, ' - 'v8 provenance/calendar/abundance/condition-checks) and matches the fresh ' - 'schema', () async { - final connection = await verifier.startAt(1); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 8); - await db.close(); - }); - - test('upgrades v2 → v8 and matches the fresh schema', () async { - final connection = await verifier.startAt(2); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 8); - await db.close(); - }); - - test('upgrades v3 → v8 and matches the fresh schema', () async { - final connection = await verifier.startAt(3); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 8); - await db.close(); - }); - - test('upgrades v4 → v8 and matches the fresh schema', () async { - final connection = await verifier.startAt(4); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 8); - await db.close(); - }); - - test( - 'upgrades v5 → v8 (adds Variety.isDraft) and matches the fresh schema', - () async { - final connection = await verifier.startAt(5); + // Every historical version upgrades cleanly to the current schema (v12). + for (var from = 1; from <= 11; from++) { + test('upgrades v$from → v12 and matches the fresh schema', () async { + final connection = await verifier.startAt(from); final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 8); + await verifier.migrateAndValidate(db, 12); await db.close(); - }, - ); - - test('upgrades v6 → v8 (adds Variety.isOrganic, Species.viabilityYears) and ' - 'matches the fresh schema', () async { - final connection = await verifier.startAt(6); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 8); - await db.close(); - }); - - test('upgrades v7 → v8 (adds Variety.needsReproduction + crop calendar, ' - 'Lot.origin/abundance/preservationFormat, ConditionChecks) and matches ' - 'the fresh schema', () async { - final connection = await verifier.startAt(7); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 8); - await db.close(); - }); + }); + } } diff --git a/apps/app_seeds/test/db/schema/schema.dart b/apps/app_seeds/test/db/schema/schema.dart index f76d308..49f49d7 100644 --- a/apps/app_seeds/test/db/schema/schema.dart +++ b/apps/app_seeds/test/db/schema/schema.dart @@ -12,6 +12,10 @@ import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; import 'schema_v8.dart' as v8; +import 'schema_v9.dart' as v9; +import 'schema_v10.dart' as v10; +import 'schema_v11.dart' as v11; +import 'schema_v12.dart' as v12; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -33,10 +37,18 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v7.DatabaseAtV7(db); case 8: return v8.DatabaseAtV8(db); + case 9: + return v9.DatabaseAtV9(db); + case 10: + return v10.DatabaseAtV10(db); + case 11: + return v11.DatabaseAtV11(db); + case 12: + return v12.DatabaseAtV12(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7, 8]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; } diff --git a/apps/app_seeds/test/db/schema/schema_v10.dart b/apps/app_seeds/test/db/schema/schema_v10.dart new file mode 100644 index 0000000..cb9ba19 --- /dev/null +++ b/apps/app_seeds/test/db/schema/schema_v10.dart @@ -0,0 +1,1997 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Varieties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Varieties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn cultivarName = GeneratedColumn( + 'cultivar_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isDraft = GeneratedColumn( + 'is_draft', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_draft IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isOrganic = GeneratedColumn( + 'is_organic', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_organic IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn needsReproduction = GeneratedColumn( + 'needs_reproduction', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'NOT NULL DEFAULT 0 CHECK (needs_reproduction IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn sowMonths = GeneratedColumn( + 'sow_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn transplantMonths = GeneratedColumn( + 'transplant_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn floweringMonths = GeneratedColumn( + 'flowering_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fruitingMonths = GeneratedColumn( + 'fruiting_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seedHarvestMonths = GeneratedColumn( + 'seed_harvest_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + label, + speciesId, + cultivarName, + category, + notes, + isDraft, + isOrganic, + needsReproduction, + sowMonths, + transplantMonths, + floweringMonths, + fruitingMonths, + seedHarvestMonths, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'varieties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Varieties createAlias(String alias) { + return Varieties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class VarietyVernacularNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + VarietyVernacularNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn region = GeneratedColumn( + 'region', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + name, + language, + region, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'variety_vernacular_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + VarietyVernacularNames createAlias(String alias) { + return VarietyVernacularNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Species extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Species(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn scientificName = GeneratedColumn( + 'scientific_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn wikidataQid = GeneratedColumn( + 'wikidata_qid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn gbifKey = GeneratedColumn( + 'gbif_key', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn family = GeneratedColumn( + 'family', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isBundled = GeneratedColumn( + 'is_bundled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_bundled IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn viabilityYears = GeneratedColumn( + 'viability_years', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + scientificName, + wikidataQid, + gbifKey, + family, + isBundled, + viabilityYears, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Species createAlias(String alias) { + return Species(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class SpeciesCommonNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SpeciesCommonNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + speciesId, + name, + language, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species_common_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SpeciesCommonNames createAlias(String alias) { + return SpeciesCommonNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Lots extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Lots(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'seed\'', + defaultValue: const CustomExpression('\'seed\''), + ); + late final GeneratedColumn harvestYear = GeneratedColumn( + 'harvest_year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn harvestMonth = GeneratedColumn( + 'harvest_month', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn presentation = GeneratedColumn( + 'presentation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn storageLocation = GeneratedColumn( + 'storage_location', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn offerStatus = GeneratedColumn( + 'offer_status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'private\'', + defaultValue: const CustomExpression('\'private\''), + ); + late final GeneratedColumn seedbankId = GeneratedColumn( + 'seedbank_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originName = GeneratedColumn( + 'origin_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originPlace = GeneratedColumn( + 'origin_place', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn abundance = GeneratedColumn( + 'abundance', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn preservationFormat = + GeneratedColumn( + 'preservation_format', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + type, + harvestYear, + harvestMonth, + quantityKind, + quantityPrecise, + quantityLabel, + presentation, + storageLocation, + offerStatus, + seedbankId, + originName, + originPlace, + abundance, + preservationFormat, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'lots'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Lots createAlias(String alias) { + return Lots(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class GerminationTests extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GerminationTests(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn testedOn = GeneratedColumn( + 'tested_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sampleSize = GeneratedColumn( + 'sample_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn germinatedCount = GeneratedColumn( + 'germinated_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + testedOn, + sampleSize, + germinatedCount, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'germination_tests'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GerminationTests createAlias(String alias) { + return GerminationTests(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ConditionChecks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ConditionChecks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn checkedOn = GeneratedColumn( + 'checked_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn containerCount = GeneratedColumn( + 'container_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn desiccantState = GeneratedColumn( + 'desiccant_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + checkedOn, + containerCount, + desiccantState, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'condition_checks'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ConditionChecks createAlias(String alias) { + return ConditionChecks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Movements extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Movements(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn occurredOn = GeneratedColumn( + 'occurred_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn counterpartyId = GeneratedColumn( + 'counterparty_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn parentMovementId = GeneratedColumn( + 'parent_movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn plantareId = GeneratedColumn( + 'plantare_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + lastAuthor, + schemaRowVersion, + lotId, + type, + occurredOn, + counterpartyId, + quantityKind, + quantityPrecise, + quantityLabel, + parentMovementId, + plantareId, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'movements'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Movements createAlias(String alias) { + return Movements(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Parties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Parties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn publicKey = GeneratedColumn( + 'public_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'person\'', + defaultValue: const CustomExpression('\'person\''), + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + displayName, + publicKey, + kind, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'parties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Parties createAlias(String alias) { + return Parties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Attachments extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Attachments(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn uri = GeneratedColumn( + 'uri', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn bytes = + GeneratedColumn( + 'bytes', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mimeType = GeneratedColumn( + 'mime_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + kind, + uri, + bytes, + mimeType, + sortOrder, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'attachments'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Attachments createAlias(String alias) { + return Attachments(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ExternalLinks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ExternalLinks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + url, + title, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'external_links'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ExternalLinks createAlias(String alias) { + return ExternalLinks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Plantares extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Plantares(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn owedDescription = GeneratedColumn( + 'owed_description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn madeOn = GeneratedColumn( + 'made_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn dueBy = GeneratedColumn( + 'due_by', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'open\'', + defaultValue: const CustomExpression('\'open\''), + ); + late final GeneratedColumn settledOn = GeneratedColumn( + 'settled_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plantares'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Plantares createAlias(String alias) { + return Plantares(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Sales extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Sales(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn amount = GeneratedColumn( + 'amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn soldOn = GeneratedColumn( + 'sold_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + amount, + currency, + soldOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sales'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Sales createAlias(String alias) { + return Sales(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV10 extends GeneratedDatabase { + DatabaseAtV10(QueryExecutor e) : super(e); + late final Varieties varieties = Varieties(this); + late final VarietyVernacularNames varietyVernacularNames = + VarietyVernacularNames(this); + late final Species species = Species(this); + late final SpeciesCommonNames speciesCommonNames = SpeciesCommonNames(this); + late final Lots lots = Lots(this); + late final GerminationTests germinationTests = GerminationTests(this); + late final ConditionChecks conditionChecks = ConditionChecks(this); + late final Movements movements = Movements(this); + late final Parties parties = Parties(this); + late final Attachments attachments = Attachments(this); + late final ExternalLinks externalLinks = ExternalLinks(this); + late final Plantares plantares = Plantares(this); + late final Sales sales = Sales(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + varieties, + varietyVernacularNames, + species, + speciesCommonNames, + lots, + germinationTests, + conditionChecks, + movements, + parties, + attachments, + externalLinks, + plantares, + sales, + ]; + @override + int get schemaVersion => 10; +} diff --git a/apps/app_seeds/test/db/schema/schema_v11.dart b/apps/app_seeds/test/db/schema/schema_v11.dart new file mode 100644 index 0000000..17d4b21 --- /dev/null +++ b/apps/app_seeds/test/db/schema/schema_v11.dart @@ -0,0 +1,2015 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Varieties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Varieties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn cultivarName = GeneratedColumn( + 'cultivar_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isDraft = GeneratedColumn( + 'is_draft', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_draft IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isOrganic = GeneratedColumn( + 'is_organic', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_organic IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn needsReproduction = GeneratedColumn( + 'needs_reproduction', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'NOT NULL DEFAULT 0 CHECK (needs_reproduction IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn sowMonths = GeneratedColumn( + 'sow_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn transplantMonths = GeneratedColumn( + 'transplant_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn floweringMonths = GeneratedColumn( + 'flowering_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fruitingMonths = GeneratedColumn( + 'fruiting_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seedHarvestMonths = GeneratedColumn( + 'seed_harvest_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + label, + speciesId, + cultivarName, + category, + notes, + isDraft, + isOrganic, + needsReproduction, + sowMonths, + transplantMonths, + floweringMonths, + fruitingMonths, + seedHarvestMonths, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'varieties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Varieties createAlias(String alias) { + return Varieties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class VarietyVernacularNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + VarietyVernacularNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn region = GeneratedColumn( + 'region', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + name, + language, + region, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'variety_vernacular_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + VarietyVernacularNames createAlias(String alias) { + return VarietyVernacularNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Species extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Species(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn scientificName = GeneratedColumn( + 'scientific_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn wikidataQid = GeneratedColumn( + 'wikidata_qid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn gbifKey = GeneratedColumn( + 'gbif_key', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn family = GeneratedColumn( + 'family', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isBundled = GeneratedColumn( + 'is_bundled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_bundled IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn viabilityYears = GeneratedColumn( + 'viability_years', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + scientificName, + wikidataQid, + gbifKey, + family, + isBundled, + viabilityYears, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Species createAlias(String alias) { + return Species(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class SpeciesCommonNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SpeciesCommonNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + speciesId, + name, + language, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species_common_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SpeciesCommonNames createAlias(String alias) { + return SpeciesCommonNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Lots extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Lots(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'seed\'', + defaultValue: const CustomExpression('\'seed\''), + ); + late final GeneratedColumn harvestYear = GeneratedColumn( + 'harvest_year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn harvestMonth = GeneratedColumn( + 'harvest_month', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn presentation = GeneratedColumn( + 'presentation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn storageLocation = GeneratedColumn( + 'storage_location', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn offerStatus = GeneratedColumn( + 'offer_status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'private\'', + defaultValue: const CustomExpression('\'private\''), + ); + late final GeneratedColumn seedbankId = GeneratedColumn( + 'seedbank_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originName = GeneratedColumn( + 'origin_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originPlace = GeneratedColumn( + 'origin_place', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn abundance = GeneratedColumn( + 'abundance', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn preservationFormat = + GeneratedColumn( + 'preservation_format', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn priceAmount = GeneratedColumn( + 'price_amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn priceCurrency = GeneratedColumn( + 'price_currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + type, + harvestYear, + harvestMonth, + quantityKind, + quantityPrecise, + quantityLabel, + presentation, + storageLocation, + offerStatus, + seedbankId, + originName, + originPlace, + abundance, + preservationFormat, + priceAmount, + priceCurrency, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'lots'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Lots createAlias(String alias) { + return Lots(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class GerminationTests extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GerminationTests(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn testedOn = GeneratedColumn( + 'tested_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sampleSize = GeneratedColumn( + 'sample_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn germinatedCount = GeneratedColumn( + 'germinated_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + testedOn, + sampleSize, + germinatedCount, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'germination_tests'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GerminationTests createAlias(String alias) { + return GerminationTests(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ConditionChecks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ConditionChecks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn checkedOn = GeneratedColumn( + 'checked_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn containerCount = GeneratedColumn( + 'container_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn desiccantState = GeneratedColumn( + 'desiccant_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + checkedOn, + containerCount, + desiccantState, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'condition_checks'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ConditionChecks createAlias(String alias) { + return ConditionChecks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Movements extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Movements(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn occurredOn = GeneratedColumn( + 'occurred_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn counterpartyId = GeneratedColumn( + 'counterparty_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn parentMovementId = GeneratedColumn( + 'parent_movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn plantareId = GeneratedColumn( + 'plantare_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + lastAuthor, + schemaRowVersion, + lotId, + type, + occurredOn, + counterpartyId, + quantityKind, + quantityPrecise, + quantityLabel, + parentMovementId, + plantareId, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'movements'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Movements createAlias(String alias) { + return Movements(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Parties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Parties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn publicKey = GeneratedColumn( + 'public_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'person\'', + defaultValue: const CustomExpression('\'person\''), + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + displayName, + publicKey, + kind, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'parties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Parties createAlias(String alias) { + return Parties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Attachments extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Attachments(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn uri = GeneratedColumn( + 'uri', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn bytes = + GeneratedColumn( + 'bytes', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mimeType = GeneratedColumn( + 'mime_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + kind, + uri, + bytes, + mimeType, + sortOrder, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'attachments'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Attachments createAlias(String alias) { + return Attachments(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ExternalLinks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ExternalLinks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + url, + title, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'external_links'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ExternalLinks createAlias(String alias) { + return ExternalLinks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Plantares extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Plantares(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn owedDescription = GeneratedColumn( + 'owed_description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn madeOn = GeneratedColumn( + 'made_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn dueBy = GeneratedColumn( + 'due_by', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'open\'', + defaultValue: const CustomExpression('\'open\''), + ); + late final GeneratedColumn settledOn = GeneratedColumn( + 'settled_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plantares'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Plantares createAlias(String alias) { + return Plantares(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Sales extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Sales(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn amount = GeneratedColumn( + 'amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn soldOn = GeneratedColumn( + 'sold_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + amount, + currency, + soldOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sales'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Sales createAlias(String alias) { + return Sales(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV11 extends GeneratedDatabase { + DatabaseAtV11(QueryExecutor e) : super(e); + late final Varieties varieties = Varieties(this); + late final VarietyVernacularNames varietyVernacularNames = + VarietyVernacularNames(this); + late final Species species = Species(this); + late final SpeciesCommonNames speciesCommonNames = SpeciesCommonNames(this); + late final Lots lots = Lots(this); + late final GerminationTests germinationTests = GerminationTests(this); + late final ConditionChecks conditionChecks = ConditionChecks(this); + late final Movements movements = Movements(this); + late final Parties parties = Parties(this); + late final Attachments attachments = Attachments(this); + late final ExternalLinks externalLinks = ExternalLinks(this); + late final Plantares plantares = Plantares(this); + late final Sales sales = Sales(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + varieties, + varietyVernacularNames, + species, + speciesCommonNames, + lots, + germinationTests, + conditionChecks, + movements, + parties, + attachments, + externalLinks, + plantares, + sales, + ]; + @override + int get schemaVersion => 11; +} diff --git a/apps/app_seeds/test/db/schema/schema_v12.dart b/apps/app_seeds/test/db/schema/schema_v12.dart new file mode 100644 index 0000000..7adb158 --- /dev/null +++ b/apps/app_seeds/test/db/schema/schema_v12.dart @@ -0,0 +1,2098 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Varieties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Varieties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn cultivarName = GeneratedColumn( + 'cultivar_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isDraft = GeneratedColumn( + 'is_draft', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_draft IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isOrganic = GeneratedColumn( + 'is_organic', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_organic IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn needsReproduction = GeneratedColumn( + 'needs_reproduction', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'NOT NULL DEFAULT 0 CHECK (needs_reproduction IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn sowMonths = GeneratedColumn( + 'sow_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn transplantMonths = GeneratedColumn( + 'transplant_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn floweringMonths = GeneratedColumn( + 'flowering_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fruitingMonths = GeneratedColumn( + 'fruiting_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seedHarvestMonths = GeneratedColumn( + 'seed_harvest_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + label, + speciesId, + cultivarName, + category, + notes, + isDraft, + isOrganic, + needsReproduction, + sowMonths, + transplantMonths, + floweringMonths, + fruitingMonths, + seedHarvestMonths, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'varieties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Varieties createAlias(String alias) { + return Varieties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class VarietyVernacularNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + VarietyVernacularNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn region = GeneratedColumn( + 'region', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + name, + language, + region, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'variety_vernacular_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + VarietyVernacularNames createAlias(String alias) { + return VarietyVernacularNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Species extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Species(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn scientificName = GeneratedColumn( + 'scientific_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn wikidataQid = GeneratedColumn( + 'wikidata_qid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn gbifKey = GeneratedColumn( + 'gbif_key', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn family = GeneratedColumn( + 'family', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isBundled = GeneratedColumn( + 'is_bundled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_bundled IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn viabilityYears = GeneratedColumn( + 'viability_years', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + scientificName, + wikidataQid, + gbifKey, + family, + isBundled, + viabilityYears, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Species createAlias(String alias) { + return Species(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class SpeciesCommonNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SpeciesCommonNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + speciesId, + name, + language, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species_common_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SpeciesCommonNames createAlias(String alias) { + return SpeciesCommonNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Lots extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Lots(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'seed\'', + defaultValue: const CustomExpression('\'seed\''), + ); + late final GeneratedColumn harvestYear = GeneratedColumn( + 'harvest_year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn harvestMonth = GeneratedColumn( + 'harvest_month', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn presentation = GeneratedColumn( + 'presentation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn storageLocation = GeneratedColumn( + 'storage_location', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn offerStatus = GeneratedColumn( + 'offer_status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'private\'', + defaultValue: const CustomExpression('\'private\''), + ); + late final GeneratedColumn seedbankId = GeneratedColumn( + 'seedbank_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originName = GeneratedColumn( + 'origin_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originPlace = GeneratedColumn( + 'origin_place', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn abundance = GeneratedColumn( + 'abundance', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn preservationFormat = + GeneratedColumn( + 'preservation_format', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn priceAmount = GeneratedColumn( + 'price_amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn priceCurrency = GeneratedColumn( + 'price_currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + type, + harvestYear, + harvestMonth, + quantityKind, + quantityPrecise, + quantityLabel, + presentation, + storageLocation, + offerStatus, + seedbankId, + originName, + originPlace, + abundance, + preservationFormat, + priceAmount, + priceCurrency, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'lots'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Lots createAlias(String alias) { + return Lots(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class GerminationTests extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GerminationTests(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn testedOn = GeneratedColumn( + 'tested_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sampleSize = GeneratedColumn( + 'sample_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn germinatedCount = GeneratedColumn( + 'germinated_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + testedOn, + sampleSize, + germinatedCount, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'germination_tests'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GerminationTests createAlias(String alias) { + return GerminationTests(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ConditionChecks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ConditionChecks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn checkedOn = GeneratedColumn( + 'checked_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn containerCount = GeneratedColumn( + 'container_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn desiccantState = GeneratedColumn( + 'desiccant_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + checkedOn, + containerCount, + desiccantState, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'condition_checks'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ConditionChecks createAlias(String alias) { + return ConditionChecks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Movements extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Movements(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn occurredOn = GeneratedColumn( + 'occurred_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn counterpartyId = GeneratedColumn( + 'counterparty_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn parentMovementId = GeneratedColumn( + 'parent_movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn plantareId = GeneratedColumn( + 'plantare_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + lastAuthor, + schemaRowVersion, + lotId, + type, + occurredOn, + counterpartyId, + quantityKind, + quantityPrecise, + quantityLabel, + parentMovementId, + plantareId, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'movements'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Movements createAlias(String alias) { + return Movements(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Parties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Parties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn publicKey = GeneratedColumn( + 'public_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'person\'', + defaultValue: const CustomExpression('\'person\''), + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + displayName, + publicKey, + kind, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'parties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Parties createAlias(String alias) { + return Parties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Attachments extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Attachments(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn uri = GeneratedColumn( + 'uri', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn bytes = + GeneratedColumn( + 'bytes', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mimeType = GeneratedColumn( + 'mime_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + kind, + uri, + bytes, + mimeType, + sortOrder, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'attachments'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Attachments createAlias(String alias) { + return Attachments(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ExternalLinks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ExternalLinks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + url, + title, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'external_links'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ExternalLinks createAlias(String alias) { + return ExternalLinks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Plantares extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Plantares(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn owedDescription = GeneratedColumn( + 'owed_description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn madeOn = GeneratedColumn( + 'made_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn dueBy = GeneratedColumn( + 'due_by', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'open\'', + defaultValue: const CustomExpression('\'open\''), + ); + late final GeneratedColumn settledOn = GeneratedColumn( + 'settled_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn pledgeId = GeneratedColumn( + 'pledge_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn debtorKey = GeneratedColumn( + 'debtor_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn creditorKey = GeneratedColumn( + 'creditor_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn debtorSignature = GeneratedColumn( + 'debtor_signature', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn creditorSignature = + GeneratedColumn( + 'creditor_signature', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn movementId = GeneratedColumn( + 'movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn remoteState = GeneratedColumn( + 'remote_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn returnKind = GeneratedColumn( + 'return_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'similar\'', + defaultValue: const CustomExpression('\'similar\''), + ); + late final GeneratedColumn workHours = GeneratedColumn( + 'work_hours', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + pledgeId, + debtorKey, + creditorKey, + debtorSignature, + creditorSignature, + movementId, + remoteState, + returnKind, + workHours, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plantares'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Plantares createAlias(String alias) { + return Plantares(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Sales extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Sales(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn amount = GeneratedColumn( + 'amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn soldOn = GeneratedColumn( + 'sold_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + amount, + currency, + soldOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sales'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Sales createAlias(String alias) { + return Sales(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV12 extends GeneratedDatabase { + DatabaseAtV12(QueryExecutor e) : super(e); + late final Varieties varieties = Varieties(this); + late final VarietyVernacularNames varietyVernacularNames = + VarietyVernacularNames(this); + late final Species species = Species(this); + late final SpeciesCommonNames speciesCommonNames = SpeciesCommonNames(this); + late final Lots lots = Lots(this); + late final GerminationTests germinationTests = GerminationTests(this); + late final ConditionChecks conditionChecks = ConditionChecks(this); + late final Movements movements = Movements(this); + late final Parties parties = Parties(this); + late final Attachments attachments = Attachments(this); + late final ExternalLinks externalLinks = ExternalLinks(this); + late final Plantares plantares = Plantares(this); + late final Sales sales = Sales(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + varieties, + varietyVernacularNames, + species, + speciesCommonNames, + lots, + germinationTests, + conditionChecks, + movements, + parties, + attachments, + externalLinks, + plantares, + sales, + ]; + @override + int get schemaVersion => 12; +} diff --git a/apps/app_seeds/test/db/schema/schema_v9.dart b/apps/app_seeds/test/db/schema/schema_v9.dart new file mode 100644 index 0000000..94c2111 --- /dev/null +++ b/apps/app_seeds/test/db/schema/schema_v9.dart @@ -0,0 +1,1845 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Varieties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Varieties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn cultivarName = GeneratedColumn( + 'cultivar_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isDraft = GeneratedColumn( + 'is_draft', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_draft IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isOrganic = GeneratedColumn( + 'is_organic', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_organic IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn needsReproduction = GeneratedColumn( + 'needs_reproduction', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'NOT NULL DEFAULT 0 CHECK (needs_reproduction IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn sowMonths = GeneratedColumn( + 'sow_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn transplantMonths = GeneratedColumn( + 'transplant_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn floweringMonths = GeneratedColumn( + 'flowering_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fruitingMonths = GeneratedColumn( + 'fruiting_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seedHarvestMonths = GeneratedColumn( + 'seed_harvest_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + label, + speciesId, + cultivarName, + category, + notes, + isDraft, + isOrganic, + needsReproduction, + sowMonths, + transplantMonths, + floweringMonths, + fruitingMonths, + seedHarvestMonths, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'varieties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Varieties createAlias(String alias) { + return Varieties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class VarietyVernacularNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + VarietyVernacularNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn region = GeneratedColumn( + 'region', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + name, + language, + region, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'variety_vernacular_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + VarietyVernacularNames createAlias(String alias) { + return VarietyVernacularNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Species extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Species(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn scientificName = GeneratedColumn( + 'scientific_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn wikidataQid = GeneratedColumn( + 'wikidata_qid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn gbifKey = GeneratedColumn( + 'gbif_key', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn family = GeneratedColumn( + 'family', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isBundled = GeneratedColumn( + 'is_bundled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_bundled IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn viabilityYears = GeneratedColumn( + 'viability_years', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + scientificName, + wikidataQid, + gbifKey, + family, + isBundled, + viabilityYears, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Species createAlias(String alias) { + return Species(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class SpeciesCommonNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SpeciesCommonNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + speciesId, + name, + language, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species_common_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SpeciesCommonNames createAlias(String alias) { + return SpeciesCommonNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Lots extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Lots(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'seed\'', + defaultValue: const CustomExpression('\'seed\''), + ); + late final GeneratedColumn harvestYear = GeneratedColumn( + 'harvest_year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn harvestMonth = GeneratedColumn( + 'harvest_month', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn presentation = GeneratedColumn( + 'presentation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn storageLocation = GeneratedColumn( + 'storage_location', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn offerStatus = GeneratedColumn( + 'offer_status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'private\'', + defaultValue: const CustomExpression('\'private\''), + ); + late final GeneratedColumn seedbankId = GeneratedColumn( + 'seedbank_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originName = GeneratedColumn( + 'origin_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originPlace = GeneratedColumn( + 'origin_place', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn abundance = GeneratedColumn( + 'abundance', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn preservationFormat = + GeneratedColumn( + 'preservation_format', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + type, + harvestYear, + harvestMonth, + quantityKind, + quantityPrecise, + quantityLabel, + presentation, + storageLocation, + offerStatus, + seedbankId, + originName, + originPlace, + abundance, + preservationFormat, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'lots'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Lots createAlias(String alias) { + return Lots(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class GerminationTests extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GerminationTests(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn testedOn = GeneratedColumn( + 'tested_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sampleSize = GeneratedColumn( + 'sample_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn germinatedCount = GeneratedColumn( + 'germinated_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + testedOn, + sampleSize, + germinatedCount, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'germination_tests'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GerminationTests createAlias(String alias) { + return GerminationTests(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ConditionChecks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ConditionChecks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn checkedOn = GeneratedColumn( + 'checked_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn containerCount = GeneratedColumn( + 'container_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn desiccantState = GeneratedColumn( + 'desiccant_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + checkedOn, + containerCount, + desiccantState, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'condition_checks'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ConditionChecks createAlias(String alias) { + return ConditionChecks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Movements extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Movements(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn occurredOn = GeneratedColumn( + 'occurred_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn counterpartyId = GeneratedColumn( + 'counterparty_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn parentMovementId = GeneratedColumn( + 'parent_movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn plantareId = GeneratedColumn( + 'plantare_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + lastAuthor, + schemaRowVersion, + lotId, + type, + occurredOn, + counterpartyId, + quantityKind, + quantityPrecise, + quantityLabel, + parentMovementId, + plantareId, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'movements'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Movements createAlias(String alias) { + return Movements(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Parties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Parties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn publicKey = GeneratedColumn( + 'public_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'person\'', + defaultValue: const CustomExpression('\'person\''), + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + displayName, + publicKey, + kind, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'parties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Parties createAlias(String alias) { + return Parties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Attachments extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Attachments(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn uri = GeneratedColumn( + 'uri', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn bytes = + GeneratedColumn( + 'bytes', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mimeType = GeneratedColumn( + 'mime_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + kind, + uri, + bytes, + mimeType, + sortOrder, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'attachments'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Attachments createAlias(String alias) { + return Attachments(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ExternalLinks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ExternalLinks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + url, + title, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'external_links'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ExternalLinks createAlias(String alias) { + return ExternalLinks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Plantares extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Plantares(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn owedDescription = GeneratedColumn( + 'owed_description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn madeOn = GeneratedColumn( + 'made_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn dueBy = GeneratedColumn( + 'due_by', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'open\'', + defaultValue: const CustomExpression('\'open\''), + ); + late final GeneratedColumn settledOn = GeneratedColumn( + 'settled_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plantares'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Plantares createAlias(String alias) { + return Plantares(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV9 extends GeneratedDatabase { + DatabaseAtV9(QueryExecutor e) : super(e); + late final Varieties varieties = Varieties(this); + late final VarietyVernacularNames varietyVernacularNames = + VarietyVernacularNames(this); + late final Species species = Species(this); + late final SpeciesCommonNames speciesCommonNames = SpeciesCommonNames(this); + late final Lots lots = Lots(this); + late final GerminationTests germinationTests = GerminationTests(this); + late final ConditionChecks conditionChecks = ConditionChecks(this); + late final Movements movements = Movements(this); + late final Parties parties = Parties(this); + late final Attachments attachments = Attachments(this); + late final ExternalLinks externalLinks = ExternalLinks(this); + late final Plantares plantares = Plantares(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + varieties, + varietyVernacularNames, + species, + speciesCommonNames, + lots, + germinationTests, + conditionChecks, + movements, + parties, + attachments, + externalLinks, + plantares, + ]; + @override + int get schemaVersion => 9; +} diff --git a/apps/app_seeds/test/domain/chat_timeline_test.dart b/apps/app_seeds/test/domain/chat_timeline_test.dart new file mode 100644 index 0000000..5f942c2 --- /dev/null +++ b/apps/app_seeds/test/domain/chat_timeline_test.dart @@ -0,0 +1,120 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:tane/domain/chat_timeline.dart'; + +void main() { + // In the app, flutter_localizations loads intl's locale symbols; a pure test + // must initialize them itself before any DateFormat. + setUpAll(initializeDateFormatting); + + PrivateMessage at(DateTime when) => + PrivateMessage(fromPubkey: 'p', text: 't', at: when); + + group('chatTimeline', () { + test('empty in, empty out', () { + expect(chatTimeline(const []), isEmpty); + }); + + test('a day separator precedes the first message of each day', () { + final items = chatTimeline([ + at(DateTime(2026, 6, 14, 10)), + at(DateTime(2026, 6, 14, 11)), + at(DateTime(2026, 6, 15, 9)), + ]); + // sep, msg, msg, sep, msg + expect(items.map((i) => i is ChatDaySeparator), [ + true, + false, + false, + true, + false, + ]); + expect((items[0] as ChatDaySeparator).day, DateTime(2026, 6, 14)); + expect((items[3] as ChatDaySeparator).day, DateTime(2026, 6, 15)); + }); + + test('messages stay oldest-first', () { + final items = chatTimeline([ + at(DateTime(2026, 1, 1, 8)), + at(DateTime(2026, 1, 1, 9)), + ]); + expect(items.whereType(), hasLength(2)); + expect(items.whereType(), hasLength(1)); + }); + }); + + group('chatDayLabel', () { + final now = DateTime(2026, 6, 15, 12); + + test('the two most recent days use the given today/yesterday labels', () { + expect( + chatDayLabel( + DateTime(2026, 6, 15), + now: now, + today: 'Today', + yesterday: 'Yesterday', + localeCode: 'en', + ), + 'Today', + ); + expect( + chatDayLabel( + DateTime(2026, 6, 14), + now: now, + today: 'Today', + yesterday: 'Yesterday', + localeCode: 'en', + ), + 'Yesterday', + ); + }); + + test('older days use a locale date, not today/yesterday', () { + final label = chatDayLabel( + DateTime(2026, 6, 10), + now: now, + today: 'Today', + yesterday: 'Yesterday', + localeCode: 'en', + ); + expect(label, isNot(anyOf('Today', 'Yesterday'))); + expect(label, contains('10')); // the day number + }); + + test('a previous year is shown', () { + final label = chatDayLabel( + DateTime(2024, 6, 10), + now: now, + today: 'Today', + yesterday: 'Yesterday', + localeCode: 'en', + ); + expect(label, contains('2024')); + }); + + test('respects the locale (Spanish month name)', () { + final label = chatDayLabel( + DateTime(2026, 6, 10), + now: now, + today: 'Hoy', + yesterday: 'Ayer', + localeCode: 'es', + ); + expect(label.toLowerCase(), contains('junio')); + }); + }); + + group('chatBubbleTime', () { + test('English uses a 12-hour clock', () { + final s = chatBubbleTime(DateTime(2026, 1, 1, 14, 5), 'en'); + expect(s.toUpperCase(), contains('PM')); + }); + + test('Spanish uses a 24-hour clock', () { + final s = chatBubbleTime(DateTime(2026, 1, 1, 14, 5), 'es'); + expect(s, contains('14')); + expect(s.toUpperCase(), isNot(contains('PM'))); + }); + }); +} diff --git a/apps/app_seeds/test/domain/message_rules_test.dart b/apps/app_seeds/test/domain/message_rules_test.dart new file mode 100644 index 0000000..c50d561 --- /dev/null +++ b/apps/app_seeds/test/domain/message_rules_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/message_rules.dart'; + +void main() { + group('containsUrl', () { + test('flags explicit schemes and www', () { + expect(containsUrl('look https://evil.example/login'), isTrue); + expect(containsUrl('http://foo'), isTrue); + expect(containsUrl('go to www.example.org now'), isTrue); + }); + + test('flags bare domains with a common TLD', () { + expect(containsUrl('see tomate.com'), isTrue); + expect(containsUrl('my-shop.store has it'), isTrue); + expect(containsUrl('grab it at bit.ly'), isFalse); // ly not in the list + }); + + test('leaves ordinary seed talk alone', () { + expect(containsUrl('tengo 3.5kg de tomate rosa'), isFalse); + expect(containsUrl('variedad F1 vs polinización abierta'), isFalse); + expect(containsUrl('nos vemos a las 12.30'), isFalse); + expect(containsUrl('semillas de calabaza, ¿cambiamos?'), isFalse); + expect(containsUrl(''), isFalse); + }); + + test('is case-insensitive', () { + expect(containsUrl('HTTPS://EXAMPLE.COM'), isTrue); + expect(containsUrl('Example.COM'), isTrue); + }); + }); +} diff --git a/apps/app_seeds/test/domain/seed_saving_test.dart b/apps/app_seeds/test/domain/seed_saving_test.dart new file mode 100644 index 0000000..5265464 --- /dev/null +++ b/apps/app_seeds/test/domain/seed_saving_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/seed_saving.dart'; + +/// Pure lookup/merge logic for the seed-saving guidance: a per-species entry +/// overrides its family default, a bare genus is a fallback, and unknown crops +/// resolve to null. +void main() { + final data = parseSeedSaving(''' + { + "families": { + "Fabaceae": { "lifeCycle": "annual", "pollination": "self", "processing": "dry", "difficulty": "easy" }, + "Solanaceae": { "pollination": "self", "isolationMinM": 3, "isolationMaxM": 15 } + }, + "species": { + "Vicia faba": { "pollination": "cross", "vector": "insect", "isolationMinM": 180, "note": {"es": "aísla", "en": "isolate"} }, + "Cucurbita": { "pollination": "cross", "vector": "insect" } + } + } + '''); + + test('a species entry overrides its family default', () { + final g = data.guideFor(scientificName: 'Vicia faba', family: 'Fabaceae'); + expect(g, isNotNull); + // Family said self+easy+annual; the species override flips pollination. + expect(g!.pollination, Pollination.cross); + expect(g.vector, PollinationVector.insect); + expect(g.isolationMinM, 180); + // Fields the species didn't set fall through to the family. + expect(g.lifeCycle, LifeCycle.annual); + expect(g.difficulty, SavingDifficulty.easy); + expect(g.processing, SeedProcessing.dry); + }); + + test('a bare genus key is a fallback when the species is unlisted', () { + final g = data.guideFor(scientificName: 'Cucurbita maxima', family: null); + expect(g?.pollination, Pollination.cross); + expect(g?.vector, PollinationVector.insect); + }); + + test('family-only lookup returns the family default', () { + final g = data.guideFor(family: 'Solanaceae'); + expect(g?.pollination, Pollination.self); + expect(g?.isolationMaxM, 15); + }); + + test('lookup is case-insensitive', () { + expect(data.guideFor(family: 'fabaceae'), isNotNull); + expect(data.guideFor(scientificName: 'vicia faba'), isNotNull); + }); + + test('an unknown crop resolves to null', () { + expect(data.guideFor(scientificName: 'Musa acuminata', family: 'Musaceae'), + isNull); + }); + + test('noteFor falls back to English then any language', () { + final g = data.guideFor(scientificName: 'Vicia faba')!; + expect(g.noteFor('es'), 'aísla'); + expect(g.noteFor('fr'), 'isolate'); // no fr → en + }); + + test('mergedWith unions notes with the override winning', () { + const base = SeedSavingGuide(note: {'es': 'a', 'en': 'b'}); + const over = SeedSavingGuide(note: {'es': 'c'}); + expect(base.mergedWith(over).note, {'es': 'c', 'en': 'b'}); + }); +} diff --git a/apps/app_seeds/test/no_stream_first_in_widget_tests_test.dart b/apps/app_seeds/test/no_stream_first_in_widget_tests_test.dart new file mode 100644 index 0000000..b499751 --- /dev/null +++ b/apps/app_seeds/test/no_stream_first_in_widget_tests_test.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Guard against the single most reliable way to make a widget test hang. +/// +/// Awaiting a Drift/Nostr stream's `.first` (e.g. `await repo.watchSales() +/// .first`) inside a `testWidgets` body never completes: the widget-test +/// fake-async clock does not advance the stream's backing timer, so the test +/// sits until the framework's 10-minute hard cap — the per-test `timeout:` in +/// dart_test.yaml can't bound it because `pumpAndSettle` has already returned. +/// It has bitten this repo more than once. Read one-shot state with a plain +/// `.get()` / `.getSingle()` query instead; those resolve fine under fake-async. +/// +/// This is a pure-Dart source scan (no widgets, milliseconds) so it runs in the +/// fast, hang-proof gate and fails loudly the moment the pattern reappears. +void main() { + test('no `watchX().first` awaited inside widget tests', () { + // `watchAnything(...)` immediately followed by `.first` — catches + // watchSales().first, watchPlantares().first, watchInventoryForVariety(id) + // .first, etc. Finder.first (find.byIcon(...).first) has no `watch(` before + // it, so it is never matched. + final streamFirst = RegExp(r'watch\w*\([^;]*\)\s*\.first\b'); + + final uiTests = Directory('test/ui'); + expect( + uiTests.existsSync(), + isTrue, + reason: 'expected widget tests under test/ui/', + ); + + final offenders = []; + for (final entity in uiTests.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final lines = entity.readAsLinesSync(); + for (var i = 0; i < lines.length; i++) { + final trimmed = lines[i].trimLeft(); + if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue; + if (streamFirst.hasMatch(lines[i])) { + offenders.add('${entity.path}:${i + 1}: ${lines[i].trim()}'); + } + } + } + + expect( + offenders, + isEmpty, + reason: + 'Awaiting a stream .first inside a widget test hangs it to the 10-min ' + 'cap. Replace with a one-shot query (db.select(...).get()):\n' + '${offenders.join('\n')}', + ); + }); +} diff --git a/apps/app_seeds/test/screenshots/README.md b/apps/app_seeds/test/screenshots/README.md new file mode 100644 index 0000000..ee54082 --- /dev/null +++ b/apps/app_seeds/test/screenshots/README.md @@ -0,0 +1,43 @@ +# Localized app screenshots + +Golden-based harness that renders key screens as PNGs in several languages, for +the landing site (`tane.comunes.org`) and the app-store listings. No emulator — +it runs under `flutter test`, so it's deterministic and CI-friendly. + +## Regenerate + +```sh +cd apps/app_seeds +flutter test --update-goldens --run-skipped --tags screenshots test/screenshots/screenshots_test.dart +tool/collect_screenshots.sh # copy PNGs into site/ and fastlane/ +``` + +Drop `--update-goldens` to *verify* the committed PNGs still match. The +`screenshots` tag is **skipped by default** (`dart_test.yaml`) — so it never +runs in the normal suite or CI's `flutter test --coverage`, where golden pixel +differences across OS/font versions would cause false failures. `--run-skipped` +opts it back in. + +## What it produces + +`goldens//.png` for **en, es, fr, de, pt, ja**, screens +**home · inventory · market · calendar · detail**, plus `goldens/rtl/inventory.png`. +The market is rendered populated via a seeded `OffersCubit` with a no-op transport +(no network); home/calendar/detail/inventory render from the repository alone. + +- Fonts: real glyphs come from `loadScreenshotFonts()` — every family in + `FontManifest.json` (MaterialIcons + bundled Noto/seedks) plus the bundled + **DejaVu Sans** as the Latin text face. No Roboto needed. +- `rtl/` is a **layout demo**, not a locale: English strings forced + right-to-left to prove the design is RTL-safe. The app ships no Arabic + translation yet, so there is deliberately no `ar/` with Arabic copy. +- Partial locales (e.g. `ja`) fall back to English for untranslated strings — + that's the app's real runtime behavior, shown honestly. +- These are raw device-canvas frames (no marketing captions or device bezels). + Add those downstream only if a store rejects plain screenshots. + +## Adding a screen or locale + +Edit `screenshotLocales` / the `screens` map in `screenshots_test.dart`. Screens +must render from the repository + cubits alone (no live Nostr/network stack), so +inventory/detail/calendar are the safe, self-contained set. diff --git a/apps/app_seeds/test/screenshots/fixtures/CREDITS.md b/apps/app_seeds/test/screenshots/fixtures/CREDITS.md new file mode 100644 index 0000000..eb13d7e --- /dev/null +++ b/apps/app_seeds/test/screenshots/fixtures/CREDITS.md @@ -0,0 +1,13 @@ +# Example inventory photos — credits + +CC0 / public-domain images used as example variety photos in the screenshot +harness only (to generate store/landing screenshots). They are **not** bundled +in the app. Square-cropped and re-compressed from the originals below. + +| Fixture | Subject | Author | License | Source | +|---|---|---|---|---| +| maize.jpg | Red & white flint corn | vjrj (Tane project) | own photo | — | +| buckwheat.jpg | Fagopyrum esculentum (buckwheat) | Daderot | CC0 | https://commons.wikimedia.org/wiki/File:Fagopyrum_esculentum_-_Osaka_Museum_of_Natural_History_-_DSC07737.JPG | +| tomato.jpg | Ripe red tomatoes | Glenda Green | CC0 | https://commons.wikimedia.org/wiki/File:Ripe_Red_Tomatoes.jpg | +| pepper.jpg | Capsicum annuum (Guajillo), dried | ZooFari | Public domain | https://commons.wikimedia.org/wiki/File:Capsicum_annuum_(Guajillo)_-_dried.jpg | +| raspberry.jpg | Rubus idaeus (raspberry) | Vassil | Public domain | https://commons.wikimedia.org/wiki/File:Framboise_Margy_3.jpg | diff --git a/apps/app_seeds/test/screenshots/fixtures/buckwheat.jpg b/apps/app_seeds/test/screenshots/fixtures/buckwheat.jpg new file mode 100644 index 0000000..7ad1e58 Binary files /dev/null and b/apps/app_seeds/test/screenshots/fixtures/buckwheat.jpg differ diff --git a/apps/app_seeds/test/screenshots/fixtures/maize.jpg b/apps/app_seeds/test/screenshots/fixtures/maize.jpg new file mode 100644 index 0000000..0917176 Binary files /dev/null and b/apps/app_seeds/test/screenshots/fixtures/maize.jpg differ diff --git a/apps/app_seeds/test/screenshots/fixtures/pepper.jpg b/apps/app_seeds/test/screenshots/fixtures/pepper.jpg new file mode 100644 index 0000000..da50f24 Binary files /dev/null and b/apps/app_seeds/test/screenshots/fixtures/pepper.jpg differ diff --git a/apps/app_seeds/test/screenshots/fixtures/raspberry.jpg b/apps/app_seeds/test/screenshots/fixtures/raspberry.jpg new file mode 100644 index 0000000..2b635bc Binary files /dev/null and b/apps/app_seeds/test/screenshots/fixtures/raspberry.jpg differ diff --git a/apps/app_seeds/test/screenshots/fixtures/tomato.jpg b/apps/app_seeds/test/screenshots/fixtures/tomato.jpg new file mode 100644 index 0000000..e876692 Binary files /dev/null and b/apps/app_seeds/test/screenshots/fixtures/tomato.jpg differ diff --git a/apps/app_seeds/test/screenshots/goldens/de/calendar.png b/apps/app_seeds/test/screenshots/goldens/de/calendar.png new file mode 100644 index 0000000..4927225 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/de/calendar.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/de/detail.png b/apps/app_seeds/test/screenshots/goldens/de/detail.png new file mode 100644 index 0000000..abdaffd Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/de/detail.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/de/home.png b/apps/app_seeds/test/screenshots/goldens/de/home.png new file mode 100644 index 0000000..5be2c15 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/de/home.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/de/inventory.png b/apps/app_seeds/test/screenshots/goldens/de/inventory.png new file mode 100644 index 0000000..c3b27af Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/de/inventory.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/de/market.png b/apps/app_seeds/test/screenshots/goldens/de/market.png new file mode 100644 index 0000000..80649b3 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/de/market.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/en/calendar.png b/apps/app_seeds/test/screenshots/goldens/en/calendar.png new file mode 100644 index 0000000..578b7c7 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/en/calendar.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/en/detail.png b/apps/app_seeds/test/screenshots/goldens/en/detail.png new file mode 100644 index 0000000..b7dd841 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/en/detail.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/en/home.png b/apps/app_seeds/test/screenshots/goldens/en/home.png new file mode 100644 index 0000000..de3191d Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/en/home.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/en/inventory.png b/apps/app_seeds/test/screenshots/goldens/en/inventory.png new file mode 100644 index 0000000..6b71397 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/en/inventory.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/en/market.png b/apps/app_seeds/test/screenshots/goldens/en/market.png new file mode 100644 index 0000000..456b140 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/en/market.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/es/calendar.png b/apps/app_seeds/test/screenshots/goldens/es/calendar.png new file mode 100644 index 0000000..f87fa3e Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/es/calendar.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/es/detail.png b/apps/app_seeds/test/screenshots/goldens/es/detail.png new file mode 100644 index 0000000..f9e9a92 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/es/detail.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/es/home.png b/apps/app_seeds/test/screenshots/goldens/es/home.png new file mode 100644 index 0000000..f39ef76 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/es/home.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/es/inventory.png b/apps/app_seeds/test/screenshots/goldens/es/inventory.png new file mode 100644 index 0000000..8c95790 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/es/inventory.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/es/market.png b/apps/app_seeds/test/screenshots/goldens/es/market.png new file mode 100644 index 0000000..de0c402 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/es/market.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/fr/calendar.png b/apps/app_seeds/test/screenshots/goldens/fr/calendar.png new file mode 100644 index 0000000..23de5f8 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/fr/calendar.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/fr/detail.png b/apps/app_seeds/test/screenshots/goldens/fr/detail.png new file mode 100644 index 0000000..85281ec Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/fr/detail.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/fr/home.png b/apps/app_seeds/test/screenshots/goldens/fr/home.png new file mode 100644 index 0000000..17a7795 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/fr/home.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/fr/inventory.png b/apps/app_seeds/test/screenshots/goldens/fr/inventory.png new file mode 100644 index 0000000..64a91c1 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/fr/inventory.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/fr/market.png b/apps/app_seeds/test/screenshots/goldens/fr/market.png new file mode 100644 index 0000000..82906d8 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/fr/market.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/ja/calendar.png b/apps/app_seeds/test/screenshots/goldens/ja/calendar.png new file mode 100644 index 0000000..af58e1a Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/ja/calendar.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/ja/detail.png b/apps/app_seeds/test/screenshots/goldens/ja/detail.png new file mode 100644 index 0000000..b7dd841 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/ja/detail.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/ja/home.png b/apps/app_seeds/test/screenshots/goldens/ja/home.png new file mode 100644 index 0000000..78b507e Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/ja/home.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/ja/inventory.png b/apps/app_seeds/test/screenshots/goldens/ja/inventory.png new file mode 100644 index 0000000..6b71397 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/ja/inventory.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/ja/market.png b/apps/app_seeds/test/screenshots/goldens/ja/market.png new file mode 100644 index 0000000..456b140 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/ja/market.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/pt/calendar.png b/apps/app_seeds/test/screenshots/goldens/pt/calendar.png new file mode 100644 index 0000000..646ae1f Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/pt/calendar.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/pt/detail.png b/apps/app_seeds/test/screenshots/goldens/pt/detail.png new file mode 100644 index 0000000..33d22e8 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/pt/detail.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/pt/home.png b/apps/app_seeds/test/screenshots/goldens/pt/home.png new file mode 100644 index 0000000..c0464e0 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/pt/home.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/pt/inventory.png b/apps/app_seeds/test/screenshots/goldens/pt/inventory.png new file mode 100644 index 0000000..3469de0 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/pt/inventory.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/pt/market.png b/apps/app_seeds/test/screenshots/goldens/pt/market.png new file mode 100644 index 0000000..bf4ed59 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/pt/market.png differ diff --git a/apps/app_seeds/test/screenshots/goldens/rtl/inventory.png b/apps/app_seeds/test/screenshots/goldens/rtl/inventory.png new file mode 100644 index 0000000..4e08d73 Binary files /dev/null and b/apps/app_seeds/test/screenshots/goldens/rtl/inventory.png differ diff --git a/apps/app_seeds/test/screenshots/screenshot_support.dart b/apps/app_seeds/test/screenshots/screenshot_support.dart new file mode 100644 index 0000000..878ab53 --- /dev/null +++ b/apps/app_seeds/test/screenshots/screenshot_support.dart @@ -0,0 +1,228 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show FontLoader, rootBundle; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/app.dart' show materialLocaleFor; +import 'package:tane/data/species_repository.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/domain/crop_calendar.dart' show monthsToMask; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/state/inventory_cubit.dart'; +import 'package:tane/ui/theme.dart'; + +/// Marketing screenshot canvas: a tall phone at 3x. Logical 360x760. +const screenshotSize = Size(1080, 2280); +const screenshotPixelRatio = 3.0; + +/// Locales we render real, translated screenshots for. Asturian (`ast`) is +/// omitted from store assets; the RTL demo is handled separately (the app has +/// no Arabic strings yet — see [ScreenshotLocale.rtlDemo]). +const screenshotLocales = [ + AppLocale.en, + AppLocale.es, + AppLocale.fr, + AppLocale.de, + AppLocale.pt, + AppLocale.ja, +]; + +bool _fontsLoaded = false; + +/// Registers real glyph-bearing fonts so golden screenshots show text and icons +/// rather than the Ahem/tofu boxes flutter_test uses by default. Loads every +/// family declared in the test bundle's FontManifest.json (MaterialIcons, the +/// bundled Noto Sans JP/Arabic and seedks glyphs), then adds DejaVu Sans — a +/// bundled asset, not a font *family* — as the Latin text face so en/es/fr/de/pt +/// render legibly without needing Roboto. +Future loadScreenshotFonts() async { + if (_fontsLoaded) return; + + Future family(String name, List assets) async { + final loader = FontLoader(name); + for (final asset in assets) { + loader.addFont(rootBundle.load(asset)); + } + await loader.load(); + } + + final manifest = + json.decode(await rootBundle.loadString('FontManifest.json')) + as List; + for (final entry in manifest.cast>()) { + await family( + entry['family'] as String, + [for (final f in entry['fonts'] as List) f['asset'] as String], + ); + } + + await family('DejaVu Sans', [ + 'assets/fonts/DejaVuSans.ttf', + 'assets/fonts/DejaVuSans-Bold.ttf', + ]); + _fontsLoaded = true; +} + +/// Localized sample labels so each screenshot reads in its own language. Family +/// names stay botanical Latin; only the human variety/offer names are localized. +/// English is the fallback (e.g. for `ja`, whose UI is itself partly English). +const sampleLabels = >{ + // Inventory varieties + 'maize': {AppLocale.en: 'Painted flint corn', AppLocale.es: 'Maíz rojo', AppLocale.fr: 'Maïs corné rouge', AppLocale.de: 'Roter Hartmais', AppLocale.pt: 'Milho pintado'}, + 'buckwheat': {AppLocale.en: 'Common buckwheat', AppLocale.es: 'Trigo sarraceno', AppLocale.fr: 'Sarrasin', AppLocale.de: 'Buchweizen', AppLocale.pt: 'Trigo-mourisco'}, + 'raspberry': {AppLocale.en: 'Wild raspberry', AppLocale.es: 'Frambuesa', AppLocale.fr: 'Framboise sauvage', AppLocale.de: 'Wilde Himbeere', AppLocale.pt: 'Framboesa'}, + 'tomato': {AppLocale.en: 'Slicing tomato', AppLocale.es: 'Tomate de ensalada', AppLocale.fr: 'Tomate à trancher', AppLocale.de: 'Fleischtomate', AppLocale.pt: 'Tomate para salada'}, + 'chilli': {AppLocale.en: 'Guajillo chilli', AppLocale.es: 'Chile guajillo', AppLocale.fr: 'Piment guajillo', AppLocale.de: 'Guajillo-Chili', AppLocale.pt: 'Pimenta guajillo'}, + 'origin': {AppLocale.en: 'Community seed swap', AppLocale.es: 'Intercambio de semillas', AppLocale.fr: 'Troc de graines', AppLocale.de: 'Saatgut-Tausch', AppLocale.pt: 'Troca de sementes'}, + // Market offers + 'cherryTomato': {AppLocale.en: 'Cherry tomato', AppLocale.es: 'Tomate cherry', AppLocale.fr: 'Tomate cerise', AppLocale.de: 'Kirschtomate', AppLocale.pt: 'Tomate-cereja'}, + 'bean': {AppLocale.en: 'Climbing bean', AppLocale.es: 'Judía de enrame', AppLocale.fr: 'Haricot à rames', AppLocale.de: 'Stangenbohne', AppLocale.pt: 'Feijão-trepador'}, + 'sunflower': {AppLocale.en: 'Sunflower', AppLocale.es: 'Girasol', AppLocale.fr: 'Tournesol', AppLocale.de: 'Sonnenblume', AppLocale.pt: 'Girassol'}, + 'basil': {AppLocale.en: 'Sweet basil', AppLocale.es: 'Albahaca', AppLocale.fr: 'Basilic', AppLocale.de: 'Basilikum', AppLocale.pt: 'Manjericão'}, + 'carrot': {AppLocale.en: 'Purple carrot', AppLocale.es: 'Zanahoria morada', AppLocale.fr: 'Carotte violette', AppLocale.de: 'Violette Karotte', AppLocale.pt: 'Cenoura roxa'}, +}; + +/// The sample label for [key] in [locale], falling back to English. +String labelFor(String key, AppLocale locale) { + final m = sampleLabels[key]!; + return m[locale] ?? m[AppLocale.en]!; +} + +const _fontFallbacks = ['Noto Sans JP', 'Noto Sans Arabic']; + +/// The real app theme, but with every text style pinned to the bundled +/// [DejaVu Sans] so screenshots render legible glyphs (see [loadScreenshotFonts]). +ThemeData screenshotTheme() { + final base = buildTaneTheme(); + return base.copyWith( + textTheme: base.textTheme.apply( + fontFamily: 'DejaVu Sans', + fontFamilyFallback: _fontFallbacks, + ), + primaryTextTheme: base.primaryTextTheme.apply( + fontFamily: 'DejaVu Sans', + fontFamilyFallback: _fontFallbacks, + ), + appBarTheme: base.appBarTheme.copyWith( + titleTextStyle: base.appBarTheme.titleTextStyle?.copyWith( + fontFamily: 'DejaVu Sans', + fontFamilyFallback: _fontFallbacks, + ), + ), + ); +} + +/// Wraps [child] as a full screen for capture: the real green theme with +/// screenshot fonts, the given [locale], repositories and an [InventoryCubit]. +/// Pass [textDirection] to force RTL for the layout demo. +Widget screenshotApp({ + required VarietyRepository repository, + required SpeciesRepository species, + required Widget child, + AppLocale locale = AppLocale.en, + TextDirection? textDirection, +}) { + LocaleSettings.setLocaleSync(locale); + return TranslationProvider( + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: repository), + RepositoryProvider.value(value: species), + ], + child: BlocProvider( + create: (_) => InventoryCubit(repository), + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: screenshotTheme(), + locale: materialLocaleFor(locale.flutterLocale), + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: textDirection == null + ? child + : Directionality(textDirection: textDirection, child: child), + ), + ), + ), + ); +} + +/// One seeded variety plus the id of a "hero" variety used for the detail shot. +class SeededInventory { + const SeededInventory(this.heroVarietyId); + final String heroVarietyId; +} + +/// Seeds an attractive, internationally-neutral inventory: a spread of +/// colour-coded botanical families, lots with years/quantities/origins, photo +/// avatars, and crop-calendar month masks so the calendar screen is populated. +/// Returns the hero variety (a cherry tomato with several lots) for detail. +Future seedShowcase( + AppDatabase db, + VarietyRepository repo, + AppLocale locale, +) async { + String l(String key) => labelFor(key, locale); + // Sow in spring, harvest seed in late summer — visible in the calendar. + final sow = monthsToMask(const [3, 4, 5]); + final harvest = monthsToMask(const [8, 9]); + + // Most varieties use the app's default coloured-initial disc avatar; the hero + // (maize) carries a real photo (below). + Future add(String label, String family, {Uint8List? photo}) async { + final id = await repo.addQuickVariety( + label: label, + category: family, + photoBytes: photo, + ); + await (db.update(db.varieties)..where((v) => v.id.equals(id))).write( + VarietiesCompanion( + sowMonths: Value(sow), + seedHarvestMonths: Value(harvest), + ), + ); + return id; + } + + // Example photos are CC0 / public domain (see fixtures/CREDITS.md), except the + // maize which is vjrj's own; loaded from the committed fixtures (CWD is the + // package root under `flutter test`). The maize is the hero for the detail shot. + Future photo(String name) => + File('test/screenshots/fixtures/$name.jpg').readAsBytes(); + + final maize = await add(l('maize'), 'Poaceae', photo: await photo('maize')); + await repo.addLot( + varietyId: maize, + harvestYear: 2024, + quantity: const Quantity(kind: QuantityKind.cob, count: 6), + originName: l('origin'), + abundance: Abundance.plentyToShare, + offerStatus: OfferStatus.shared, + ); + await repo.addLot( + varietyId: maize, + harvestYear: 2023, + quantity: const Quantity(kind: QuantityKind.packet, count: 1), + offerStatus: OfferStatus.exchange, + ); + + // The rest sit in families that sort at/after Poaceae, so the maize stays the + // first, top-of-list group in the inventory shot. Each carries a CC0/PD photo. + await add(l('buckwheat'), 'Polygonaceae', photo: await photo('buckwheat')); + await add(l('raspberry'), 'Rosaceae', photo: await photo('raspberry')); + await add(l('tomato'), 'Solanaceae', photo: await photo('tomato')); + await add(l('chilli'), 'Solanaceae', photo: await photo('pepper')); + + return SeededInventory(maize); +} diff --git a/apps/app_seeds/test/screenshots/screenshots_test.dart b/apps/app_seeds/test/screenshots/screenshots_test.dart new file mode 100644 index 0000000..fac0139 --- /dev/null +++ b/apps/app_seeds/test/screenshots/screenshots_test.dart @@ -0,0 +1,176 @@ +@Tags(['screenshots']) +library; + +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/species_repository.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/state/offers_cubit.dart'; +import 'package:tane/state/variety_detail_cubit.dart'; +import 'package:tane/ui/calendar_screen.dart'; +import 'package:tane/ui/home_screen.dart'; +import 'package:tane/ui/inventory_list_screen.dart'; +import 'package:tane/ui/market_screen.dart'; +import 'package:tane/ui/variety_detail_screen.dart'; + +import '../support/test_support.dart'; +import 'screenshot_support.dart'; + +/// Generates localized marketing/store screenshots as golden PNGs. +/// +/// Run (writes the PNGs) — the `screenshots` tag is skipped by default +/// (dart_test.yaml), so `--run-skipped` is required: +/// flutter test --update-goldens --run-skipped --tags screenshots test/screenshots/screenshots_test.dart +/// +/// Output: test/screenshots/goldens/``/``.png for en/es/fr/de/pt/ja, +/// plus goldens/rtl/ — a right-to-left *layout* demo (English strings mirrored; +/// the app ships no Arabic translation yet, so this proves the design is RTL-safe +/// rather than claiming an Arabic locale). These are raw device-canvas frames +/// with no marketing chrome; add captions/device frames downstream if a store +/// requires them. `tool/collect_screenshots.sh` copies them into site/ and fastlane/. +void main() { + setUpAll(() async { + await loadScreenshotFonts(); + // Touch mode never draws focus rings, so no highlight frames the FAB/fields. + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTouch; + }); + + late AppDatabase db; + late VarietyRepository repo; + late SpeciesRepository species; + + setUp(() async { + db = newTestDatabase(); + repo = newTestRepository(db); + species = newTestSpeciesRepository(db); + }); + tearDown(() => db.close()); + + Future capture(WidgetTester tester, String dir, String name) async { + tester.view.physicalSize = screenshotSize; + tester.view.devicePixelRatio = screenshotPixelRatio; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpAndSettle(); + // Asset images (e.g. the home logo) decode via real async that the fake + // test clock doesn't drive, so they'd paint blank. Precache them for real, + // then repaint, so they show up in the golden. + await tester.runAsync(() async { + for (final element in find.byType(Image).evaluate()) { + await precacheImage((element.widget as Image).image, element); + } + }); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/$dir/$name.png'), + ); + await disposeTree(tester); + } + + // A POPULATED market: a seeded OffersCubit (no transport) feeding MarketBody, + // so the offer list renders with no live discovery stream to hang + // pumpAndSettle. Offer names are localized. (Live discovery is exercised by + // offers_cubit_test.dart; see market_screen_test.dart on why not via widgets.) + Widget marketWidget(AppLocale locale) => Builder( + builder: (context) => Scaffold( + appBar: AppBar( + title: Text(context.t.market.title), + actions: [IconButton(icon: const Icon(Icons.tune), onPressed: () {})], + ), + body: BlocProvider( + create: (_) => _SeededOffersCubit(_sampleOffers(locale)), + child: MarketBody(onConfigure: () {}), + ), + ), + ); + + const screenNames = ['home', 'inventory', 'market', 'calendar', 'detail']; + + for (final locale in screenshotLocales) { + for (final name in screenNames) { + testWidgets('${locale.languageCode}/$name', (tester) async { + // Re-seed per shot so the sample variety names are in the shot's + // language. Drift writes must run in REAL async (runAsync), not the + // testWidgets fake clock, or they hang. + late final SeededInventory seeded; + await tester.runAsync(() async { + seeded = await seedShowcase(db, repo, locale); + }); + final child = switch (name) { + 'home' => const HomeScreen(marketEnabled: true), + 'inventory' => const InventoryListScreen(), + 'market' => marketWidget(locale), + 'calendar' => const CalendarScreen(initialMonth: 4), + _ => BlocProvider( + create: (_) => VarietyDetailCubit(repo, seeded.heroVarietyId), + child: const VarietyDetailScreen(), + ), + }; + await tester.pumpWidget( + screenshotApp( + repository: repo, + species: species, + locale: locale, + child: child, + ), + ); + await capture(tester, locale.languageCode, name); + }); + } + } + + // RTL layout demo: base (English) strings, forced right-to-left, so the + // mirrored inventory proves the design is RTL-safe. Not a translated locale. + testWidgets('rtl/inventory', (tester) async { + await tester.runAsync(() => seedShowcase(db, repo, AppLocale.en)); + await tester.pumpWidget( + screenshotApp( + repository: repo, + species: species, + locale: AppLocale.en, + textDirection: TextDirection.rtl, + child: const InventoryListScreen(), + ), + ); + await capture(tester, 'rtl', 'inventory'); + }); +} + +/// An [OffersCubit] pre-loaded with a fixed offer list. It carries a no-op +/// transport (never called — offers are pre-seeded) purely so `isOnline` is +/// true and MarketBody renders the list instead of the "can't reach" state. +class _SeededOffersCubit extends OffersCubit { + _SeededOffersCubit(List offers) : super(_NoopOfferTransport()) { + emit(state.copyWith(offers: offers, hasSearched: true, areaGeohash: 'sp3e')); + } +} + +/// A transport that is present (so the market reads as online) but never used. +class _NoopOfferTransport implements OfferTransport { + @override + Stream discover(DiscoveryQuery query) => const Stream.empty(); + @override + Future publish(Offer offer) => throw UnimplementedError(); + @override + Future retract(String offerId) async {} + @override + Future close() async {} +} + +/// A small set of nearby offers across gift / swap / sale and colour-coded +/// botanical families, with names localized to [l]. +List _sampleOffers(AppLocale l) => [ + Offer(id: 'o1', authorPubkeyHex: 'a1' * 32, summary: labelFor('cherryTomato', l), type: OfferType.gift, category: 'Solanaceae', approxGeohash: 'sp3e9', isOrganic: true), + Offer(id: 'o2', authorPubkeyHex: 'a2' * 32, summary: labelFor('bean', l), type: OfferType.exchange, category: 'Fabaceae', approxGeohash: 'sp3e8'), + Offer(id: 'o3', authorPubkeyHex: 'a3' * 32, summary: labelFor('sunflower', l), type: OfferType.sale, category: 'Asteraceae', approxGeohash: 'sp3ec', priceAmount: 2, priceCurrency: '€'), + Offer(id: 'o4', authorPubkeyHex: 'a4' * 32, summary: labelFor('basil', l), type: OfferType.gift, category: 'Lamiaceae', approxGeohash: 'sp3e2', isOrganic: true), + Offer(id: 'o5', authorPubkeyHex: 'a5' * 32, summary: labelFor('carrot', l), type: OfferType.exchange, category: 'Apiaceae', approxGeohash: 'sp3ef'), +]; diff --git a/apps/app_seeds/test/services/auto_backup_service_test.dart b/apps/app_seeds/test/services/auto_backup_service_test.dart new file mode 100644 index 0000000..723398d --- /dev/null +++ b/apps/app_seeds/test/services/auto_backup_service_test.dart @@ -0,0 +1,167 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/security/secure_key_store.dart'; +import 'package:tane/services/auto_backup_service.dart'; +import 'package:tane/services/auto_backup_store.dart'; +import 'package:tane/services/export_import_service.dart'; +import 'package:tane/services/file_service.dart'; + +import '../support/test_support.dart'; + +/// [FileService] the exporter never actually calls for [buildSealedBackup]; the +/// automatic path writes files itself. +class _UnusedFiles implements FileService { + @override + Future saveFile({ + required String suggestedName, + required Uint8List bytes, + }) async => null; + + @override + Future pickFileBytes({List? allowedExtensions}) async => + null; +} + +/// An exporter whose backup build always fails, to prove failures stay silent. +class _ThrowingExporter extends ExportImportService { + _ThrowingExporter(AppDatabase db) + : super( + repository: newTestRepository(db), + files: _UnusedFiles(), + keys: SecureKeyStore(store: InMemorySecretStore()), + ); + + @override + Future buildSealedBackup() => throw Exception('disk full'); +} + +void main() { + late AppDatabase db; + late Directory dir; + + setUp(() async { + db = newTestDatabase(); + dir = await Directory.systemTemp.createTemp('tane_auto_backup_test'); + }); + tearDown(() async { + await db.close(); + if (dir.existsSync()) await dir.delete(recursive: true); + }); + + ExportImportService newExporter() => ExportImportService( + repository: newTestRepository(db), + files: _UnusedFiles(), + keys: SecureKeyStore(store: InMemorySecretStore()), + ); + + AutoBackupService newService( + ExportImportService exporter, { + required AutoBackupStore store, + required DateTime now, + }) => AutoBackupService( + exporter: exporter, + store: store, + directory: () async => dir, + now: () => now, + ); + + List autoCopies() => + dir + .listSync() + .whereType() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.startsWith('tane-auto-')) + .toList() + ..sort(); + + test('first run writes one sealed copy', () async { + await newTestRepository(db).addQuickVariety(label: 'Maize'); + final service = newService( + newExporter(), + store: AutoBackupStore(InMemorySecretStore()), + now: DateTime.utc(2026, 7, 10), + ); + + expect(await service.runIfDue(), isTrue); + + final copies = autoCopies(); + expect(copies, ['tane-auto-2026-07-10.tane']); + final bytes = File('${dir.path}/${copies.single}').readAsBytesSync(); + expect(BackupBox.looksSealed(bytes), isTrue); + }); + + test('a second run within the interval is a no-op', () async { + final store = AutoBackupStore(InMemorySecretStore()); + await store.markBackupAt(DateTime.utc(2026, 7, 8)); + final service = newService( + newExporter(), + store: store, + now: DateTime.utc(2026, 7, 10), // 2 days later < 7-day interval + ); + + expect(await service.runIfDue(), isFalse); + expect(autoCopies(), isEmpty); + }); + + test('a run after the interval writes again', () async { + final store = AutoBackupStore(InMemorySecretStore()); + await store.markBackupAt(DateTime.utc(2026, 7, 1)); + final service = newService( + newExporter(), + store: store, + now: DateTime.utc(2026, 7, 10), // 9 days later >= 7 + ); + + expect(await service.runIfDue(), isTrue); + expect(autoCopies(), ['tane-auto-2026-07-10.tane']); + }); + + test('rotation keeps only the newest three copies', () async { + for (final d in ['2020-01-01', '2020-01-02', '2020-01-03', '2020-01-04']) { + File('${dir.path}/tane-auto-$d.tane').writeAsBytesSync([0]); + } + final service = newService( + newExporter(), + store: AutoBackupStore(InMemorySecretStore()), + now: DateTime.utc(2026, 7, 10), + ); + + await service.runIfDue(); + + expect(autoCopies(), [ + 'tane-auto-2020-01-03.tane', + 'tane-auto-2020-01-04.tane', + 'tane-auto-2026-07-10.tane', + ]); + }); + + test('backupNow writes even when a copy is not yet due', () async { + final store = AutoBackupStore(InMemorySecretStore()); + await store.markBackupAt(DateTime.utc(2026, 7, 10)); // today: not due + final service = newService( + newExporter(), + store: store, + now: DateTime.utc(2026, 7, 10), + ); + + // runIfDue is a no-op today, but an explicit tap still makes a copy. + expect(await service.runIfDue(), isFalse); + expect(await service.backupNow(), isTrue); + expect(autoCopies(), ['tane-auto-2026-07-10.tane']); + }); + + test('a failed backup returns false and never throws', () async { + final service = newService( + _ThrowingExporter(db), + store: AutoBackupStore(InMemorySecretStore()), + now: DateTime.utc(2026, 7, 10), + ); + + expect(await service.runIfDue(), isFalse); + expect(autoCopies(), isEmpty); + }); +} diff --git a/apps/app_seeds/test/services/auto_backup_store_test.dart b/apps/app_seeds/test/services/auto_backup_store_test.dart new file mode 100644 index 0000000..840f573 --- /dev/null +++ b/apps/app_seeds/test/services/auto_backup_store_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/auto_backup_store.dart'; + +import '../support/test_support.dart'; + +void main() { + test('lastBackupAt is null until the first backup is marked', () async { + final store = AutoBackupStore(InMemorySecretStore()); + expect(await store.lastBackupAt(), isNull); + }); + + test('markBackupAt round-trips to the second', () async { + final store = AutoBackupStore(InMemorySecretStore()); + final when = DateTime.utc(2026, 7, 10, 8, 30); + + await store.markBackupAt(when); + + expect(await store.lastBackupAt(), when); + }); +} diff --git a/apps/app_seeds/test/services/device_id_store_test.dart b/apps/app_seeds/test/services/device_id_store_test.dart new file mode 100644 index 0000000..3ae832b --- /dev/null +++ b/apps/app_seeds/test/services/device_id_store_test.dart @@ -0,0 +1,31 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/device_id_store.dart'; + +import '../support/test_support.dart'; + +void main() { + test('generates a 64-bit hex id and persists it', () async { + final secret = InMemorySecretStore(); + final id = await DeviceIdStore(secret).deviceId(); + expect(id, matches(RegExp(r'^[0-9a-f]{16}$'))); + + // A fresh store over the same keystore reads the SAME id (stable install). + final again = await DeviceIdStore(secret).deviceId(); + expect(again, id); + }); + + test('is stable across calls on one instance', () async { + final store = DeviceIdStore(InMemorySecretStore()); + expect(await store.deviceId(), await store.deviceId()); + }); + + test('two fresh installs get different ids', () async { + final a = await DeviceIdStore(InMemorySecretStore(), random: Random(1)) + .deviceId(); + final b = await DeviceIdStore(InMemorySecretStore(), random: Random(2)) + .deviceId(); + expect(a, isNot(b)); + }); +} diff --git a/apps/app_seeds/test/services/discovery_area_test.dart b/apps/app_seeds/test/services/discovery_area_test.dart new file mode 100644 index 0000000..710f066 --- /dev/null +++ b/apps/app_seeds/test/services/discovery_area_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/discovery_area.dart'; + +void main() { + test('coarsens the area to the search precision', () { + expect(searchPrefix('sp3e9', 4), 'sp3e'); + expect(searchPrefix('sp3e9', 3), 'sp3'); + expect(searchPrefix('sp3e9', 5), 'sp3e9'); + }); + + test('never asks for more than the area actually has', () { + expect(searchPrefix('sp', 4), 'sp'); + expect(searchPrefix('', 4), ''); + }); + + test('two neighbours in adjacent 5-char cells share a 4-char prefix', () { + // The crux of the bug: sp3e9 and sp3e8 are different ±2.4 km cells, but + // searching at precision 4 collapses both to 'sp3e', so they find each + // other. Publishing emits the ladder down to 'sp3e', so this matches. + expect(searchPrefix('sp3e9', 4), searchPrefix('sp3e8', 4)); + }); +} diff --git a/apps/app_seeds/test/services/export_import_service_test.dart b/apps/app_seeds/test/services/export_import_service_test.dart index d1fefab..a0941c8 100644 --- a/apps/app_seeds/test/services/export_import_service_test.dart +++ b/apps/app_seeds/test/services/export_import_service_test.dart @@ -65,7 +65,7 @@ void main() { await service.exportBackup(); - expect(files.savedName, endsWith('.tanemaki')); + expect(files.savedName, endsWith('.tane')); expect(BackupBox.looksSealed(files.saved!), isTrue); expect(String.fromCharCodes(files.saved!).contains('Secret'), isFalse); }); @@ -141,6 +141,61 @@ void main() { expect(summary.inserted, 1); }); + test('sync snapshot round-trips UNSEALED between devices, idempotently', + () async { + final (serviceA, _, _) = newService(dbA); + final repoA = newTestRepository(dbA); + await repoA.addQuickVariety(label: 'Sync tomato'); + + final snapshot = await serviceA.buildSnapshot(); + // Unsealed: plain interchange JSON — the sync transport does the encryption, + // so this must NOT be double-sealed. + expect(BackupBox.looksSealed(Uint8List.fromList(snapshot)), isFalse); + expect(utf8.decode(snapshot).contains('Sync tomato'), isTrue); + + final (serviceB, _, _) = newService(dbB); + await serviceB.applySnapshot(snapshot); + expect((await dbB.select(dbB.varieties).get()).single.label, 'Sync tomato'); + + // Idempotent: re-applying the same snapshot changes nothing. + await serviceB.applySnapshot(snapshot); + expect(await dbB.select(dbB.varieties).get(), hasLength(1)); + }); + + test('sync replicates a DELETION (tombstone), not just additions', () async { + final (serviceA, _, _) = newService(dbA); + final repoA = newTestRepository(dbA); + final id = await repoA.addQuickVariety(label: 'To delete'); + + // First sync: device B receives the variety, alive. + final (serviceB, _, _) = newService(dbB); + await serviceB.applySnapshot(await serviceA.buildSnapshot()); + var onB = (await dbB.select(dbB.varieties).get()).firstWhere((v) => v.id == id); + expect(onB.isDeleted, isFalse); + + // A deletes it; the next sync carries the tombstone. + await repoA.softDeleteVariety(id); + await serviceB.applySnapshot(await serviceA.buildSnapshot()); + onB = (await dbB.select(dbB.varieties).get()).firstWhere((v) => v.id == id); + expect(onB.isDeleted, isTrue); // deletion replicated, not resurrected + }); + + test('the sync snapshot carries NO photo bytes (photos stay device-local)', + () async { + final (serviceA, _, _) = newService(dbA); + final repoA = newTestRepository(dbA); + final id = await repoA.addQuickVariety(label: 'With photo'); + await repoA.addPhoto(id, Uint8List.fromList(List.filled(5000, 42))); + + final syncBytes = await serviceA.buildSnapshot(); + expect(utf8.decode(syncBytes).contains('bytesBase64'), isFalse); + expect(syncBytes.length, lessThan(5000)); // the 5 KB photo isn't in it + + // A sealed BACKUP, by contrast, still embeds the photo. + final backup = await serviceA.buildSealedBackup(); + expect(backup.length, greaterThan(5000)); + }); + test('legacy plain exports still restore (no seal, direct parse)', () async { final repoA = newTestRepository(dbA); await repoA.addQuickVariety(label: 'Old export'); diff --git a/apps/app_seeds/test/services/i18n_ja_locale_test.dart b/apps/app_seeds/test/services/i18n_ja_locale_test.dart new file mode 100644 index 0000000..8de9e37 --- /dev/null +++ b/apps/app_seeds/test/services/i18n_ja_locale_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; + +/// Japanese (`ja`) is the CJK reference locale — fitting, since the app's own +/// name is Japanese (種, *tane*, "seed"). It proves the CJK path end to end: +/// the locale is wired, core strings resolve in Japanese, and untranslated +/// keys fall back to the English base (slang `fallback_strategy: base_locale`). +void main() { + test('ja is a supported locale', () { + expect( + AppLocaleUtils.supportedLocales.map((l) => l.languageCode), + contains('ja'), + ); + }); + + test('core strings resolve in Japanese', () { + final ja = AppLocale.ja.buildSync(); + expect(ja.menu.inventory, '在庫'); + expect(ja.common.save, '保存'); + expect(ja.settings.langJa, '日本語'); + }); + + test('untranslated keys fall back to the English base', () { + final ja = AppLocale.ja.buildSync(); + final en = AppLocale.en.buildSync(); + // `menu.plantares` is left untranslated in ja (a coined feature name), so + // it must surface the English text rather than a blank or a key. + expect(ja.menu.plantares, en.menu.plantares); + expect(ja.menu.plantares, isNotEmpty); + }); +} diff --git a/apps/app_seeds/test/services/inbox_service_test.dart b/apps/app_seeds/test/services/inbox_service_test.dart new file mode 100644 index 0000000..ad12f8e --- /dev/null +++ b/apps/app_seeds/test/services/inbox_service_test.dart @@ -0,0 +1,146 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/inbox_service.dart'; +import 'package:tane/services/message_store.dart'; +import 'package:tane/services/notification_service.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/services/unread_service.dart'; + +import '../support/test_support.dart'; + +/// Records notification calls instead of touching the OS plugin. +class _RecordingNotifications extends NotificationService { + _RecordingNotifications() : super(supported: false); + + final titles = []; + final peers = []; + + @override + Future showMessage({ + required String peerPubkey, + required String title, + }) async { + peers.add(peerPubkey); + titles.add(title); + } +} + +/// The app-wide inbox listener persists incoming messages, updates unread and +/// notifies — even when the specific chat isn't open. Driven through the +/// [InboxService.ingest] seam so no relay/network is involved. +void main() { + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + + // A connection that never actually opens — ingest doesn't use it. + Future offlineConnection() async => SocialConnection( + social: await SocialService.fromRootSeedHex(seedHex), + settings: SocialSettings(InMemorySecretStore()), + open: (_) async => throw StateError('unused in ingest tests'), + online: const Stream.empty(), + ); + + late MessageStore store; + late InboxService inbox; + + setUp(() async { + store = MessageStore(newTestChatDatabase()); + inbox = InboxService( + connection: await offlineConnection(), + selfPubkey: 'me', + store: store, + ); + }); + + tearDown(() => inbox.stop()); + + PrivateMessage msg(String from, String text, int atMs) => PrivateMessage( + fromPubkey: from, + text: text, + at: DateTime.fromMillisecondsSinceEpoch(atMs)); + + test('messages from a blocked peer are dropped before storage', () async { + final settings = SocialSettings(InMemorySecretStore()); + await settings.block('mallory'); + inbox = InboxService( + connection: await offlineConnection(), + selfPubkey: 'me', + store: store, + settings: settings, + ); + + await inbox.ingest(msg('mallory', 'spam', 1000)); + await inbox.ingest(msg('alice', 'hola', 2000)); + + final convos = await store.conversations(); + expect(convos.single.peerPubkey, 'alice'); // mallory never landed + }); + + test('an incoming message is persisted into its conversation', () async { + await inbox.ingest(msg('alice', 'got seeds?', 1000)); + final convos = await store.conversations(); + expect(convos.single.peerPubkey, 'alice'); + expect(convos.single.lastText, 'got seeds?'); + }); + + test('a new message announces a change; a duplicate stays silent', () async { + final changes = []; + final sub = inbox.changes.listen(changes.add); + + await inbox.ingest(msg('alice', 'hola', 1000)); + await inbox.ingest(msg('alice', 'hola', 1000)); // relay re-delivery + await Future.delayed(Duration.zero); // let the broadcast flush + + expect(changes, hasLength(1)); // only the first, new one fired + expect(await store.history('alice'), hasLength(1)); + await sub.cancel(); + }); + + group('unread + notification hooks', () { + late UnreadService unread; + late _RecordingNotifications notifications; + const myPubkey = 'me'; + + setUp(() async { + unread = UnreadService(store, InMemorySecretStore()); + notifications = _RecordingNotifications(); + inbox = InboxService( + connection: await offlineConnection(), + selfPubkey: myPubkey, + store: store, + unread: unread, + notifications: notifications, + ); + }); + + tearDown(() => unread.dispose()); + + test('a new peer message updates unread and notifies', () async { + await inbox.ingest(msg('alice', 'hola', 1000)); + expect(await unread.unreadCount('alice'), 1); + expect(notifications.peers, ['alice']); + }); + + test('a duplicate re-delivery neither counts nor notifies', () async { + await inbox.ingest(msg('alice', 'hola', 1000)); + await inbox.ingest(msg('alice', 'hola', 1000)); + expect(await unread.unreadCount('alice'), 1); + expect(notifications.peers, hasLength(1)); + }); + + test('a message authored by me is never notified', () async { + // NIP-17 doesn't loop your own gift wrap back; the guard is defensive. + await inbox.ingest(msg(myPubkey, 'echo', 1000)); + expect(notifications.peers, isEmpty); + }); + + test('the open chat is not notified and stays read', () async { + unread.activePeer = 'alice'; + await inbox.ingest(msg('alice', 'while open', 1000)); + expect(await unread.unreadCount('alice'), 0); + expect(notifications.peers, isEmpty); + }); + }); +} diff --git a/apps/app_seeds/test/services/label_sheet_service_test.dart b/apps/app_seeds/test/services/label_sheet_service_test.dart new file mode 100644 index 0000000..75d58f0 --- /dev/null +++ b/apps/app_seeds/test/services/label_sheet_service_test.dart @@ -0,0 +1,113 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/file_service.dart'; +import 'package:tane/services/label_sheet_service.dart'; + +/// Captures the bytes handed to the save dialog instead of touching disk. +class _RecordingFileService implements FileService { + Uint8List? saved; + String? savedName; + + @override + Future saveFile({ + required String suggestedName, + required Uint8List bytes, + }) async { + saved = bytes; + savedName = suggestedName; + return '/dev/null/$suggestedName'; + } + + @override + Future pickFileBytes({List? allowedExtensions}) async => + null; +} + +const _labels = [ + LabelSheetLabel( + varietyLabel: 'Acelga de Perales', + commonName: 'Acelga', + scientificName: 'Beta vulgaris', + details: '2006 · Perales', + qrData: 'tane://seed?v=Acelga+de+Perales&y=2006', + ), + LabelSheetLabel( + varietyLabel: 'Alubia de Tolosa', + qrData: 'tane://seed?v=Alubia+de+Tolosa', + ), +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final format in LabelSheetFormat.values) { + test('buildPdf renders a well-formed PDF for $format', () async { + final service = LabelSheetService(files: _RecordingFileService()); + final bytes = await service.buildPdf(labels: _labels, format: format); + + // %PDF header + non-trivial body; text content is compressed, so the + // human-readable assertions live in the widget/data tests. + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + expect(bytes.length, greaterThan(1000)); + }); + } + + test('buildPdf renders a name-only label (no optional fields)', () async { + final service = LabelSheetService(files: _RecordingFileService()); + final bytes = await service.buildPdf( + labels: const [ + LabelSheetLabel(varietyLabel: 'Maíz', qrData: 'tane://seed?v=Maíz'), + ], + format: LabelSheetFormat.stickers, + ); + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + }); + + test('buildPdf honours an RTL text direction', () async { + final service = LabelSheetService(files: _RecordingFileService()); + final bytes = await service.buildPdf( + labels: const [ + LabelSheetLabel(varietyLabel: 'بامية', qrData: 'tane://seed?v=x'), + ], + format: LabelSheetFormat.cards, + rtl: true, + ); + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + }); + + test('buildPdf embeds CJK and Arabic glyphs (no tofu)', () async { + // Japanese (CJK) and Arabic (RTL) fall outside DejaVu's coverage; the Noto + // fallbacks in the PDF theme must render them without throwing. Guards + // against a regression that drops the fallback fonts. + final service = LabelSheetService(files: _RecordingFileService()); + final bytes = await service.buildPdf( + labels: const [ + LabelSheetLabel( + varietyLabel: '日本の在来種のトマト', + commonName: 'トマト', + scientificName: 'Solanum lycopersicum', + qrData: 'tane://seed?v=tomato', + ), + LabelSheetLabel(varietyLabel: 'طماطم بلدية', qrData: 'tane://seed?v=x'), + ], + format: LabelSheetFormat.cards, + ); + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + expect(bytes.length, greaterThan(1000)); + }); + + test('saveLabels hands the PDF to the save dialog', () async { + final files = _RecordingFileService(); + final service = LabelSheetService(files: files); + final saved = await service.saveLabels( + labels: _labels, + format: LabelSheetFormat.cards, + suggestedName: 'tane-labels-2026-07-10.pdf', + ); + + expect(saved, isTrue); + expect(files.savedName, 'tane-labels-2026-07-10.pdf'); + expect(String.fromCharCodes(files.saved!.take(5)), '%PDF-'); + }); +} diff --git a/apps/app_seeds/test/services/locale_store_test.dart b/apps/app_seeds/test/services/locale_store_test.dart new file mode 100644 index 0000000..8784a9d --- /dev/null +++ b/apps/app_seeds/test/services/locale_store_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/security/secret_store.dart'; +import 'package:tane/services/locale_store.dart'; + +/// In-memory [SecretStore] for tests. +class FakeSecretStore implements SecretStore { + final Map _data = {}; + @override + Future read(String key) async => _data[key]; + @override + Future write(String key, String value) async => _data[key] = value; +} + +void main() { + // setLocale/useDeviceLocale touch the Flutter locale, which needs a binding. + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeSecretStore secrets; + late LocaleStore store; + + setUp(() { + secrets = FakeSecretStore(); + store = LocaleStore(secrets); + }); + + test('no choice saved yet → follows the device (null)', () async { + expect(await store.saved(), isNull); + }); + + test('picking Asturian persists it and applies it', () async { + await store.setLocale(AppLocale.ast); + + expect(await store.saved(), AppLocale.ast); + expect(LocaleSettings.currentLocale, AppLocale.ast); + // Asturian is modelled as its real `ast` code so the schema stays honest; + // Material chrome borrows Spanish elsewhere (see app.dart). + expect(AppLocale.ast.languageCode, 'ast'); + }); + + test('choosing "system language" forgets the explicit choice', () async { + await store.setLocale(AppLocale.es); + expect(await store.saved(), AppLocale.es); + + await store.useDeviceLocale(); + expect(await store.saved(), isNull); + }); + + test('an unknown stored tag reads as "follow the device", never throws', + () async { + await secrets.write('tane.locale', 'zz-ZZ-removed-in-a-later-build'); + expect(await store.saved(), isNull); + }); +} diff --git a/apps/app_seeds/test/services/message_store_test.dart b/apps/app_seeds/test/services/message_store_test.dart new file mode 100644 index 0000000..c11c6ed --- /dev/null +++ b/apps/app_seeds/test/services/message_store_test.dart @@ -0,0 +1,101 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/chat_database.dart'; +import 'package:tane/services/message_store.dart'; + +import '../support/test_support.dart'; + +void main() { + late ChatDatabase db; + late MessageStore store; + setUp(() { + db = newTestChatDatabase(); + store = MessageStore(db); + }); + tearDown(() => db.close()); + + PrivateMessage msg(String from, String text, int atMs) => PrivateMessage( + fromPubkey: from, + text: text, + at: DateTime.fromMillisecondsSinceEpoch(atMs), + ); + + test('history is empty for an unknown peer', () async { + expect(await store.history('peer'), isEmpty); + }); + + test('append then history round-trips, oldest first', () async { + await store.append('peer', msg('peer', 'hi', 1000)); + await store.append('peer', msg('me', 'hello', 2000)); + final history = await store.history('peer'); + expect(history.map((m) => m.text), ['hi', 'hello']); + expect(history.first.fromPubkey, 'peer'); + expect(history.last.at.millisecondsSinceEpoch, 2000); + }); + + test('conversations are kept separate per peer', () async { + await store.append('a', msg('a', 'toA', 1)); + await store.append('b', msg('b', 'toB', 1)); + expect((await store.history('a')).single.text, 'toA'); + expect((await store.history('b')).single.text, 'toB'); + }); + + test( + 'conversations lists peers newest-first with their last message', + () async { + await store.append('peerA', msg('me', 'hi A', 1000)); + await store.append('peerB', msg('peerB', 'yo B', 3000)); + await store.append('peerA', msg('peerA', 'back A', 2000)); + + final convos = await store.conversations(); + expect(convos.map((c) => c.peerPubkey), [ + 'peerB', + 'peerA', + ]); // 3000 > 2000 + expect(convos.first.lastText, 'yo B'); + expect(convos.last.lastText, 'back A'); + }, + ); + + test( + 'append is idempotent: a re-delivered message is not duplicated', + () async { + // Same sender + timestamp + text = the same gift wrap redelivered by a + // relay on resubscribe. It must not pile up. + expect(await store.append('peer', msg('peer', 'hola', 1000)), isTrue); + expect(await store.append('peer', msg('peer', 'hola', 1000)), isFalse); + expect(await store.history('peer'), hasLength(1)); + + // A genuinely different message (later timestamp) still lands. + expect(await store.append('peer', msg('peer', 'hola', 2000)), isTrue); + expect(await store.history('peer'), hasLength(2)); + }, + ); + + test( + 'per-identity scope isolates conversations (0 = legacy scope)', + () async { + final acct0 = MessageStore(db); // legacy / account 0 + final acct1 = MessageStore(db, accountScope: 'acct1'); + + await acct0.append('peer', msg('peer', 'for identity 0', 1000)); + await acct1.append('peer', msg('peer', 'for identity 1', 2000)); + + // Same peer, but each identity sees only its own conversation. + expect((await acct0.history('peer')).single.text, 'for identity 0'); + expect((await acct1.history('peer')).single.text, 'for identity 1'); + expect((await acct0.conversations()).single.lastText, 'for identity 0'); + expect((await acct1.conversations()).single.lastText, 'for identity 1'); + }, + ); + + test('history is uncapped: nothing is silently dropped', () async { + for (var i = 0; i < 210; i++) { + await store.append('peer', msg('me', 'm$i', i)); + } + final history = await store.history('peer'); + expect(history, hasLength(210)); + expect(history.first.text, 'm0'); // the oldest survives + expect(history.last.text, 'm209'); + }); +} diff --git a/apps/app_seeds/test/services/notification_service_test.dart b/apps/app_seeds/test/services/notification_service_test.dart new file mode 100644 index 0000000..89a08af --- /dev/null +++ b/apps/app_seeds/test/services/notification_service_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:tane/services/notification_service.dart'; + +class _MockPlugin extends Mock implements FlutterLocalNotificationsPlugin {} + +void main() { + late _MockPlugin plugin; + + setUpAll(() { + registerFallbackValue( + const NotificationDetails( + android: AndroidNotificationDetails('x', 'x'), + ), + ); + registerFallbackValue(const InitializationSettings()); + }); + + setUp(() { + plugin = _MockPlugin(); + when(() => plugin.show(any(), any(), any(), any(), + payload: any(named: 'payload'))).thenAnswer((_) async {}); + // Generic platform resolution returns null, so the permission requests in + // initialize() are skipped in tests. + when(() => plugin.resolvePlatformSpecificImplementation()) + .thenReturn(null); + }); + + test('showMessage passes the title and pubkey payload, no body', () async { + final service = NotificationService(plugin: plugin, supported: true); + await service.showMessage( + peerPubkey: 'abc123', title: 'New message from Alice'); + final captured = verify(() => plugin.show( + captureAny(), + captureAny(), + captureAny(), + captureAny(), + payload: captureAny(named: 'payload'), + )).captured; + expect(captured[1], 'New message from Alice'); // title + expect(captured[2], isNull); // no body (privacy: no message text) + expect(captured[4], 'abc123'); // payload = peer pubkey + }); + + test('is a no-op on unsupported platforms (web/windows)', () async { + final service = NotificationService(plugin: plugin, supported: false); + await service.initialize(); + await service.showMessage(peerPubkey: 'abc123', title: 'hi'); + verifyNever(() => plugin.show(any(), any(), any(), any(), + payload: any(named: 'payload'))); + }); + + test('a tap with a payload invokes onTapChat with the pubkey', () async { + // Capture the response handler initialize() registers, then simulate a tap. + void Function(NotificationResponse)? handler; + when(() => plugin.initialize( + any(), + onDidReceiveNotificationResponse: + any(named: 'onDidReceiveNotificationResponse'), + )).thenAnswer((inv) async { + handler = inv.namedArguments[#onDidReceiveNotificationResponse] + as void Function(NotificationResponse)?; + return true; + }); + + final service = NotificationService(plugin: plugin, supported: true); + String? tapped; + service.onTapChat = (pk) => tapped = pk; + await service.initialize(); + + handler!(const NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: 'abc123', + )); + expect(tapped, 'abc123'); + }); +} diff --git a/apps/app_seeds/test/services/offer_outbox_test.dart b/apps/app_seeds/test/services/offer_outbox_test.dart new file mode 100644 index 0000000..20abd71 --- /dev/null +++ b/apps/app_seeds/test/services/offer_outbox_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/offer_outbox.dart'; + +import '../support/test_support.dart'; + +void main() { + late OfferOutbox outbox; + setUp(() => outbox = OfferOutbox(InMemorySecretStore())); + + test('starts empty', () async { + expect(await outbox.pending(), isEmpty); + }); + + test('enqueue adds ids; it is a set (no duplicates)', () async { + await outbox.enqueue(['a', 'b']); + await outbox.enqueue(['b', 'c']); + expect(await outbox.pending(), {'a', 'b', 'c'}); + }); + + test('remove drops the given ids', () async { + await outbox.enqueue(['a', 'b', 'c']); + await outbox.remove(['b']); + expect(await outbox.pending(), {'a', 'c'}); + }); + + test('clear empties it', () async { + await outbox.enqueue(['a', 'b']); + await outbox.clear(); + expect(await outbox.pending(), isEmpty); + }); +} diff --git a/apps/app_seeds/test/services/offer_thumbnail_test.dart b/apps/app_seeds/test/services/offer_thumbnail_test.dart new file mode 100644 index 0000000..a0e9530 --- /dev/null +++ b/apps/app_seeds/test/services/offer_thumbnail_test.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:tane/services/offer_thumbnail.dart'; + +void main() { + /// A real, decodable photo far bigger than a thumbnail, with varied pixels so + /// JPEG can't compress it to nothing. + Uint8List bigPhoto() { + final image = img.Image(width: 1280, height: 960); + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + image.setPixelRgb(x, y, (x * 7) % 256, (y * 5) % 256, (x + y) % 256); + } + } + return img.encodeJpg(image, quality: 90); + } + + test('produces a small jpeg data-URI that fits the byte budget', () { + final uri = offerThumbnailDataUri(bigPhoto(), maxBytes: 40000); + + expect(uri, isNotNull); + expect(uri!, startsWith('data:image/jpeg;base64,')); + final b64 = uri.substring('data:image/jpeg;base64,'.length); + expect(b64.length, lessThanOrEqualTo(40000)); + + // The payload really is a smaller image than the source. + final decoded = img.decodeJpg(base64.decode(b64))!; + expect(decoded.width, lessThanOrEqualTo(320)); + expect(decoded.height, lessThanOrEqualTo(320)); + }); + + test('never upscales an already-tiny image', () { + final small = img.encodeJpg(img.Image(width: 64, height: 48), quality: 80); + final uri = offerThumbnailDataUri(small); + + expect(uri, isNotNull); + final b64 = uri!.substring('data:image/jpeg;base64,'.length); + final decoded = img.decodeJpg(base64.decode(b64))!; + expect(decoded.width, 64); + expect(decoded.height, 48); + }); + + test('returns null for bytes that are not an image', () { + expect(offerThumbnailDataUri(Uint8List.fromList([1, 2, 3, 4])), isNull); + }); + + group('decodeDataUri', () { + test('round-trips a base64 data URI to its bytes', () { + final bytes = Uint8List.fromList([9, 8, 7, 6]); + final uri = 'data:image/jpeg;base64,${base64.encode(bytes)}'; + expect(decodeDataUri(uri), bytes); + }); + + test('returns null for a plain http url', () { + expect(decodeDataUri('https://example.org/a.jpg'), isNull); + }); + }); +} diff --git a/apps/app_seeds/test/services/onboarding_store_test.dart b/apps/app_seeds/test/services/onboarding_store_test.dart new file mode 100644 index 0000000..ecbe5fb --- /dev/null +++ b/apps/app_seeds/test/services/onboarding_store_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/onboarding_store.dart'; + +import '../support/test_support.dart'; + +void main() { + test('market rules start unaccepted and stay accepted once marked', () async { + final store = OnboardingStore(InMemorySecretStore()); + + expect(await store.marketRulesAccepted(), isFalse); + await store.markMarketRulesAccepted(); + expect(await store.marketRulesAccepted(), isTrue); + }); + + test('market-rules flag is independent of the intro flag', () async { + final store = OnboardingStore(InMemorySecretStore()); + + await store.markIntroSeen(); + expect(await store.introSeen(), isTrue); + expect(await store.marketRulesAccepted(), isFalse); + }); +} diff --git a/apps/app_seeds/test/services/plantare_service_test.dart b/apps/app_seeds/test/services/plantare_service_test.dart new file mode 100644 index 0000000..4b69b67 --- /dev/null +++ b/apps/app_seeds/test/services/plantare_service_test.dart @@ -0,0 +1,211 @@ +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/services/plantare_service.dart'; + +import '../support/test_support.dart'; + +/// A records-only transport: captures what a service sends so the test can hand +/// the resulting move to the other party's [PlantareService.ingest]. No relay. +class _CapturingTransport implements PlantareTransport { + final proposed = []; + final accepted = []; + final declined = <({String toPubkey, String pledgeId, String reason})>[]; + + @override + Future propose(PlantarePledge pledge) async => proposed.add(pledge); + @override + Future accept(PlantarePledge pledge) async => accepted.add(pledge); + @override + Future decline({ + required String toPubkey, + required String pledgeId, + String reason = '', + }) async => + declined.add((toPubkey: toPubkey, pledgeId: pledgeId, reason: reason)); + @override + Stream incoming() => const Stream.empty(); + @override + Future close() async {} +} + +void main() { + Future idFor(int fill) => NostrKeyDerivation.deriveFromSeed( + Uint8List(32)..fillRange(0, 32, fill), + ); + + late AppDatabase dbA; + late AppDatabase dbB; + late VarietyRepository repoA; + late VarietyRepository repoB; + late NostrIdentity creditor; // gives seed, proposes + late NostrIdentity debtor; // receives seed, owes a return + late PlantareService svcA; // creditor's service + late PlantareService svcB; // debtor's service + late _CapturingTransport txA; + late _CapturingTransport txB; + + setUp(() async { + dbA = newTestDatabase(); + dbB = newTestDatabase(); + repoA = newTestRepository(dbA); + repoB = newTestRepository(dbB); + creditor = await idFor(1); + debtor = await idFor(2); + txA = _CapturingTransport(); + txB = _CapturingTransport(); + svcA = PlantareService( + repo: repoA, + selfPubkey: creditor.publicKeyHex, + selfSecretKey: creditor.privateKeyHex, + )..bindTransport(txA); + svcB = PlantareService( + repo: repoB, + selfPubkey: debtor.publicKeyHex, + selfSecretKey: debtor.privateKeyHex, + )..bindTransport(txB); + }); + + tearDown(() async { + await svcA.stop(); + await svcB.stop(); + await dbA.close(); + await dbB.close(); + }); + + PlantareEnvelope envelope( + PlantareMessageKind kind, + String from, + PlantarePledge pledge, + ) => + PlantareEnvelope(kind: kind, fromPubkey: from, pledge: pledge); + + test('full handshake: propose → accept, both end fully signed & accepted', + () async { + // Creditor proposes (their view: owedToMe). + final pledgeId = await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: debtor.publicKeyHex, + counterpartyName: 'Ana', + label: 'Tomate rosa', + owedDescription: 'un puñado', + ); + expect(txA.proposed, hasLength(1)); + + // Creditor's local row: proposed, creditor-signed only. + final aRow = await repoA.plantareByPledgeId(pledgeId); + expect(aRow!.remoteState, PlantareRemoteState.proposed); + expect(aRow.creditorSignature, isNotNull); + expect(aRow.debtorSignature, isNull); + + // Debtor receives the proposal. + await svcB.ingest( + envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, + txA.proposed.single), + ); + final bRow = await repoB.plantareByPledgeId(pledgeId); + expect(bRow, isNotNull); + expect(bRow!.direction, PlantareDirection.iReturn); // mirror view + expect(bRow.remoteState, PlantareRemoteState.proposed); + expect(svcB.pendingProposals, contains(pledgeId)); + + // Debtor accepts → counter-signs and sends back. + await svcB.accept(pledgeId); + expect(txB.accepted, hasLength(1)); + final closed = txB.accepted.single; + expect(await PlantareCrypto.verifyBoth(closed), isTrue); + final bAfter = await repoB.plantareByPledgeId(pledgeId); + expect(bAfter!.remoteState, PlantareRemoteState.accepted); + expect(bAfter.debtorSignature, isNotNull); + expect(svcB.pendingProposals, isEmpty); + + // Creditor receives the accept → their row closes too. + await svcA.ingest( + envelope(PlantareMessageKind.accepted, debtor.publicKeyHex, closed), + ); + final aAfter = await repoA.plantareByPledgeId(pledgeId); + expect(aAfter!.remoteState, PlantareRemoteState.accepted); + expect(aAfter.debtorSignature, isNotNull); + expect(aAfter.creditorSignature, isNotNull); + }); + + test('decline notifies the proposer and marks the row declined', () async { + final pledgeId = await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: debtor.publicKeyHex, + label: 'Maíz', + ); + await svcB.ingest( + envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, + txA.proposed.single), + ); + + await svcB.decline(pledgeId, reason: 'ya no me caben'); + expect(txB.declined.single.toPubkey, creditor.publicKeyHex); + expect(txB.declined.single.reason, 'ya no me caben'); + final bRow = await repoB.plantareByPledgeId(pledgeId); + expect(bRow!.remoteState, PlantareRemoteState.declined); + + // Creditor sees the decline. + await svcA.ingest(PlantareEnvelope( + kind: PlantareMessageKind.declined, + fromPubkey: debtor.publicKeyHex, + pledgeId: pledgeId, + )); + expect( + (await repoA.plantareByPledgeId(pledgeId))!.remoteState, + PlantareRemoteState.declined); + }); + + test('a proposal with a bad signature is dropped, not stored', () async { + // Build a proposal but corrupt the creditor's stub. + final good = txA; + await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: debtor.publicKeyHex, + label: 'Judía', + ); + final tampered = good.proposed.single.copyWith( + creditorSignature: 'f' * 128, // invalid + ); + await svcB.ingest( + envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, tampered), + ); + expect(await repoB.watchPlantares().first, isEmpty); + expect(svcB.pendingProposals, isEmpty); + }); + + test('an accept must carry both valid stubs or it is ignored', () async { + final pledgeId = await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: debtor.publicKeyHex, + label: 'Calabaza', + ); + // Half-signed "accept" (only the creditor's original stub) → ignored. + await svcA.ingest( + envelope(PlantareMessageKind.accepted, debtor.publicKeyHex, + txA.proposed.single), + ); + expect( + (await repoA.plantareByPledgeId(pledgeId))!.remoteState, + PlantareRemoteState.proposed); + }); + + test('a proposal addressed to someone else is not stored', () async { + final stranger = await idFor(9); + await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: stranger.publicKeyHex, // not the debtor + label: 'Pepino', + ); + await svcB.ingest( + envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, + txA.proposed.single), + ); + expect(await repoB.watchPlantares().first, isEmpty); + }); +} diff --git a/apps/app_seeds/test/services/profile_cache_test.dart b/apps/app_seeds/test/services/profile_cache_test.dart new file mode 100644 index 0000000..a14b8d7 --- /dev/null +++ b/apps/app_seeds/test/services/profile_cache_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/profile_cache.dart'; + +import '../support/test_support.dart'; + +void main() { + test('unknown pubkey has no cached name', () async { + final cache = ProfileCache(InMemorySecretStore()); + expect(await cache.name('abc'), isNull); + }); + + test('setName then name round-trips (trimmed)', () async { + final cache = ProfileCache(InMemorySecretStore()); + await cache.setName('abc', ' Alicia '); + expect(await cache.name('abc'), 'Alicia'); + }); + + test('picture caches independently of name', () async { + final cache = ProfileCache(InMemorySecretStore()); + expect(await cache.picture('abc'), isNull); + await cache.setPicture('abc', ' tane:seed:jars '); + expect(await cache.picture('abc'), 'tane:seed:jars'); + expect(await cache.name('abc'), isNull); // name untouched + }); + + test('shortPubkey abbreviates long keys, keeps short ones', () { + expect(shortPubkey('abcdef1234567890'), 'abcdef…7890'); + expect(shortPubkey('short'), 'short'); + }); +} diff --git a/apps/app_seeds/test/services/profile_store_test.dart b/apps/app_seeds/test/services/profile_store_test.dart new file mode 100644 index 0000000..86faf4e --- /dev/null +++ b/apps/app_seeds/test/services/profile_store_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/profile_store.dart'; + +import '../support/test_support.dart'; + +void main() { + late ProfileStore store; + setUp(() => store = ProfileStore(InMemorySecretStore())); + + test('defaults are empty', () async { + expect(await store.name(), ''); + expect(await store.about(), ''); + }); + + test('save then read back, trimmed', () async { + await store.save(name: ' Alicia ', about: ' tomate rosa '); + expect(await store.name(), 'Alicia'); + expect(await store.about(), 'tomate rosa'); + }); + + test('avatar defaults empty and round-trips', () async { + expect(await store.avatar(), ''); + await store.save(name: 'A', about: '', avatar: 'tane:seed:jars'); + expect(await store.avatar(), 'tane:seed:jars'); + }); +} diff --git a/apps/app_seeds/test/services/saved_offers_store_test.dart b/apps/app_seeds/test/services/saved_offers_store_test.dart new file mode 100644 index 0000000..e7d4eca --- /dev/null +++ b/apps/app_seeds/test/services/saved_offers_store_test.dart @@ -0,0 +1,100 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/saved_offers_store.dart'; + +import '../support/test_support.dart'; + +Offer _offer( + String id, { + String author = 'aa', + OfferType type = OfferType.gift, + num? price, + String? currency, + String geohash = 'u09', +}) => Offer( + id: id, + authorPubkeyHex: author, + summary: 'Tomate $id', + type: type, + approxGeohash: geohash, + priceAmount: price, + priceCurrency: currency, + isOrganic: true, +); + +void main() { + late SavedOffersStore store; + + setUp(() => store = SavedOffersStore(InMemorySecretStore())); + + test('keyOf is the author:id coordinate', () { + expect(SavedOffersStore.keyOf(_offer('x', author: 'bb')), 'bb:x'); + }); + + test('starts empty', () async { + expect(await store.list(), isEmpty); + expect(await store.isSaved('aa:x'), isFalse); + }); + + test('save then list round-trips every field', () async { + await store.save( + _offer('1', type: OfferType.sale, price: 3, currency: 'Ğ1'), + savedAt: 100, + ); + final saved = await store.list(); + expect(saved, hasLength(1)); + final o = saved.single; + expect(o.id, '1'); + expect(o.authorPubkeyHex, 'aa'); + expect(o.type, OfferType.sale); + expect(o.priceAmount, 3); + expect(o.priceCurrency, 'Ğ1'); + expect(o.isOrganic, isTrue); + expect(await store.isSaved('aa:1'), isTrue); + }); + + test('list is newest-first by savedAt', () async { + await store.save(_offer('old'), savedAt: 100); + await store.save(_offer('new'), savedAt: 200); + expect([for (final o in await store.list()) o.id], ['new', 'old']); + }); + + test('saving the same coordinate again de-duplicates', () async { + await store.save(_offer('1'), savedAt: 100); + await store.save(_offer('1'), savedAt: 200); // same author:id + expect(await store.list(), hasLength(1)); + }); + + test('remove drops it', () async { + await store.save(_offer('1'), savedAt: 100); + await store.remove('aa:1'); + expect(await store.list(), isEmpty); + expect(await store.isSaved('aa:1'), isFalse); + }); + + test('toggle saves then removes and reports the new state', () async { + expect(await store.toggle(_offer('1'), savedAt: 100), isTrue); + expect(await store.isSaved('aa:1'), isTrue); + expect(await store.toggle(_offer('1'), savedAt: 200), isFalse); + expect(await store.isSaved('aa:1'), isFalse); + }); + + test('scopes are isolated by account', () async { + final secret = InMemorySecretStore(); + final a = SavedOffersStore(secret, accountScope: 'acct1'); + final b = SavedOffersStore(secret, accountScope: 'acct2'); + await a.save(_offer('1'), savedAt: 100); + expect(await a.list(), hasLength(1)); + expect(await b.list(), isEmpty); + }); + + test('changes stream fires on save and remove', () async { + final events = []; + final sub = store.changes.listen(events.add); + await store.save(_offer('1'), savedAt: 100); + await store.remove('aa:1'); + await Future.delayed(Duration.zero); + expect(events, hasLength(2)); + await sub.cancel(); + }); +} diff --git a/apps/app_seeds/test/services/share_catalog_service_test.dart b/apps/app_seeds/test/services/share_catalog_service_test.dart index 2484664..b32dbcc 100644 --- a/apps/app_seeds/test/services/share_catalog_service_test.dart +++ b/apps/app_seeds/test/services/share_catalog_service_test.dart @@ -55,12 +55,12 @@ void main() { final saved = await service.saveCatalog( title: 'What I share', date: 'July 9, 2026', - suggestedName: 'tanemaki-catalog-2026-07-09.pdf', + suggestedName: 'tane-catalog-2026-07-09.pdf', rows: const [ShareCatalogRow(name: 'Maize', mode: 'To give away')], ); expect(saved, isTrue); - expect(files.savedName, 'tanemaki-catalog-2026-07-09.pdf'); + expect(files.savedName, 'tane-catalog-2026-07-09.pdf'); expect(String.fromCharCodes(files.saved!.take(5)), '%PDF-'); }); } diff --git a/apps/app_seeds/test/services/social_account_store_test.dart b/apps/app_seeds/test/services/social_account_store_test.dart new file mode 100644 index 0000000..029034e --- /dev/null +++ b/apps/app_seeds/test/services/social_account_store_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/social_account_store.dart'; + +import '../support/test_support.dart'; + +void main() { + test('defaults to account 0 with no max created yet', () async { + final store = SocialAccountStore(InMemorySecretStore()); + expect(await store.active(), 0); + expect(await store.maxCreated(), 0); + }); + + test('createNew increments the max and makes it active', () async { + final store = SocialAccountStore(InMemorySecretStore()); + expect(await store.createNew(), 1); + expect(await store.active(), 1); + expect(await store.maxCreated(), 1); + + expect(await store.createNew(), 2); + expect(await store.active(), 2); + expect(await store.maxCreated(), 2); + }); + + test('setActive can switch back to an earlier identity, keeping the max', + () async { + final store = SocialAccountStore(InMemorySecretStore()); + await store.createNew(); // 1 + await store.createNew(); // 2 + await store.setActive(0); // back to the original + expect(await store.active(), 0); + expect(await store.maxCreated(), 2); // still remembers we made 3 identities + }); + + test('a negative account is rejected', () async { + final store = SocialAccountStore(InMemorySecretStore()); + expect(() => store.setActive(-1), throwsArgumentError); + }); + + test('scope: account 0 uses legacy (empty) keys, others are namespaced', () { + expect(socialAccountScope(0), ''); + expect(socialAccountScope(1), 'acct1'); + expect(socialAccountScope(2), 'acct2'); + }); +} diff --git a/apps/app_seeds/test/services/social_connection_test.dart b/apps/app_seeds/test/services/social_connection_test.dart new file mode 100644 index 0000000..e4c7151 --- /dev/null +++ b/apps/app_seeds/test/services/social_connection_test.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; + +import '../support/test_support.dart'; + +/// A no-op [NostrChannel] that only tracks whether it was closed — enough to +/// build a [SocialSession] and assert the connection's lifecycle. +class FakeChannel implements NostrChannel { + bool closed = false; + + @override + String get privateKeyHex => '00' * 32; + @override + String get publicKeyHex => 'ab' * 32; + @override + Future<({bool accepted, String message})> publish(Event event) async => + (accepted: true, message: ''); + @override + Stream subscribe(Filter filter) => const Stream.empty(); + @override + Future> reqOnce(Filter filter) async => const []; + @override + Future close() async => closed = true; +} + +void main() { + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + + late SocialService social; + late SocialSettings settings; // relayUrls() falls back to defaults (non-empty) + + setUp(() async { + social = await SocialService.fromRootSeedHex(seedHex); + settings = SocialSettings(InMemorySecretStore()); + }); + + SocialConnection make({ + required List opened, + Stream? online, + bool Function()? fail, + }) => + SocialConnection( + social: social, + settings: settings, + online: online, + open: (_) async { + if (fail?.call() ?? false) throw StateError('unreachable'); + final ch = FakeChannel(); + opened.add(ch); + return SocialSession(ch); + }, + ); + + test('connects once and reuses the shared session', () async { + final opened = []; + final conn = make(opened: opened); + final a = await conn.session(); + final b = await conn.session(); + expect(a, isNotNull); + expect(identical(a, b), isTrue); // same shared instance + expect(opened, hasLength(1)); // only one connection opened + await conn.dispose(); + }); + + test('concurrent callers share a single connect', () async { + final opened = []; + final conn = make(opened: opened); + final results = await Future.wait([conn.session(), conn.session()]); + expect(identical(results[0], results[1]), isTrue); + expect(opened, hasLength(1)); + await conn.dispose(); + }); + + test('drops when offline and reconnects when back online', () async { + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + final emitted = []; + conn.sessions.listen(emitted.add); + + conn.start(); // watch connectivity + initial connect + final first = await conn.session(); + expect(first, isNotNull); + expect(opened, hasLength(1)); + + online.add(false); // network lost + await Future.delayed(Duration.zero); + expect(conn.current, isNull); + expect(opened.first.closed, isTrue); // old session closed + expect(emitted.last, isNull); // announced the drop + + online.add(true); // network back + await Future.delayed(Duration.zero); + expect(conn.current, isNotNull); + expect(opened, hasLength(2)); // reconnected + expect(identical(emitted.last, conn.current), isTrue); + + await conn.dispose(); + await online.close(); + }); + + test('returns null when the relay is unreachable, and retries later', + () async { + final opened = []; + var down = true; + final conn = make(opened: opened, fail: () => down); + expect(await conn.session(), isNull); // unreachable now + down = false; + expect(await conn.session(), isNotNull); // succeeds on retry + await conn.dispose(); + }); +} diff --git a/apps/app_seeds/test/services/social_service_test.dart b/apps/app_seeds/test/services/social_service_test.dart new file mode 100644 index 0000000..7f8fd68 --- /dev/null +++ b/apps/app_seeds/test/services/social_service_test.dart @@ -0,0 +1,48 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/social_service.dart'; + +/// Wiring test: the app derives its social identity from the SAME root seed the +/// recovery QR backs up, deterministically and without any network. +void main() { + // A fixed 32-byte seed (64 hex chars). + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + + test('derives a stable Nostr identity from the root-seed hex', () async { + final service = await SocialService.fromRootSeedHex(seedHex); + expect(service.npub, startsWith('npub1')); + expect(service.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$'))); + }); + + test('is deterministic: same seed → same public identity', () async { + final a = await SocialService.fromRootSeedHex(seedHex); + final b = await SocialService.fromRootSeedHex(seedHex); + expect(a.publicKeyHex, b.publicKeyHex); + expect(a.npub, b.npub); + }); + + test('matches commons_core derivation on the same bytes', () async { + final service = await SocialService.fromRootSeedHex(seedHex); + final direct = await NostrKeyDerivation.deriveFromSeed([ + for (var i = 0; i < seedHex.length; i += 2) + int.parse(seedHex.substring(i, i + 2), radix: 16), + ]); + expect(service.publicKeyHex, direct.publicKeyHex); + }); + + test('a different seed yields a different identity', () async { + final a = await SocialService.fromRootSeedHex(seedHex); + final b = await SocialService.fromRootSeedHex( + 'ff${seedHex.substring(2)}', + ); + expect(a.publicKeyHex, isNot(b.publicKeyHex)); + }); + + test('rejects a malformed (odd-length) seed hex', () async { + await expectLater( + SocialService.fromRootSeedHex('abc'), + throwsA(isA()), + ); + }); +} diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart new file mode 100644 index 0000000..c3abe43 --- /dev/null +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/security/secret_store.dart'; +import 'package:tane/services/social_settings.dart'; + +/// In-memory [SecretStore] for tests. +class FakeSecretStore implements SecretStore { + final Map _data = {}; + @override + Future read(String key) async => _data[key]; + @override + Future write(String key, String value) async => _data[key] = value; +} + +void main() { + late SocialSettings settings; + setUp(() => settings = SocialSettings(FakeSecretStore())); + + test('defaults: empty area, but relays fall back to the defaults', () async { + expect(await settings.areaGeohash(), ''); + expect(await settings.relayUrls(), SocialSettings.defaultRelays); + // Not configured yet: the area is still unset. + expect(await settings.isConfigured, isFalse); + }); + + test('an explicitly empty relay choice turns the network off', () async { + await settings.setRelayUrls(const []); + expect(await settings.relayUrls(), isEmpty); + }); + + test('stores and normalises the area geohash (trim + lowercase)', () async { + await settings.setAreaGeohash(' SP3E9 '); + expect(await settings.areaGeohash(), 'sp3e9'); + }); + + test('stores relay urls, dropping blanks', () async { + await settings.setRelayUrls([ + 'wss://relay.comunes.org', + ' ', + 'wss://seeds.example.org', + ]); + expect(await settings.relayUrls(), + ['wss://relay.comunes.org', 'wss://seeds.example.org']); + }); + + test('is configured once an area is set (relays are defaulted)', () async { + expect(await settings.isConfigured, isFalse); // no area yet + await settings.setAreaGeohash('sp3e9'); + expect(await settings.isConfigured, isTrue); + }); + + test('search precision defaults to 4 (a wide "around here")', () async { + expect(await settings.searchPrecision(), 4); + }); + + test('stores and reads back the search precision', () async { + await settings.setSearchPrecision(3); + expect(await settings.searchPrecision(), 3); + await settings.setSearchPrecision(5); + expect(await settings.searchPrecision(), 5); + }); + + test('search precision is clamped to the 3–5 range', () async { + await settings.setSearchPrecision(1); + expect(await settings.searchPrecision(), 3); + await settings.setSearchPrecision(9); + expect(await settings.searchPrecision(), 5); + }); + + test('a corrupt stored precision falls back to the default', () async { + final store = FakeSecretStore(); + await store.write('tane.social.search_precision', 'oops'); + expect(await SocialSettings(store).searchPrecision(), 4); + }); + + test('the blocklist starts empty and round-trips block/unblock', () async { + expect(await settings.blockedPubkeys(), isEmpty); + + await settings.block('AB' * 32); + expect(await settings.isBlocked('ab' * 32), isTrue); // normalised + expect(await settings.blockedPubkeys(), {'ab' * 32}); + + await settings.block('cd' * 32); + expect(await settings.blockedPubkeys(), hasLength(2)); + + await settings.unblock('ab' * 32); + expect(await settings.isBlocked('ab' * 32), isFalse); + expect(await settings.blockedPubkeys(), {'cd' * 32}); + }); + + test('blocking twice keeps a single entry', () async { + await settings.block('ab' * 32); + await settings.block(' AB' * 1 + 'ab' * 31); // messy spacing/case + expect(await settings.blockedPubkeys(), hasLength(1)); + }); + + test('hidden (reported) offers round-trip as author:id keys', () async { + expect(await settings.hiddenOfferKeys(), isEmpty); + await settings.hideOffer(authorPubkeyHex: 'AB' * 32, offerId: 'tomate-1'); + expect( + await settings.hiddenOfferKeys(), + {SocialSettings.offerKey('ab' * 32, 'tomate-1')}, + ); + }); +} diff --git a/apps/app_seeds/test/services/sync_service_test.dart b/apps/app_seeds/test/services/sync_service_test.dart new file mode 100644 index 0000000..e7e7eef --- /dev/null +++ b/apps/app_seeds/test/services/sync_service_test.dart @@ -0,0 +1,59 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/inventory_snapshot_io.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/services/sync_service.dart'; + +import '../support/test_support.dart'; + +/// Records what it's asked to build/apply — no DB, no relay. +class _FakeIO implements InventorySnapshotIO { + final applied = >[]; + List snapshot = const [1, 2, 3]; + + @override + Future> buildSnapshot() async => snapshot; + + @override + Future applySnapshot(List bytes) async => applied.add(bytes); +} + +void main() { + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + + Future make(_FakeIO io) async => SyncService( + connection: SocialConnection( + social: await SocialService.fromRootSeedHex(seedHex), + settings: SocialSettings(InMemorySecretStore()), + open: (_) async => throw StateError('unused'), + online: const Stream.empty(), + ), + io: io, + localChanges: const Stream.empty(), + selfDeviceId: 'me', + ); + + SyncSnapshot snap(String device, List data) => + SyncSnapshot(deviceId: device, data: data, at: DateTime(2026)); + + test('merges another device\'s snapshot into the local inventory', () async { + final io = _FakeIO(); + final sync = await make(io); + await sync.handleRemote(snap('other', const [9, 9, 9])); + expect(io.applied, [ + [9, 9, 9] + ]); + await sync.stop(); + }); + + test('ignores our own device\'s snapshot (no self-echo)', () async { + final io = _FakeIO(); + final sync = await make(io); + await sync.handleRemote(snap('me', const [1])); + expect(io.applied, isEmpty); + await sync.stop(); + }); +} diff --git a/apps/app_seeds/test/services/unread_service_test.dart b/apps/app_seeds/test/services/unread_service_test.dart new file mode 100644 index 0000000..3470f29 --- /dev/null +++ b/apps/app_seeds/test/services/unread_service_test.dart @@ -0,0 +1,90 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/message_store.dart'; +import 'package:tane/services/unread_service.dart'; + +import '../support/test_support.dart'; + +/// Unread counting is derived from stored history plus a per-peer "last read" +/// mark; these tests drive it through a real [MessageStore] over an in-memory +/// keystore, so no relay or network is involved. +void main() { + const me = 'me-pubkey'; + + late MessageStore store; + late UnreadService unread; + + setUp(() { + store = MessageStore(newTestChatDatabase()); + unread = UnreadService(store, InMemorySecretStore()); + }); + + tearDown(() => unread.dispose()); + + PrivateMessage msg(String from, String text, int atMs) => PrivateMessage( + fromPubkey: from, + text: text, + at: DateTime.fromMillisecondsSinceEpoch(atMs)); + + // Persist then notify, the way InboxService.ingest does. + Future receive(String peer, PrivateMessage m) async { + await store.append(peer, m); + await unread.onMessageReceived(peer, m); + } + + test('an unknown peer has no unread', () async { + expect(await unread.unreadCount('alice'), 0); + }); + + test('inbound messages increment the unread count', () async { + await receive('alice', msg('alice', 'hi', 1000)); + await receive('alice', msg('alice', 'you there?', 2000)); + expect(await unread.unreadCount('alice'), 2); + }); + + test('markRead clears older messages but keeps newer ones', () async { + await receive('alice', msg('alice', 'a', 1000)); + await unread.markRead('alice'); // reads up to the latest (1000) + expect(await unread.unreadCount('alice'), 0); + + await receive('alice', msg('alice', 'b', 2000)); + expect(await unread.unreadCount('alice'), 1); + }); + + test('own outgoing messages never count as unread', () async { + await receive('alice', msg(me, 'my reply', 1000)); + expect(await unread.unreadCount('alice'), 0); + }); + + test('a message for the open chat is auto-read (no unread)', () async { + unread.activePeer = 'alice'; + await receive('alice', msg('alice', 'while open', 1000)); + expect(await unread.unreadCount('alice'), 0); + }); + + test('totalUnreadCount sums across conversations', () async { + await receive('alice', msg('alice', 'a', 1000)); + await receive('bob', msg('bob', 'b1', 1000)); + await receive('bob', msg('bob', 'b2', 2000)); + expect(await unread.totalUnreadCount(), 3); + }); + + test('changes fires on a new inbound message', () async { + final events = []; + final sub = unread.changes.listen(events.add); + await receive('alice', msg('alice', 'hi', 1000)); + await Future.delayed(Duration.zero); + expect(events, hasLength(1)); + await sub.cancel(); + }); + + test('changes fires on markRead', () async { + await receive('alice', msg('alice', 'hi', 1000)); + final events = []; + final sub = unread.changes.listen(events.add); + await unread.markRead('alice'); + await Future.delayed(Duration.zero); + expect(events, hasLength(1)); + await sub.cancel(); + }); +} diff --git a/apps/app_seeds/test/state/inventory_cubit_test.dart b/apps/app_seeds/test/state/inventory_cubit_test.dart index 3ce4ad9..fbabece 100644 --- a/apps/app_seeds/test/state/inventory_cubit_test.dart +++ b/apps/app_seeds/test/state/inventory_cubit_test.dart @@ -1,5 +1,7 @@ import 'dart:typed_data'; +import 'package:commons_core/commons_core.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; @@ -8,6 +10,25 @@ import 'package:tane/state/inventory_cubit.dart'; import '../support/test_support.dart'; +/// A repository whose inventory stream errors for the first +/// [failuresBeforeHealthy] subscriptions, then behaves normally — mimics the +/// transient DB-not-ready failure that used to leave the spinner stuck forever. +/// The default never recovers, for the "give up" path. +class _FailingRepository extends VarietyRepository { + // ignore: use_super_parameters + _FailingRepository(AppDatabase db, {this.failuresBeforeHealthy = 1 << 30}) + : super(db, idGen: IdGen(), nodeId: 'test-node'); + + final int failuresBeforeHealthy; + int subscriptions = 0; + + @override + Stream<({List items, List drafts})> + watchInventoryView() => subscriptions++ < failuresBeforeHealthy + ? Stream.error(StateError('boom')) + : super.watchInventoryView(); +} + /// Waits until the cubit's state satisfies [predicate], whether it already /// does or a later stream emission gets it there. Future waitFor( @@ -39,6 +60,53 @@ void main() { expect(state.items.single.label, 'Maize'); }); + group('stream failure', () { + test('a transient failure recovers on its own — no error, no manual retry', + () async { + // Fails the first two subscribes, then the DB is ready. + final failing = _FailingRepository(db, failuresBeforeHealthy: 2); + await failing.addQuickVariety(label: 'Maize'); + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + + // Auto-retry with backoff brings it back without ever showing an error. + final state = await waitFor(failCubit, (s) => !s.loading && s.error == null); + expect(state.items.single.label, 'Maize'); + expect(failing.subscriptions, greaterThan(1)); + }); + + test('stays in loading (spinner), never flips to error, while auto-retrying', + () async { + final failing = _FailingRepository(db, failuresBeforeHealthy: 2); + await failing.addQuickVariety(label: 'Maize'); + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + + final errors = []; + final sub = failCubit.stream.listen((s) => errors.add(s.error)); + addTearDown(sub.cancel); + + await waitFor(failCubit, (s) => !s.loading && s.error == null); + expect(errors.every((e) => e == null), isTrue, + reason: 'no error state should be emitted during auto-recovery'); + }); + + test('gives up after the retry budget and surfaces a manual-retry error', + () { + fakeAsync((async) { + final failing = _FailingRepository(db); // never recovers + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + + // Fast-forward past all backoff windows (~12s of retries). + async.elapse(const Duration(seconds: 30)); + + expect(failCubit.state.loading, isFalse); + expect(failCubit.state.error, contains('boom')); + }); + }); + }); + test('drafts stay in the tray and never mix into the list', () async { await repo.addDraftVariety(Uint8List.fromList([1, 2, 3])); var state = await waitFor(cubit, (s) => s.drafts.isNotEmpty); @@ -147,4 +215,50 @@ void main() { expect(state.categories, hasLength(2)); expect(state.categories.toSet(), {'Poaceae', 'Fabaceae'}); }); + + group('selection mode', () { + test('enterSelection starts the mode with one id', () async { + await repo.addQuickVariety(label: 'Bean'); + final state = await waitFor(cubit, (s) => s.items.isNotEmpty); + final id = state.items.single.id; + + cubit.enterSelection(id); + expect(cubit.state.selectionMode, isTrue); + expect(cubit.state.selectedIds, {id}); + }); + + test('toggleSelection adds then removes, staying in mode', () async { + await repo.addQuickVariety(label: 'Bean'); + final state = await waitFor(cubit, (s) => s.items.isNotEmpty); + final id = state.items.single.id; + + cubit.toggleSelection(id); + expect(cubit.state.selectedIds, {id}); + + cubit.toggleSelection(id); + expect(cubit.state.selectedIds, isEmpty); + expect(cubit.state.selectionMode, isTrue); + }); + + test('selectAllVisible selects only the filtered subset', () async { + await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae'); + await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae'); + final state = await waitFor(cubit, (s) => s.items.length == 2); + final beanId = state.items.firstWhere((i) => i.label == 'Bean').id; + + cubit.toggleCategory('Fabaceae'); // hide the tomato + cubit.selectAllVisible(); + expect(cubit.state.selectedIds, {beanId}); + }); + + test('exitSelection leaves the mode and clears the selection', () async { + await repo.addQuickVariety(label: 'Bean'); + final state = await waitFor(cubit, (s) => s.items.isNotEmpty); + cubit.enterSelection(state.items.single.id); + + cubit.exitSelection(); + expect(cubit.state.selectionMode, isFalse); + expect(cubit.state.selectedIds, isEmpty); + }); + }); } diff --git a/apps/app_seeds/test/state/messages_cubit_test.dart b/apps/app_seeds/test/state/messages_cubit_test.dart new file mode 100644 index 0000000..d16d8c3 --- /dev/null +++ b/apps/app_seeds/test/state/messages_cubit_test.dart @@ -0,0 +1,219 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/message_store.dart'; +import 'package:tane/state/messages_cubit.dart'; + +import '../support/test_support.dart'; + +/// In-memory [MessageTransport]: records sends, lets a test push inbox messages. +class FakeMessageTransport implements MessageTransport { + final List<({String to, String text})> sent = []; + final StreamController _inbox = + StreamController.broadcast(); + + void receive(PrivateMessage message) => _inbox.add(message); + + @override + Future send({required String toPubkey, required String text}) async => + sent.add((to: toPubkey, text: text)); + + @override + Stream inbox() => _inbox.stream; + + @override + Future close() async => _inbox.close(); +} + +/// A transport whose [send] always fails (e.g. no relay reachable). +class _FailingSendTransport implements MessageTransport { + @override + Future send({required String toPubkey, required String text}) async => + throw StateError('no relay'); + + @override + Stream inbox() => const Stream.empty(); + + @override + Future close() async {} +} + +void main() { + const peer = 'aa'; + const me = 'bb'; + + PrivateMessage msg(String from, String text) => + PrivateMessage(fromPubkey: from, text: text, at: DateTime(2026)); + + test('receives messages from the peer only', () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + + transport.receive(msg(peer, 'hola')); + transport.receive(msg('cc', 'not for this chat')); // other conversation + await pumpEventQueue(); + + expect(cubit.state.messages, hasLength(1)); + expect(cubit.state.messages.single.text, 'hola'); + expect(cubit.isMine(cubit.state.messages.single), isFalse); + await cubit.close(); + }); + + test('send delivers to the peer and appends our own message', () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); + + await cubit.send(' ¿cambiamos? '); + expect(transport.sent, [(to: peer, text: '¿cambiamos?')]); + expect(cubit.state.messages, hasLength(1)); + expect(cubit.isMine(cubit.state.messages.single), isTrue); + expect(cubit.state.sending, isFalse); + await cubit.close(); + }); + + test('a full exchange keeps order and sides', () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + + await cubit.send('hi'); + transport.receive(msg(peer, 'hello')); + await pumpEventQueue(); + + expect(cubit.state.messages.map((m) => m.text), ['hi', 'hello']); + expect(cubit.state.messages.map(cubit.isMine), [true, false]); + await cubit.close(); + }); + + test( + 'a failed send surfaces an error and drops the optimistic message', + () async { + final transport = _FailingSendTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.send('hi'); + expect(cubit.state.error, isNotNull); + expect(cubit.state.sending, isFalse); + expect(cubit.state.messages, isEmpty); + await cubit.close(); + }, + ); + + test('a message containing a link is not sent', () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.send('check https://evil.example/login'); + expect(transport.sent, isEmpty); + expect(cubit.state.messages, isEmpty); + await cubit.close(); + }); + + test('empty/whitespace text is not sent', () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.send(' '); + expect(transport.sent, isEmpty); + expect(cubit.state.messages, isEmpty); + await cubit.close(); + }); + + test( + 'loads saved history on start and persists across a new cubit', + () async { + // History is ordered by each message's own timestamp, so use realistic + // monotonic times: 'earlier' predates the sent 'hi' (stamped now() by the + // cubit), and 'reply' comes after it. + PrivateMessage at(String from, String text, DateTime when) => + PrivateMessage(fromPubkey: from, text: text, at: when); + + final store = MessageStore(newTestChatDatabase()); + await store.append( + peer, + at(peer, 'earlier', DateTime(2020)), + ); // from a previous session + + final transport = FakeMessageTransport(); + final cubit = MessagesCubit( + transport, + peerPubkey: peer, + selfPubkey: me, + store: store, + ); + await cubit.start(); + expect(cubit.state.messages.map((m) => m.text), ['earlier']); + + await cubit.send('hi'); // persisted, stamped now() + transport.receive(at(peer, 'reply', DateTime(2100))); // persisted, later + await pumpEventQueue(); + expect(cubit.state.messages.map((m) => m.text), [ + 'earlier', + 'hi', + 'reply', + ]); + await cubit.close(); + + // A fresh cubit (even offline) sees the saved conversation. + final reopened = MessagesCubit( + null, + peerPubkey: peer, + selfPubkey: me, + store: store, + ); + await reopened.start(); + expect(reopened.state.messages.map((m) => m.text), [ + 'earlier', + 'hi', + 'reply', + ]); + await reopened.close(); + }, + ); + + test( + 'a re-delivered wrap is shown once (relay resends stored events)', + () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + + final same = msg(peer, 'hola'); // identical sender+timestamp+text + transport.receive(same); + transport.receive(same); // relay re-delivery on the same subscription + await pumpEventQueue(); + + expect(cubit.state.messages, hasLength(1)); + await cubit.close(); + }, + ); + + test('history already surfaced live is not shown twice', () async { + // The stored message is ALSO handed back by the live subscription on open. + final store = MessageStore(newTestChatDatabase()); + final m = msg(peer, 'hola'); + await store.append(peer, m); + + final transport = FakeMessageTransport(); + final cubit = MessagesCubit( + transport, + peerPubkey: peer, + selfPubkey: me, + store: store, + ); + unawaited(cubit.start()); + transport.receive(m); // live redelivery races the history load + await pumpEventQueue(); + + expect(cubit.state.messages.map((m) => m.text), ['hola']); + await cubit.close(); + }); + + test('offline (no transport) never throws', () async { + final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me) + ..start(); + expect(cubit.isOnline, isFalse); + await cubit.send('hi'); + expect(cubit.state.messages, isEmpty); + await cubit.close(); + }); +} diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart new file mode 100644 index 0000000..d38a96e --- /dev/null +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -0,0 +1,704 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/services/discovery_area.dart'; +import 'package:tane/services/offer_mapper.dart'; +import 'package:tane/services/offer_outbox.dart'; +import 'package:tane/state/offers_cubit.dart'; + +import '../support/test_support.dart'; + +/// In-memory [OfferTransport] for tests: addressable by id, discover matches by +/// geohash prefix. No relay, no network. +class FakeOfferTransport implements OfferTransport { + final List _offers = []; + + @override + Future publish(Offer offer) async { + _offers.removeWhere((o) => o.id == offer.id); + _offers.add(offer); + return PublishResult(accepted: true, transportRef: offer.id); + } + + @override + Stream discover(DiscoveryQuery query) { + final controller = StreamController(); + for (final o in _offers) { + if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o); + } + return controller.stream; // left open (live), like a real subscription + } + + @override + Future retract(String offerId) async => + _offers.removeWhere((o) => o.id == offerId); + + @override + Future close() async {} +} + +/// Emits every published offer TWICE on discover — the way a relay legitimately +/// resends an addressable event (a stored copy plus a live echo). Used to prove +/// the cubit dedups instead of doubling the listing. +class DuplicatingOfferTransport implements OfferTransport { + final List _offers = []; + + @override + Future publish(Offer offer) async { + _offers.add(offer); + return PublishResult(accepted: true, transportRef: offer.id); + } + + @override + Stream discover(DiscoveryQuery query) { + final controller = StreamController(); + for (final o in _offers) { + if (o.approxGeohash.startsWith(query.geohashPrefix)) { + controller.add(o); + controller.add(o); // the duplicate + } + } + return controller.stream; + } + + @override + Future retract(String offerId) async => + _offers.removeWhere((o) => o.id == offerId); + + @override + Future close() async {} +} + +void main() { + group('OfferMapper', () { + test('maps local sharing intent to the network offer type', () { + expect(OfferMapper.offerTypeFor(OfferStatus.shared), OfferType.gift); + expect(OfferMapper.offerTypeFor(OfferStatus.exchange), OfferType.exchange); + expect(OfferMapper.offerTypeFor(OfferStatus.sell), OfferType.sale); + }); + + test('never publishes a private lot', () { + expect(() => OfferMapper.offerTypeFor(OfferStatus.private), + throwsArgumentError); + }); + + test('builds an offer, keeping price only for a sale', () { + final gift = OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + priceAmount: 5, // should be dropped for a gift + ); + expect(gift.type, OfferType.gift); + expect(gift.id, 'lot-1'); + expect(gift.priceAmount, isNull); + + final sale = OfferMapper.fromSharedLot( + lotId: 'lot-2', + authorPubkeyHex: 'ab' * 32, + summary: 'Judía', + sharing: OfferStatus.sell, + areaGeohash: 'sp3e9', + priceAmount: 3, + priceCurrency: 'G1', + ); + expect(sale.type, OfferType.sale); + expect(sale.priceAmount, 3); + expect(sale.priceCurrency, 'G1'); + }); + + test('carries the organic (eco) flag through to the offer', () { + final eco = OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Lechuga', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + isOrganic: true, + ); + expect(eco.isOrganic, isTrue); + final plain = OfferMapper.fromSharedLot( + lotId: 'lot-2', + authorPubkeyHex: 'ab' * 32, + summary: 'Lechuga', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + ); + expect(plain.isOrganic, isFalse); + }); + }); + + group('OffersCubit', () { + test('discovers published offers by geohash prefix', () async { + final transport = FakeOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa de Barbastro', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + + await cubit.discover('sp3'); + await pumpEventQueue(); + + expect(cubit.state.hasSearched, isTrue); + expect(cubit.state.searching, isFalse); + expect(cubit.state.offers, hasLength(1)); + expect(cubit.state.offers.single.summary, 'Tomate rosa de Barbastro'); + await cubit.close(); + }); + + test('search narrows visible offers by summary, keeping the full list', + () async { + final transport = FakeOfferTransport(); + for (final s in ['Tomate rosa', 'Judía verde', 'Tomate rojo']) { + await transport.publish(OfferMapper.fromSharedLot( + lotId: s, + authorPubkeyHex: 'ab' * 32, + summary: s, + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + } + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(3)); + + cubit.search('tomate'); // case-insensitive + expect(cubit.state.visibleOffers.map((o) => o.summary), + containsAll(['Tomate rosa', 'Tomate rojo'])); + expect(cubit.state.visibleOffers, hasLength(2)); + expect(cubit.state.offers, hasLength(3)); // underlying list untouched + + cubit.search(''); // clearing shows everything again + expect(cubit.state.visibleOffers, hasLength(3)); + await cubit.close(); + }); + + test('blocked authors disappear from the visible list and can resurface', + () async { + final transport = FakeOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-good', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-spam', + authorPubkeyHex: 'cd' * 32, + summary: 'Spam total', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.visibleOffers, hasLength(2)); + + cubit.setBlockedAuthors({'cd' * 32}); + expect(cubit.state.visibleOffers.map((o) => o.summary), ['Tomate rosa']); + expect(cubit.state.offers, hasLength(2)); // discoveries kept underneath + + // The blocklist survives a refresh (re-discovery resets results only). + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.visibleOffers.map((o) => o.summary), ['Tomate rosa']); + + cubit.setBlockedAuthors(const {}); // unblock resurfaces + expect(cubit.state.visibleOffers, hasLength(2)); + await cubit.close(); + }); + + test('a hidden (reported) offer disappears without touching its author', + () async { + final transport = FakeOfferTransport(); + for (final id in ['lot-1', 'lot-2']) { + await transport.publish(OfferMapper.fromSharedLot( + lotId: id, + authorPubkeyHex: 'ab' * 32, + summary: 'Oferta $id', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + } + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.visibleOffers, hasLength(2)); + + cubit.setHiddenOffers({'${'ab' * 32}:lot-1'}); + expect(cubit.state.visibleOffers.map((o) => o.id), ['lot-2']); + await cubit.close(); + }); + + test('the search filter survives a refresh (re-discovery)', () async { + final transport = FakeOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Pimiento', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + cubit.search('pim'); + expect(cubit.state.query, 'pim'); + + await cubit.discover('sp3'); // pull-to-refresh + await pumpEventQueue(); + expect(cubit.state.query, 'pim'); // kept across the refresh + expect(cubit.state.visibleOffers, hasLength(1)); + await cubit.close(); + }); + + test('a neighbour in an adjacent cell is found via the coarsened prefix', + () async { + // Regression: A publishes at 'sp3e9', B lives one ±2.4 km cell over at + // 'sp3e8'. Searching B's full 5-char area misses it; searching the + // precision-4 prefix ('sp3e') finds it, because publish emits the ladder. + final transport = FakeOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + + await cubit.discover(searchPrefix('sp3e8', 4)); // 'sp3e' + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(1)); + + await cubit.discover('sp3e8'); // full precision → the old, broken behaviour + await pumpEventQueue(); + expect(cubit.state.offers, isEmpty); + await cubit.close(); + }); + + test('a different area finds nothing', () async { + final transport = FakeOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Pimiento', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + await cubit.discover('u09'); + await pumpEventQueue(); + expect(cubit.state.offers, isEmpty); + await cubit.close(); + }); + + test('publish reports the transport verdict', () async { + final cubit = OffersCubit(FakeOfferTransport()); + final result = await cubit.publish(OfferMapper.fromSharedLot( + lotId: 'lot-9', + authorPubkeyHex: 'ab' * 32, + summary: 'Calabaza', + sharing: OfferStatus.exchange, + areaGeohash: 'sp3e9', + exchangeTerms: 'a cambio de legumbre', + )); + expect(result.accepted, isTrue); + expect(cubit.state.publishing, isFalse); + await cubit.close(); + }); + + test('publishLots publishes each shared lot and counts acceptances', + () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit(transport); + final count = await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared, + category: 'Solanaceae', + ), + ShareableLot( + lotId: 'lot-2', + varietyId: 'var-2', + summary: 'Judía', + offerStatus: OfferStatus.exchange, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + expect(count, 2); + expect(cubit.state.publishing, isFalse); + + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(2)); + await cubit.close(); + }); + + test('publishLots carries the lot asking price on sale offers', () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit(transport); + await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-sale', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.sell, + priceAmount: 3.5, + priceCurrency: 'Ğ1', + ), + // "To be agreed": sale without a figure publishes without a price. + ShareableLot( + lotId: 'lot-negotiable', + varietyId: 'var-2', + summary: 'Judía', + offerStatus: OfferStatus.sell, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + + await cubit.discover('sp3'); + await pumpEventQueue(); + final sale = cubit.state.offers.singleWhere((o) => o.id == 'lot-sale'); + expect(sale.type, OfferType.sale); + expect(sale.priceAmount, 3.5); + expect(sale.priceCurrency, 'Ğ1'); + final negotiable = + cubit.state.offers.singleWhere((o) => o.id == 'lot-negotiable'); + expect(negotiable.priceAmount, isNull); + await cubit.close(); + }); + + test('publishLots embeds an inline thumbnail data-URI on the offer', + () async { + final transport = FakeOfferTransport(); + var thumbnailed = 0; + final cubit = OffersCubit( + transport, + coverPhoto: (id) async => Uint8List.fromList([1, 2, 3]), + thumbnail: (bytes) { + thumbnailed++; + return 'data:image/jpeg;base64,AAAA'; + }, + ); + await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(thumbnailed, 1); + expect(cubit.state.offers.single.imageUrl, 'data:image/jpeg;base64,AAAA'); + await cubit.close(); + }); + + test('publishLots still publishes (without image) when thumbnailing fails', + () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit( + transport, + coverPhoto: (id) async => Uint8List.fromList([1, 2, 3]), + thumbnail: (bytes) => throw Exception('boom'), + ); + final count = await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + + expect(count, 1); // the offer still went out + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers.single.imageUrl, isNull); + await cubit.close(); + }); + + test('publishLots without a thumbnailer publishes no image', () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit(transport); // no thumbnail, no coverPhoto + await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers.single.imageUrl, isNull); + await cubit.close(); + }); + + test('publishLots is a no-op offline or with no area', () async { + final offline = OffersCubit(null); + expect( + await offline.publishLots( + const [ + ShareableLot( + lotId: 'x', + varietyId: 'vx', + summary: 'x', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ), + 0, + ); + await offline.close(); + + final noArea = OffersCubit(FakeOfferTransport()); + expect( + await noArea.publishLots( + const [ + ShareableLot( + lotId: 'x', + varietyId: 'vx', + summary: 'x', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: '', + ), + 0, + ); + await noArea.close(); + }); + + test('flushOutbox publishes queued lots, then clears them', () async { + final outbox = OfferOutbox(InMemorySecretStore()); + await outbox.enqueue(['lot-1', 'lot-2']); + final cubit = OffersCubit(FakeOfferTransport()); + final count = await flushOutbox( + outbox: outbox, + cubit: cubit, + shareableLots: const [ + ShareableLot( + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared), + ShareableLot( + lotId: 'lot-2', + varietyId: 'var-2', + summary: 'Judía', + offerStatus: OfferStatus.exchange), + ShareableLot( + lotId: 'lot-3', + varietyId: 'var-3', + summary: 'Otro', + offerStatus: OfferStatus.shared), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + expect(count, 2); + expect(await outbox.pending(), isEmpty); + await cubit.close(); + }); + + test('flushOutbox offline keeps the queue for later', () async { + final outbox = OfferOutbox(InMemorySecretStore()); + await outbox.enqueue(['lot-1']); + final cubit = OffersCubit(null); // offline + final count = await flushOutbox( + outbox: outbox, + cubit: cubit, + shareableLots: const [], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + expect(count, 0); + expect(await outbox.pending(), {'lot-1'}); + await cubit.close(); + }); + + test('flushOutbox drops queued lots that no longer exist', () async { + final outbox = OfferOutbox(InMemorySecretStore()); + await outbox.enqueue(['gone']); + final cubit = OffersCubit(FakeOfferTransport()); + final count = await flushOutbox( + outbox: outbox, + cubit: cubit, + shareableLots: const [], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + expect(count, 0); + expect(await outbox.pending(), isEmpty); + await cubit.close(); + }); + + test('offline (no transport): degrades gracefully, never throws', () async { + final cubit = OffersCubit(null); + expect(cubit.isOnline, isFalse); + await cubit.discover('sp3'); + expect(cubit.state.error, 'offline'); + final result = await cubit.publish(OfferMapper.fromSharedLot( + lotId: 'x', + authorPubkeyHex: 'ab' * 32, + summary: 'x', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + expect(result.accepted, isFalse); + await cubit.close(); + }); + + test('a resent offer is not duplicated in the list', () async { + // Regression: publishing an inventory lot showed up twice because the + // relay resends the addressable event; the cubit must dedup by author+id. + final transport = DuplicatingOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(1)); + await cubit.close(); + }); + + test('two authors reusing the same lot id are kept apart', () async { + // A non-deduping transport, so both authors' same-id offers reach discover. + final transport = DuplicatingOfferTransport(); + for (final author in ['ab' * 32, 'cd' * 32]) { + await transport.publish(Offer( + id: 'lot-1', // same d-tag, different author + authorPubkeyHex: author, + summary: 'Tomate', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + } + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(2)); + await cubit.close(); + }); + }); + + group('OffersCubit filters', () { + Offer offer(String id, OfferType type, + {String? category, bool organic = false}) => + Offer( + id: id, + authorPubkeyHex: 'ab' * 32, + summary: id, + type: type, + category: category, + approxGeohash: 'sp3e9', + isOrganic: organic, + ); + + Future seeded(List offers) async { + final transport = FakeOfferTransport(); + for (final o in offers) { + await transport.publish(o); + } + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + return cubit; + } + + test('type, category and eco chips narrow the list (ANDed with search)', + () async { + final cubit = await seeded([ + offer('tomate-eco', OfferType.gift, category: 'Solanaceae', organic: true), + offer('tomate-sale', OfferType.sale, category: 'Solanaceae'), + offer('judia-eco', OfferType.gift, category: 'Fabaceae', organic: true), + ]); + + cubit.toggleType(OfferType.gift); + expect(cubit.state.visibleOffers.map((o) => o.id), + containsAll(['tomate-eco', 'judia-eco'])); + expect(cubit.state.visibleOffers, hasLength(2)); + + cubit.toggleCategory('Solanaceae'); + expect(cubit.state.visibleOffers.single.id, 'tomate-eco'); + + cubit.toggleOrganicOnly(); + expect(cubit.state.visibleOffers.single.id, 'tomate-eco'); + + cubit.clearFilters(); + expect(cubit.state.visibleOffers, hasLength(3)); + expect(cubit.state.hasActiveFilter, isFalse); + await cubit.close(); + }); + + test('exposes categories and hasOrganic from the discovered offers', + () async { + final cubit = await seeded([ + offer('a', OfferType.gift, category: 'Solanaceae', organic: true), + offer('b', OfferType.gift, category: 'Fabaceae'), + offer('c', OfferType.gift), + ]); + expect(cubit.state.categories, ['Fabaceae', 'Solanaceae']); // sorted + expect(cubit.state.hasOrganic, isTrue); + await cubit.close(); + }); + + test('chip filters survive a refresh (re-discovery)', () async { + final cubit = await seeded([ + offer('gift', OfferType.gift), + offer('sale', OfferType.sale), + ]); + cubit.toggleType(OfferType.gift); + await cubit.discover('sp3'); // pull-to-refresh + await pumpEventQueue(); + expect(cubit.state.typeFilter, {OfferType.gift}); + expect(cubit.state.visibleOffers.single.id, 'gift'); + await cubit.close(); + }); + }); +} diff --git a/apps/app_seeds/test/state/peer_rating_cubit_test.dart b/apps/app_seeds/test/state/peer_rating_cubit_test.dart new file mode 100644 index 0000000..e12279f --- /dev/null +++ b/apps/app_seeds/test/state/peer_rating_cubit_test.dart @@ -0,0 +1,162 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/state/peer_rating_cubit.dart'; + +/// In-memory [RatingTransport]: rate/retract act as this device's identity, +/// one live rating per (rater, subject) like the Nostr transport. +class FakeRatingTransport implements RatingTransport { + FakeRatingTransport(this.selfId); + final String selfId; + final Map> _bySubject = {}; // subject -> rater + + /// Test helper: record an arbitrary rater→subject rating. + void addRating(String rater, String subject, int stars, + {String comment = ''}) => + (_bySubject[subject] ??= {})[rater] = Rating( + rater: rater, + subject: subject, + stars: stars, + ratedAt: DateTime(2026), + comment: comment, + ); + + @override + Future rate({ + required String subjectPubkey, + required int stars, + String comment = '', + }) async => + addRating(selfId, subjectPubkey, stars, comment: comment); + + @override + Future retract({required String subjectPubkey}) async => + _bySubject[subjectPubkey]?.remove(selfId); + + @override + Future> ratingsOf(String subjectPubkey) async => + List.of(_bySubject[subjectPubkey]?.values ?? const []); + + @override + Future myRatingOf(String subjectPubkey) async => + _bySubject[subjectPubkey]?[selfId]; + + @override + Future close() async {} +} + +/// Minimal trust transport exposing a fixed certification graph, for the +/// circle weighting. +class FakeTrustTransport implements TrustTransport { + final List certs = []; + + void addCert(String issuer, String subject) => certs.add(Certification( + issuer: issuer, + subject: subject, + issuedAt: DateTime(2026), + )); + + @override + Future> allCertifications() async => List.of(certs); + + @override + Future> certifiersOf(String subjectPubkey) async => { + for (final c in certs) + if (c.subject == subjectPubkey) c.issuer, + }; + + @override + Future certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }) async {} + + @override + Future revoke({required String subjectPubkey}) async {} + + @override + Future close() async {} +} + +void main() { + const me = 'me'; + const peer = 'peer'; + + test('aggregates count and average over active ratings', () async { + final ratings = FakeRatingTransport(me) + ..addRating('a', peer, 5) + ..addRating('b', peer, 2); + final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.count, 2); + expect(cubit.state.average, closeTo(3.5, 0.001)); + expect(cubit.state.circleCount, 0); // no trust graph wired + expect(cubit.state.iRated, isFalse); + await cubit.close(); + }); + + test('counts ratings coming from your circle', () async { + final ratings = FakeRatingTransport(me) + ..addRating('friend', peer, 5) // you vouch for 'friend' + ..addRating('stranger', peer, 1); + final trust = FakeTrustTransport()..addCert(me, 'friend'); + final cubit = PeerRatingCubit( + ratings, + peerPubkey: peer, + selfPubkey: me, + trust: trust, + ); + await cubit.load(); + expect(cubit.state.count, 2); + expect(cubit.state.circleCount, 1); + await cubit.close(); + }); + + test('rate publishes then reloads; re-rating replaces', () async { + final ratings = FakeRatingTransport(me); + final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + + await cubit.rate(4, comment: 'Nice seeds'); + expect(cubit.state.myStars, 4); + expect(cubit.state.myComment, 'Nice seeds'); + expect(cubit.state.count, 1); + + await cubit.rate(2); + expect(cubit.state.myStars, 2); + expect(cubit.state.count, 1); // replaced, not piled up + await cubit.close(); + }); + + test('retract removes your rating', () async { + final ratings = FakeRatingTransport(me)..addRating(me, peer, 3); + final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.iRated, isTrue); + + await cubit.retract(); + expect(cubit.state.iRated, isFalse); + expect(cubit.state.count, 0); + await cubit.close(); + }); + + test('never rates self', () async { + final ratings = FakeRatingTransport(me); + final cubit = PeerRatingCubit(ratings, peerPubkey: me, selfPubkey: me); + await cubit.load(); + await cubit.rate(5); + expect(cubit.state.iRated, isFalse); + expect(cubit.state.count, 0); + await cubit.close(); + }); + + test('offline (no transport) never throws', () async { + final cubit = PeerRatingCubit(null, peerPubkey: peer, selfPubkey: me); + expect(cubit.isOnline, isFalse); + await cubit.load(); + await cubit.rate(5); + await cubit.retract(); + expect(cubit.state.loading, isFalse); + await cubit.close(); + }); +} diff --git a/apps/app_seeds/test/state/trust_cubit_test.dart b/apps/app_seeds/test/state/trust_cubit_test.dart new file mode 100644 index 0000000..9dcb1f9 --- /dev/null +++ b/apps/app_seeds/test/state/trust_cubit_test.dart @@ -0,0 +1,128 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/state/trust_cubit.dart'; + +/// In-memory [TrustTransport]: certify/revoke act as this device's identity. +class FakeTrustTransport implements TrustTransport { + FakeTrustTransport(this.selfId); + final String selfId; + final Map> _certs = {}; // subject -> issuers + + /// Test helper: record an arbitrary issuer→subject vouch (to build a graph). + void addCert(String issuer, String subject) => + (_certs[subject] ??= {}).add(issuer); + + @override + Future> certifiersOf(String subjectPubkey) async => + _certs[subjectPubkey] ?? {}; + + @override + Future certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }) async => + addCert(selfId, subjectPubkey); + + @override + Future revoke({required String subjectPubkey}) async => + _certs[subjectPubkey]?.remove(selfId); + + @override + Future> allCertifications() async => [ + for (final entry in _certs.entries) + for (final issuer in entry.value) + Certification( + issuer: issuer, + subject: entry.key, + issuedAt: DateTime(2026), + ), + ]; + + @override + Future close() async {} +} + +void main() { + const me = 'me'; + const peer = 'peer'; + + test('loads certifier count and whether I vouch', () async { + final transport = FakeTrustTransport(me); + await transport.certify(subjectPubkey: peer); // I already vouch + final cubit = TrustCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.certifierCount, 1); + expect(cubit.state.iVouch, isTrue); + expect(cubit.state.loading, isFalse); + await cubit.close(); + }); + + test('toggleVouch certifies then revokes', () async { + final cubit = + TrustCubit(FakeTrustTransport(me), peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.iVouch, isFalse); + + await cubit.toggleVouch(); + expect(cubit.state.iVouch, isTrue); + expect(cubit.state.certifierCount, 1); + + await cubit.toggleVouch(); + expect(cubit.state.iVouch, isFalse); + expect(cubit.state.certifierCount, 0); + await cubit.close(); + }); + + test('a friend-of-a-friend is in your circle; a stranger is not', () async { + final transport = FakeTrustTransport(me) + ..addCert(me, 'friend') // you vouch for a friend + ..addCert('friend', peer) // your friend vouches for the peer + ..addCert('stranger', 'other'); // unrelated to you + + final inCircle = TrustCubit(transport, peerPubkey: peer, selfPubkey: me); + await inCircle.load(); + expect(inCircle.state.knownToYou, isTrue); + expect(inCircle.state.tier, TrustTier.inYourCircle); + await inCircle.close(); + + final outside = TrustCubit(transport, peerPubkey: 'other', selfPubkey: me); + await outside.load(); + expect(outside.state.knownToYou, isFalse); + await outside.close(); + }); + + test('tier falls back to vouched, then unknown', () async { + final vouched = TrustCubit( + FakeTrustTransport(me)..addCert('someone', peer), + peerPubkey: peer, + selfPubkey: me); + await vouched.load(); + expect(vouched.state.tier, TrustTier.vouched); + await vouched.close(); + + final unknown = TrustCubit(FakeTrustTransport(me), + peerPubkey: 'nobody', selfPubkey: me); + await unknown.load(); + expect(unknown.state.tier, TrustTier.unknown); + await unknown.close(); + }); + + test('never vouches for self', () async { + final cubit = + TrustCubit(FakeTrustTransport(me), peerPubkey: me, selfPubkey: me); + await cubit.load(); + await cubit.toggleVouch(); + expect(cubit.state.iVouch, isFalse); + await cubit.close(); + }); + + test('offline (no transport) never throws', () async { + final cubit = TrustCubit(null, peerPubkey: peer, selfPubkey: me); + expect(cubit.isOnline, isFalse); + await cubit.load(); + await cubit.toggleVouch(); + expect(cubit.state.loading, isFalse); + await cubit.close(); + }); +} diff --git a/apps/app_seeds/test/support/test_support.dart b/apps/app_seeds/test/support/test_support.dart index c1e9685..ba39a2f 100644 --- a/apps/app_seeds/test/support/test_support.dart +++ b/apps/app_seeds/test/support/test_support.dart @@ -6,10 +6,12 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/species_repository.dart'; import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/chat_database.dart'; import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/security/secret_store.dart'; import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/app.dart' show materialLocaleFor; import 'package:tane/state/inventory_cubit.dart'; import 'package:tane/state/variety_detail_cubit.dart'; import 'package:tane/ui/variety_detail_screen.dart'; @@ -18,6 +20,9 @@ import 'package:tane/ui/variety_detail_screen.dart'; /// verified separately in the SQLCipher-only security test). AppDatabase newTestDatabase() => AppDatabase(NativeDatabase.memory()); +/// A fresh in-memory chat cache for host tests (unencrypted; see above). +ChatDatabase newTestChatDatabase() => ChatDatabase(NativeDatabase.memory()); + /// Unmounts the widget tree *inside* the test and drains the zero-duration /// timer Drift schedules when its stream subscription is cancelled. Without /// this, flutter_test reports "a Timer is still pending after disposal". Call @@ -69,7 +74,7 @@ Widget wrapScreen({ child: BlocProvider( create: (_) => InventoryCubit(repository), child: MaterialApp( - locale: locale.flutterLocale, + locale: materialLocaleFor(locale.flutterLocale), supportedLocales: AppLocaleUtils.supportedLocales, localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, @@ -103,7 +108,7 @@ Widget wrapDetail({ child: BlocProvider( create: (_) => VarietyDetailCubit(repository, varietyId), child: MaterialApp( - locale: locale.flutterLocale, + locale: materialLocaleFor(locale.flutterLocale), supportedLocales: AppLocaleUtils.supportedLocales, localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, diff --git a/apps/app_seeds/test/ui/about_screen_test.dart b/apps/app_seeds/test/ui/about_screen_test.dart index ec4b6dc..cf64c43 100644 --- a/apps/app_seeds/test/ui/about_screen_test.dart +++ b/apps/app_seeds/test/ui/about_screen_test.dart @@ -17,7 +17,7 @@ void main() { setUp(() { PackageInfo.setMockInitialValues( - appName: 'Tanemaki', + appName: 'Tane', packageName: 'org.comunes.tane', version: '0.1.0', buildNumber: '1', @@ -38,7 +38,7 @@ void main() { await tester.tap(find.text('About')); // drawer entry await tester.pumpAndSettle(); - expect(find.text('種まき'), findsOneWidget); + expect(find.text('種'), findsOneWidget); expect(find.textContaining('digital Plantare'), findsOneWidget); await disposeTree(tester); }); @@ -57,12 +57,12 @@ void main() { await tester.pumpAndSettle(); // The backup section pushed the About tile below the fold. - await tester.scrollUntilVisible(find.text('About Tanemaki'), 200); - await tester.tap(find.text('About Tanemaki')); + await tester.scrollUntilVisible(find.text('About Tane'), 200); + await tester.tap(find.text('About Tane')); await tester.pumpAndSettle(); // Explanation text and kanji are shown above the fold. - expect(find.text('種まき'), findsOneWidget); + expect(find.text('種'), findsOneWidget); expect(find.textContaining('helps people and collectives'), findsOneWidget); expect(find.textContaining('digital Plantare'), findsOneWidget); @@ -87,8 +87,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('About Tanemaki'), 200); - await tester.tap(find.text('About Tanemaki')); + await tester.scrollUntilVisible(find.text('About Tane'), 200); + await tester.tap(find.text('About Tane')); await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('Open-source licenses'), 200); diff --git a/apps/app_seeds/test/ui/asturian_locale_test.dart b/apps/app_seeds/test/ui/asturian_locale_test.dart new file mode 100644 index 0000000..2f0fe07 --- /dev/null +++ b/apps/app_seeds/test/ui/asturian_locale_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/app.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/locale_store.dart'; +import 'package:tane/ui/settings_screen.dart'; + +import '../support/test_support.dart'; + +/// Asturian (`ast`) is a first-class language, but Flutter ships no +/// Material/Cupertino localizations for it. The app carries the honest `ast` +/// locale for its own strings and borrows Spanish for the framework chrome +/// (see [materialLocaleFor]). These tests pin both halves of that contract. +void main() { + testWidgets('picking Asturian switches app text to Asturian, keeps Material ' + 'chrome resolvable in Spanish, and persists the choice', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = LocaleStore(InMemorySecretStore()); + late Locale materialLocale; + + await tester.pumpWidget( + TranslationProvider( + child: Builder( + builder: (context) { + final appLocale = TranslationProvider.of(context).flutterLocale; + return MaterialApp( + // Mirrors app.dart: the framework localizations follow the mapped + // locale, the app's own strings follow slang. + locale: materialLocaleFor(appLocale), + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Builder( + builder: (context) { + // Resolving these must never throw for the Asturian locale. + materialLocale = Localizations.localeOf(context); + MaterialLocalizations.of(context); + return SettingsScreen(localeStore: store); + }, + ), + ); + }, + ), + ), + ); + + // Starts in English. + expect(find.text('Language'), findsOneWidget); + expect(materialLocale.languageCode, 'en'); + + // Open the language picker, then choose Asturian. + await tester.tap(find.byKey(const Key('settings.language'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Asturianu')); + await tester.pumpAndSettle(); + + // App strings are now Asturian… + expect(find.text('Llingua'), findsOneWidget); + // …the framework chrome fell back to Spanish (not the unsupported `ast`)… + expect(materialLocale.languageCode, 'es'); + // …and the choice was remembered for next launch. + expect(await store.saved(), AppLocale.ast); + }); + + test('materialLocaleFor maps Asturian to Spanish and passes others through', + () { + expect(materialLocaleFor(const Locale('ast')), const Locale('es')); + expect(materialLocaleFor(const Locale('es')), const Locale('es')); + expect(materialLocaleFor(const Locale('en')), const Locale('en')); + expect(materialLocaleFor(const Locale('pt')), const Locale('pt')); + }); +} diff --git a/apps/app_seeds/test/ui/auto_backup_gate_test.dart b/apps/app_seeds/test/ui/auto_backup_gate_test.dart new file mode 100644 index 0000000..6484e62 --- /dev/null +++ b/apps/app_seeds/test/ui/auto_backup_gate_test.dart @@ -0,0 +1,86 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/security/secure_key_store.dart'; +import 'package:tane/services/auto_backup_service.dart'; +import 'package:tane/services/auto_backup_store.dart'; +import 'package:tane/services/export_import_service.dart'; +import 'package:tane/services/file_service.dart'; +import 'package:tane/ui/auto_backup_gate.dart'; + +import '../support/test_support.dart'; + +class _NoFiles implements FileService { + @override + Future saveFile({ + required String suggestedName, + required Uint8List bytes, + }) async => null; + + @override + Future pickFileBytes({List? allowedExtensions}) async => + null; +} + +/// Counts how many times the lifecycle asked for a backup. +class _SpyAutoBackup extends AutoBackupService { + _SpyAutoBackup(AppDatabase db) + : super( + exporter: ExportImportService( + repository: newTestRepository(db), + files: _NoFiles(), + keys: SecureKeyStore(store: InMemorySecretStore()), + ), + store: AutoBackupStore(InMemorySecretStore()), + directory: () async => throw UnimplementedError(), + ); + + int calls = 0; + + @override + Future runIfDue({Duration interval = const Duration(days: 7)}) async { + calls++; + return false; + } +} + +void main() { + late AppDatabase db; + + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + testWidgets('runs a backup on startup', (tester) async { + final spy = _SpyAutoBackup(db); + await tester.pumpWidget( + AutoBackupGate(service: spy, child: const SizedBox()), + ); + await tester.pump(); // let the post-frame callback fire + + expect(spy.calls, 1); + }); + + testWidgets('runs a backup again when the app is hidden', (tester) async { + final spy = _SpyAutoBackup(db); + await tester.pumpWidget( + AutoBackupGate(service: spy, child: const SizedBox()), + ); + await tester.pump(); // startup backup + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + + expect(spy.calls, 2); + }); + + testWidgets('does nothing when no service is provided', (tester) async { + await tester.pumpWidget( + const AutoBackupGate(child: Text('ok', textDirection: TextDirection.ltr)), + ); + await tester.pump(); + + expect(find.text('ok'), findsOneWidget); + }); +} diff --git a/apps/app_seeds/test/ui/backup_section_test.dart b/apps/app_seeds/test/ui/backup_section_test.dart index da2047a..8b97688 100644 --- a/apps/app_seeds/test/ui/backup_section_test.dart +++ b/apps/app_seeds/test/ui/backup_section_test.dart @@ -9,9 +9,12 @@ import 'package:tane/data/export_import/inventory_json_codec.dart'; import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/security/secure_key_store.dart'; +import 'package:tane/services/auto_backup_service.dart'; +import 'package:tane/services/auto_backup_store.dart'; import 'package:tane/services/export_import_service.dart'; import 'package:tane/services/file_service.dart'; import 'package:tane/services/recovery_sheet_service.dart'; +import 'package:tane/app.dart'; import 'package:tane/ui/backup_section.dart'; import 'package:tane/ui/settings_screen.dart'; @@ -106,12 +109,12 @@ void main() { expect(find.text('Copy saved'), findsOneWidget); }); - testWidgets('saving a backup writes a sealed .tanemaki file', (tester) async { + testWidgets('saving a backup writes a sealed .tane file', (tester) async { await tester.pumpWidget(section()); await tester.tap(find.text('Save a backup')); await tester.pumpAndSettle(); - expect(files.savedName, endsWith('.tanemaki')); + expect(files.savedName, endsWith('.tane')); expect(BackupBox.looksSealed(files.savedBytes!), isTrue); expect(find.text('Copy saved'), findsOneWidget); }); @@ -207,7 +210,7 @@ void main() { await tester.pumpAndSettle(); expect( - find.text('This file could not be read as a Tanemaki copy'), + find.text('This file could not be read as a Tane copy'), findsOneWidget, ); }); @@ -225,11 +228,106 @@ void main() { await tester.tap(find.byKey(const Key('backup.recoverySave'))); await tester.pumpAndSettle(); - expect(files.savedName, 'tanemaki-recovery.pdf'); + expect(files.savedName, 'tane-recovery.pdf'); expect(String.fromCharCodes(files.savedBytes!.take(5)), '%PDF-'); // The seed itself never appears in the clear in the PDF stream metadata; // the QR encodes the human code, which IS the secret — that's the point // of the sheet. Just assert it saved and confirmed. expect(find.text('Copy saved'), findsOneWidget); }); + + testWidgets('tapping the automatic-backups line makes a copy now', ( + tester, + ) async { + final spy = _SpyAutoBackup(db); + await tester.pumpWidget( + wrap( + Scaffold( + body: BackupSection(service: service, sheet: sheet, autoBackup: spy), + ), + ), + ); + await tester.pump(); // resolve the "last copy" future + + await tester.tap(find.text('Automatic backups')); + await tester.pumpAndSettle(); + + expect(spy.calls, 1); + expect(find.text('Copy saved'), findsOneWidget); + }); + + testWidgets('the last-backup date renders under Asturian without an ' + 'invalid-locale crash', (tester) async { + // Asturian (`ast`) has no `intl` date symbols; formatting the "last copy" + // date used to throw "Invalid locale ast". The framework locale is mapped + // to Spanish, so the date must format through that instead. + LocaleSettings.setLocaleSync(AppLocale.ast); + addTearDown(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + locale: materialLocaleFor(const Locale('ast')), + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: BackupSection( + service: service, + sheet: sheet, + autoBackup: _DatedAutoBackup(db), + ), + ), + ), + ), + ); + await tester.pump(); // resolve the "last copy" future + + // The Asturian tile (and its Spanish-formatted date subtitle) built fine. + expect(find.text('Copies automátiques'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +/// Counts explicit "back up now" taps without touching the disk. +class _SpyAutoBackup extends AutoBackupService { + _SpyAutoBackup(AppDatabase db) + : super( + exporter: ExportImportService( + repository: newTestRepository(db), + files: FakeFileService(), + keys: SecureKeyStore(store: InMemorySecretStore()), + ), + store: AutoBackupStore(InMemorySecretStore()), + directory: () async => throw UnimplementedError(), + ); + + int calls = 0; + + @override + Future backupNow() async { + calls++; + return true; + } +} + +/// Reports a fixed "last copy" date so the auto-backup subtitle formats a real +/// date — the path that crashed under Asturian. +class _DatedAutoBackup extends AutoBackupService { + _DatedAutoBackup(AppDatabase db) + : super( + exporter: ExportImportService( + repository: newTestRepository(db), + files: FakeFileService(), + keys: SecureKeyStore(store: InMemorySecretStore()), + ), + store: AutoBackupStore(InMemorySecretStore()), + directory: () async => throw UnimplementedError(), + ); + + @override + Future lastBackupAt() async => DateTime.utc(2026, 3, 14); } diff --git a/apps/app_seeds/test/ui/bootstrap_splash_test.dart b/apps/app_seeds/test/ui/bootstrap_splash_test.dart new file mode 100644 index 0000000..a204bae --- /dev/null +++ b/apps/app_seeds/test/ui/bootstrap_splash_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/bootstrap.dart'; +import 'package:tane/i18n/strings.g.dart'; + +void main() { + setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + testWidgets('shows a spinner and no error while booting', (tester) async { + await tester.pumpWidget( + TranslationProvider(child: const BootstrapSplash(error: false)), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text(t.bootstrap.failed), findsNothing); + expect(find.text(t.bootstrap.retry), findsNothing); + }); + + testWidgets('on error shows the message and a working retry', (tester) async { + var retries = 0; + await tester.pumpWidget( + TranslationProvider( + child: BootstrapSplash(error: true, onRetry: () => retries++), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text(t.bootstrap.failed), findsOneWidget); + + await tester.tap(find.text(t.bootstrap.retry)); + expect(retries, 1); + }); +} diff --git a/apps/app_seeds/test/ui/calendar_screen_test.dart b/apps/app_seeds/test/ui/calendar_screen_test.dart new file mode 100644 index 0000000..95d669e --- /dev/null +++ b/apps/app_seeds/test/ui/calendar_screen_test.dart @@ -0,0 +1,58 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/crop_calendar.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/ui/calendar_screen.dart'; + +import '../support/test_support.dart'; + +/// The "what's due this month" screen groups varieties by phase for the picked +/// month, and switching months changes what's shown. +void main() { + testWidgets('shows a variety under Sow for its recorded month, hidden in others', + (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Rúcula'); + await repo.updateVariety(id: id, sowMonths: Value(monthsToMask([3]))); + + await tester.pumpWidget(wrapScreen( + repository: repo, + locale: AppLocale.es, + child: const CalendarScreen(initialMonth: 3), + )); + await tester.pumpAndSettle(); + + expect(find.byKey(Key('calendar.entry.$id')), findsOneWidget); + expect(find.text('Rúcula'), findsOneWidget); + + // Jump to a month it doesn't sow in → empty state, entry gone. + await tester.tap(find.byKey(const Key('calendar.month.6'))); + await tester.pumpAndSettle(); + expect(find.byKey(Key('calendar.entry.$id')), findsNothing); + + await disposeTree(tester); + }); + + testWidgets('an empty month shows the "nothing" message', (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Tomate'); + await repo.updateVariety(id: id, sowMonths: Value(monthsToMask([4]))); + + await tester.pumpWidget(wrapScreen( + repository: repo, + locale: AppLocale.es, + child: const CalendarScreen(initialMonth: 1), // nothing in January + )); + await tester.pumpAndSettle(); + + expect(find.byKey(Key('calendar.entry.$id')), findsNothing); + expect(find.byIcon(Icons.event_available), findsOneWidget); + + await disposeTree(tester); + }); +} diff --git a/apps/app_seeds/test/ui/category_palette_contrast_test.dart b/apps/app_seeds/test/ui/category_palette_contrast_test.dart new file mode 100644 index 0000000..0c4f379 --- /dev/null +++ b/apps/app_seeds/test/ui/category_palette_contrast_test.dart @@ -0,0 +1,53 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/ui/category_palette.dart'; + +/// Chip labels are drawn in [Swatch.ink] over [Swatch.fill]; that pairing must +/// clear WCAG AA for normal text (≥4.5:1), like the rest of the palette +/// (see theme_contrast_test.dart). Locks the tuned inks against regressions. +void main() { + double lin(double c) => + c <= 0.03928 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4).toDouble(); + double luminance(Color c) => + 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b); + double contrast(Color a, Color b) { + final la = luminance(a), lb = luminance(b); + final hi = math.max(la, lb), lo = math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); + } + + test('every category swatch label meets AA on its fill', () { + // Cover the whole palette by probing many distinct category names. + for (var i = 0; i < 60; i++) { + final s = categorySwatch('family-$i'); + expect(contrast(s.ink, s.fill), greaterThanOrEqualTo(4.5), + reason: 'category-$i ink on fill'); + } + }); + + test('every lot-form swatch label meets AA on its fill', () { + for (final type in LotType.values) { + final s = lotTypeSwatch(type); + expect(contrast(s.ink, s.fill), greaterThanOrEqualTo(4.5), + reason: '$type ink on fill'); + } + }); + + // A *selected* swatch chip fills solid with its ink and draws white content + // (label, icon, checkmark). That pairing must clear AA too. + test('white content meets AA on every selected (ink-filled) swatch', () { + for (var i = 0; i < 60; i++) { + final s = categorySwatch('family-$i'); + expect(contrast(Colors.white, s.ink), greaterThanOrEqualTo(4.5), + reason: 'white on category-$i ink'); + } + for (final type in LotType.values) { + final s = lotTypeSwatch(type); + expect(contrast(Colors.white, s.ink), greaterThanOrEqualTo(4.5), + reason: 'white on $type ink'); + } + }); +} diff --git a/apps/app_seeds/test/ui/category_palette_test.dart b/apps/app_seeds/test/ui/category_palette_test.dart new file mode 100644 index 0000000..04ee845 --- /dev/null +++ b/apps/app_seeds/test/ui/category_palette_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/ui/category_palette.dart'; + +/// Category/form colours must be STABLE (same name → same tonality every +/// launch) so a family's chip and list header always match. +void main() { + test('a category always maps to the same swatch', () { + final a = categorySwatch('Solanáceas'); + final b = categorySwatch('Solanáceas'); + expect(a.fill, b.fill); + expect(a.ink, b.ink); + }); + + test('different families generally get different tonalities', () { + final names = [ + 'Solanáceas', + 'Leguminosas', + 'Cucurbitáceas', + 'Brasicáceas', + 'Asteráceas', + 'Amarantáceas', + ]; + final inks = {for (final n in names) categorySwatch(n).ink}; + // Not required to be all-distinct (palette wraps), but a fixed input set + // must not collapse to a single colour. + expect(inks.length, greaterThan(1)); + }); + + test('every lot form has its own swatch', () { + for (final type in LotType.values) { + final s = lotTypeSwatch(type); + expect(s.fill, isNotNull); + expect(s.ink, isNotNull); + } + }); +} diff --git a/apps/app_seeds/test/ui/chat_message_list_test.dart b/apps/app_seeds/test/ui/chat_message_list_test.dart new file mode 100644 index 0000000..2eea2a2 --- /dev/null +++ b/apps/app_seeds/test/ui/chat_message_list_test.dart @@ -0,0 +1,170 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/chat_timeline.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/state/messages_cubit.dart'; +import 'package:tane/ui/chat_screen.dart'; +import 'package:tane/ui/peer_avatar.dart'; + +/// In-memory [MessageTransport]: lets a test push inbox messages. (Same shape +/// as the one in messages_cubit_test.dart.) +class _FakeMessageTransport implements MessageTransport { + final StreamController _inbox = + StreamController.broadcast(); + + void receive(PrivateMessage message) => _inbox.add(message); + + @override + Future send({required String toPubkey, required String text}) async {} + + @override + Stream inbox() => _inbox.stream; + + @override + Future close() async => _inbox.close(); +} + +void main() { + const peer = 'aa'; + const me = 'bb'; + + PrivateMessage msg(String from, String text, int minute) => PrivateMessage( + fromPubkey: from, + text: text, + at: DateTime(2026, 1, 1, 0, minute), + ); + + Widget host(MessagesCubit cubit) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: const ChatMessageList(), + ), + ), + ), + ); + } + + testWidgets('renders oldest at top, newest at the bottom of the viewport', ( + tester, + ) async { + final transport = _FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + addTearDown(cubit.close); + + transport.receive(msg(peer, 'first', 0)); + transport.receive(msg(peer, 'second', 1)); + transport.receive(msg(peer, 'third', 2)); + await tester.pumpWidget(host(cubit)); + await tester.pump(); // let the stream events flush into the cubit + + // Bottom-anchored: the newest ('third') sits lower than the oldest. + final firstY = tester.getTopLeft(find.text('first')).dy; + final thirdY = tester.getTopLeft(find.text('third')).dy; + expect(thirdY, greaterThan(firstY)); + + // One day separator (all three are the same day) and a per-bubble time. + expect(find.byKey(const Key('chat.daySeparator')), findsOneWidget); + expect( + find.text(chatBubbleTime(DateTime(2026, 1, 1, 0, 0), 'en')), + findsOneWidget, + ); + }); + + testWidgets('a separator is inserted per calendar day', (tester) async { + final transport = _FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + addTearDown(cubit.close); + + transport.receive( + PrivateMessage( + fromPubkey: peer, + text: 'day one', + at: DateTime(2026, 1, 1, 9), + ), + ); + transport.receive( + PrivateMessage( + fromPubkey: peer, + text: 'day two', + at: DateTime(2026, 1, 2, 9), + ), + ); + await tester.pumpWidget(host(cubit)); + await tester.pump(); + + expect(find.byKey(const Key('chat.daySeparator')), findsNWidgets(2)); + }); + + testWidgets('each sender gets a distinctly coloured avatar', (tester) async { + final transport = _FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + addTearDown(cubit.close); + + transport.receive(msg(peer, 'from peer', 0)); + await cubit.send('from me'); // optimistically mine (fromPubkey == me) + await tester.pumpWidget(host(cubit)); + await tester.pump(); + + // Both sides carry an avatar, and the two people are different colours — + // so you can tell who said what at a glance. + expect(find.byType(PeerAvatar), findsNWidgets(2)); + final discs = tester + .widgetList(find.byType(CircleAvatar)) + .toList(); + expect(discs, hasLength(2)); + expect(discs.first.backgroundColor, isNot(discs.last.backgroundColor)); + }); + + testWidgets('a new message lands in view at the bottom, not below the fold', ( + tester, + ) async { + final transport = _FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + addTearDown(cubit.close); + + // Fill past a single screen so a naive top-anchored list would push the + // newest message off-screen. + for (var i = 0; i < 40; i++) { + transport.receive(msg(peer, 'line $i', i)); + } + await tester.pumpWidget(host(cubit)); + await tester.pump(); + + transport.receive(msg(peer, 'brand new', 41)); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + + // The just-arrived message is visible without any manual scroll. + expect(find.text('brand new'), findsOneWidget); + }); + + testWidgets('shows the empty note when there are no messages', ( + tester, + ) async { + final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me); + addTearDown(cubit.close); + await tester.pumpWidget(host(cubit)); + await tester.pump(); + expect(find.byType(ListView), findsNothing); + }); +} diff --git a/apps/app_seeds/test/ui/chat_screen_test.dart b/apps/app_seeds/test/ui/chat_screen_test.dart new file mode 100644 index 0000000..76f8bef --- /dev/null +++ b/apps/app_seeds/test/ui/chat_screen_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/ui/chat_screen.dart'; + +import '../support/test_support.dart'; + +void main() { + testWidgets('with no relay configured, chat shows the offline note', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: don't hit the network + final connection = SocialConnection(social: social, settings: settings); + LocaleSettings.setLocaleSync(AppLocale.en); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: ChatScreen( + social: social, + connection: connection, + peerPubkey: 'ab' * 32, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Set up sharing to send messages'), findsOneWidget); + }); +} diff --git a/apps/app_seeds/test/ui/edge_fade_test.dart b/apps/app_seeds/test/ui/edge_fade_test.dart new file mode 100644 index 0000000..a468cf1 --- /dev/null +++ b/apps/app_seeds/test/ui/edge_fade_test.dart @@ -0,0 +1,135 @@ +import 'package:tane/ui/edge_fade.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // A horizontal strip of [count] fixed-width boxes inside an EdgeFade-wrapped + // SingleChildScrollView, laid out at the default 800px test surface width. + Widget harness({ + required int count, + TextDirection direction = TextDirection.ltr, + ScrollController? controller, + }) { + return Directionality( + textDirection: direction, + child: EdgeFade( + child: SingleChildScrollView( + controller: controller, + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < count; i++) + const SizedBox(width: 100, height: 40), + ], + ), + ), + ), + ); + } + + EdgeFadeState state(WidgetTester tester) => + tester.state(find.byType(EdgeFade)); + + testWidgets('no fade on either edge when content fits', (tester) async { + await tester.pumpWidget(harness(count: 3)); // 300px < 800px + await tester.pump(); // let the post-layout metrics notification land + expect(state(tester).fadeLeft, isFalse); + expect(state(tester).fadeRight, isFalse); + expect(find.byIcon(Icons.chevron_left), findsNothing); + expect(find.byIcon(Icons.chevron_right), findsNothing); + }); + + testWidgets('a trailing chevron cue appears when the row overflows', + (tester) async { + await tester.pumpWidget(harness(count: 20)); + await tester.pump(); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + expect(find.byIcon(Icons.chevron_left), findsNothing); + }); + + testWidgets('LTR: at start only the trailing (right) edge fades', + (tester) async { + await tester.pumpWidget(harness(count: 20)); // 2000px > 800px + await tester.pump(); + expect(state(tester).fadeLeft, isFalse); + expect(state(tester).fadeRight, isTrue); + }); + + testWidgets('LTR: in the middle both edges fade', (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(harness(count: 20, controller: controller)); + await tester.pump(); + controller.jumpTo(600); + await tester.pump(); + expect(state(tester).fadeLeft, isTrue); + expect(state(tester).fadeRight, isTrue); + }); + + testWidgets('LTR: at the end only the leading (left) edge fades', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(harness(count: 20, controller: controller)); + await tester.pump(); + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pump(); + expect(state(tester).fadeLeft, isTrue); + expect(state(tester).fadeRight, isFalse); + }); + + testWidgets('RTL: at start only the LEFT edge fades (mirrored)', + (tester) async { + await tester + .pumpWidget(harness(count: 20, direction: TextDirection.rtl)); + await tester.pump(); + expect(state(tester).fadeLeft, isTrue); + expect(state(tester).fadeRight, isFalse); + }); + + testWidgets('RTL: at the end only the RIGHT edge fades (mirrored)', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(harness( + count: 20, + direction: TextDirection.rtl, + controller: controller, + )); + await tester.pump(); + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pump(); + expect(state(tester).fadeLeft, isFalse); + expect(state(tester).fadeRight, isTrue); + }); + + testWidgets('RTL: no fade when content fits', (tester) async { + await tester.pumpWidget(harness(count: 3, direction: TextDirection.rtl)); + await tester.pump(); + expect(state(tester).fadeLeft, isFalse); + expect(state(tester).fadeRight, isFalse); + }); + + testWidgets('fades clear when content shrinks to fit', (tester) async { + await tester.pumpWidget(harness(count: 20)); + await tester.pump(); + expect(state(tester).fadeRight, isTrue); + + await tester.pumpWidget(harness(count: 3)); + await tester.pump(); + expect(state(tester).fadeLeft, isFalse); + expect(state(tester).fadeRight, isFalse); + }); + + testWidgets('scrolling by drag updates the fades', (tester) async { + await tester.pumpWidget(harness(count: 20)); + await tester.pump(); + expect(state(tester).fadeLeft, isFalse); + + await tester.drag( + find.byType(SingleChildScrollView), const Offset(-300, 0)); + await tester.pump(); + expect(state(tester).fadeLeft, isTrue); + expect(state(tester).fadeRight, isTrue); + }); +} diff --git a/apps/app_seeds/test/ui/favorites_screen_test.dart b/apps/app_seeds/test/ui/favorites_screen_test.dart new file mode 100644 index 0000000..20c1b1f --- /dev/null +++ b/apps/app_seeds/test/ui/favorites_screen_test.dart @@ -0,0 +1,77 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/saved_offers_store.dart'; +import 'package:tane/ui/favorites_screen.dart'; + +import '../support/test_support.dart'; + +Widget _wrap(Widget child) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ), + ); +} + +Offer _offer(String id) => Offer( + id: id, + authorPubkeyHex: 'ab', + summary: 'Tomate $id', + type: OfferType.gift, + approxGeohash: 'u09', +); + +void main() { + testWidgets('shows the empty state when nothing is saved', (tester) async { + final store = SavedOffersStore(InMemorySecretStore()); + // No connection → no relay check, just the local snapshots. + await tester.pumpWidget(_wrap(FavoritesScreen(savedOffers: store))); + await tester.pumpAndSettle(); + + expect( + find.text('No favorites yet. Save offers you like from the market.'), + findsOneWidget, + ); + }); + + testWidgets('lists saved offers newest-first', (tester) async { + final store = SavedOffersStore(InMemorySecretStore()); + await store.save(_offer('old'), savedAt: 100); + await store.save(_offer('new'), savedAt: 200); + + await tester.pumpWidget(_wrap(FavoritesScreen(savedOffers: store))); + await tester.pumpAndSettle(); + + expect(find.text('Tomate new'), findsOneWidget); + expect(find.text('Tomate old'), findsOneWidget); + final newY = tester.getTopLeft(find.text('Tomate new')).dy; + final oldY = tester.getTopLeft(find.text('Tomate old')).dy; + expect(newY, lessThan(oldY)); // newest on top + }); + + testWidgets('the heart removes a favorite', (tester) async { + final store = SavedOffersStore(InMemorySecretStore()); + await store.save(_offer('1'), savedAt: 100); + + await tester.pumpWidget(_wrap(FavoritesScreen(savedOffers: store))); + await tester.pumpAndSettle(); + expect(find.text('Tomate 1'), findsOneWidget); + + await tester.tap(find.byKey(const Key('favorites.remove.ab.1'))); + await tester.pumpAndSettle(); + + expect(find.text('Tomate 1'), findsNothing); + expect(await store.isSaved('ab:1'), isFalse); + }); +} diff --git a/apps/app_seeds/test/ui/handover_sheet_test.dart b/apps/app_seeds/test/ui/handover_sheet_test.dart new file mode 100644 index 0000000..f00214a --- /dev/null +++ b/apps/app_seeds/test/ui/handover_sheet_test.dart @@ -0,0 +1,151 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + testWidgets('the detail screen offers ONE hand-over door, no separate ' + 'sale/plantare buttons', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail.handover')), findsOneWidget); + expect(find.byKey(const Key('detail.addSale')), findsNothing); + expect(find.byKey(const Key('detail.addPlantare')), findsNothing); + await disposeTree(tester); + }); + + testWidgets('giving all with payment and promise records the three pieces ' + 'in one save', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + final lotId = await repo.addLot( + varietyId: id, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + offerStatus: OfferStatus.sell, + priceAmount: 3, + priceCurrency: '€', + ); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.handover'))); + await tester.pumpAndSettle(); + + // "All of it" is the default — one tap covers the whole-plant case. + final allChip = tester.widget( + find.byKey(const Key('handover.allOfIt')), + ); + expect(allChip.selected, isTrue); + + await tester.enterText( + find.byKey(const Key('handover.counterparty')), + 'María', + ); + + await tester.tap(find.byKey(const Key('handover.addPayment'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('handover.amount')), '2,5'); + await tester.tap(find.byKey(const Key('handover.currencyChip.Ğ1'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('handover.addPromise'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('handover.owed')), + 'un puñado', + ); + + await tester.ensureVisible(find.byKey(const Key('handover.save'))); + await tester.tap(find.byKey(const Key('handover.save'))); + await tester.pumpAndSettle(); + + // One-shot .get() reads (NOT watch().first): a Drift stream's `.first` + // never completes under the widget-test fake-async clock, so awaiting it + // in a testWidgets body hangs the whole test to the 10-min hard cap. A + // plain SELECT resolves fine (same as the movements/lot reads below). + final sales = await (db.select( + db.sales, + )..where((s) => s.isDeleted.equals(false))).get(); + expect(sales.single.direction, SaleDirection.iSold); + expect(sales.single.amount, 2.5); + expect(sales.single.currency, 'Ğ1'); + expect(sales.single.counterparty, 'María'); + + final plantares = await (db.select( + db.plantares, + )..where((p) => p.isDeleted.equals(false))).get(); + expect(plantares.single.direction, PlantareDirection.owedToMe); + expect(plantares.single.owedDescription, 'un puñado'); + + final lot = await (db.select( + db.lots, + )..where((l) => l.id.equals(lotId))).getSingle(); + expect(lot.quantityPrecise, 0); + expect(lot.offerStatus, OfferStatus.private); + expect(lot.priceAmount, isNull); + + final movements = await db.select(db.movements).get(); + expect(movements.single.type, MovementType.given); + expect(movements.single.plantareId, plantares.single.id); + await disposeTree(tester); + }); + + testWidgets('receiving hides the how-much choice and mirrors directions', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + await repo.addLot(varietyId: id); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.handover'))); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const Key('handover.direction.iReceived')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('handover.allOfIt')), findsNothing); + + await tester.ensureVisible(find.byKey(const Key('handover.save'))); + await tester.tap(find.byKey(const Key('handover.save'))); + await tester.pumpAndSettle(); + + final movements = await db.select(db.movements).get(); + expect(movements.single.type, MovementType.received); + await disposeTree(tester); + }); +} diff --git a/apps/app_seeds/test/ui/hide_when_keyboard_open_test.dart b/apps/app_seeds/test/ui/hide_when_keyboard_open_test.dart new file mode 100644 index 0000000..1123d5f --- /dev/null +++ b/apps/app_seeds/test/ui/hide_when_keyboard_open_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/ui/chat_screen.dart'; + +void main() { + Widget host({required double keyboardInset}) => MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: keyboardInset)), + child: const Directionality( + textDirection: TextDirection.ltr, + child: HideWhenKeyboardOpen(child: Text('banner')), + ), + ); + + testWidgets('shows its child when the keyboard is closed', (tester) async { + await tester.pumpWidget(host(keyboardInset: 0)); + expect(find.text('banner'), findsOneWidget); + }); + + testWidgets('hides its child when the keyboard is open', (tester) async { + await tester.pumpWidget(host(keyboardInset: 300)); + expect(find.text('banner'), findsNothing); + }); +} diff --git a/apps/app_seeds/test/ui/home_screen_test.dart b/apps/app_seeds/test/ui/home_screen_test.dart index 9e386e2..d26b92a 100644 --- a/apps/app_seeds/test/ui/home_screen_test.dart +++ b/apps/app_seeds/test/ui/home_screen_test.dart @@ -23,13 +23,13 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Your inventory'), findsOneWidget); - expect(find.text('Open market'), findsOneWidget); + expect(find.text('Market'), findsOneWidget); expect(find.text('COMING SOON'), findsOneWidget); // market is Block 2 // Redesign copy: tagline + the two destination subtitles. expect(find.text('Share and grow local seeds'), findsOneWidget); expect(find.text('Manage your seeds'), findsOneWidget); - expect(find.text('Browse and exchange'), findsOneWidget); + expect(find.text('Discover and share seeds nearby'), findsOneWidget); await tester.tap(find.byKey(const Key('home.inventory'))); await tester.pumpAndSettle(); @@ -59,7 +59,8 @@ void main() { await disposeTree(tester); }); - testWidgets('drawer header returns to home', (tester) async { + testWidgets('inventory is a spoke: back arrow, no drawer, returns to home', + (tester) async { LocaleSettings.setLocaleSync(AppLocale.en); final db = newTestDatabase(); addTearDown(db.close); @@ -67,20 +68,19 @@ void main() { await tester.pumpWidget(app(db)); await tester.pumpAndSettle(); - // Navigate away from home first. + // Push into inventory off the home hub. await tester.tap(find.byKey(const Key('home.inventory'))); await tester.pumpAndSettle(); expect(find.text('No seeds yet. Tap + to add your first.'), findsOneWidget); - // Open the drawer and tap the Tanemaki brand header. - await tester.tap(find.byIcon(Icons.menu)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Tanemaki')); + // The spoke shows a back arrow, not a hamburger — coherent with the market. + expect(find.byIcon(Icons.menu), findsNothing); + await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); // Back on the home menu. expect(find.text('Your inventory'), findsOneWidget); - expect(find.text('Open market'), findsOneWidget); + expect(find.text('Market'), findsOneWidget); await disposeTree(tester); }); diff --git a/apps/app_seeds/test/ui/inventory_list_screen_test.dart b/apps/app_seeds/test/ui/inventory_list_screen_test.dart index 4473f13..47932e7 100644 --- a/apps/app_seeds/test/ui/inventory_list_screen_test.dart +++ b/apps/app_seeds/test/ui/inventory_list_screen_test.dart @@ -35,6 +35,23 @@ void main() { await disposeTree(tester); }); + testWidgets('a search with no matches reads "no matches", not "no seeds yet"', + (tester) async { + final repo = newTestRepository(db); + await repo.addQuickVariety(label: 'Tomate', category: 'Solanaceae'); + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('inventory.search')), 'zzzz'); + await tester.pumpAndSettle(); + + expect(find.text('No seeds match your filters.'), findsOneWidget); + expect(find.text('No seeds yet. Tap + to add your first.'), findsNothing); + await disposeTree(tester); + }); + testWidgets( 'renders seeds from the repository grouped under their category', (tester) async { @@ -219,4 +236,66 @@ void main() { await disposeTree(tester); }, ); + + testWidgets('the label toolbar enters selection and Select all enables print', ( + tester, + ) async { + final repo = newTestRepository(db); + await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae'); + await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae'); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('inventory.selectLabels'))); + await tester.pumpAndSettle(); + + // Nothing picked yet: the count reads zero and print is disabled. + expect(find.text('0 selected'), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('inventory.selection.print'))) + .onPressed, + isNull, + ); + + await tester.tap(find.byKey(const Key('inventory.selection.selectAll'))); + await tester.pumpAndSettle(); + + expect(find.text('2 selected'), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('inventory.selection.print'))) + .onPressed, + isNotNull, + ); + + // Closing the contextual bar leaves selection mode. + await tester.tap(find.byKey(const Key('inventory.selection.close'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('inventory.selection.print')), findsNothing); + await disposeTree(tester); + }); + + testWidgets('long-pressing a tile enters selection with it picked', ( + tester, + ) async { + final repo = newTestRepository(db); + await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae'); + await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae'); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Tomato')); + await tester.pumpAndSettle(); + + expect(find.text('1 selected'), findsOneWidget); + expect(find.byType(Checkbox), findsNWidgets(2)); // one per tile + await disposeTree(tester); + }); } diff --git a/apps/app_seeds/test/ui/label_print_sheet_test.dart b/apps/app_seeds/test/ui/label_print_sheet_test.dart new file mode 100644 index 0000000..258f383 --- /dev/null +++ b/apps/app_seeds/test/ui/label_print_sheet_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/ui/label_print_sheet.dart'; + +/// Hosts a button that opens the print-labels sheet for [entries]. +Widget _host(List entries) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showLabelPrintSheet(context, entries), + child: const Text('Open'), + ), + ), + ), + ), + ); +} + +void main() { + Future open(WidgetTester tester) async { + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + testWidgets('prefills copies from the suggested count and totals them', ( + tester, + ) async { + await tester.pumpWidget( + _host(const [ + SeedLabelEntry(varietyLabel: 'Tomate', suggestedCopies: 3), + SeedLabelEntry(varietyLabel: 'Alubia', suggestedCopies: 1), + ]), + ); + await open(tester); + + // 3 + 1 suggested = 4 labels. + expect(find.text('4 labels'), findsOneWidget); + }); + + testWidgets('the stepper and the field both edit a copy count', ( + tester, + ) async { + await tester.pumpWidget( + _host(const [ + SeedLabelEntry(varietyLabel: 'Tomate', suggestedCopies: 3), + SeedLabelEntry(varietyLabel: 'Alubia', suggestedCopies: 1), + ]), + ); + await open(tester); + + await tester.tap( + find.byKey(const Key('printLabels.copies.minus.Tomate')), + ); + await tester.pumpAndSettle(); + expect(find.text('3 labels'), findsOneWidget); // 2 + 1 + + await tester.enterText( + find.byKey(const Key('printLabels.copies.field.Tomate')), + '5', + ); + await tester.pumpAndSettle(); + expect(find.text('6 labels'), findsOneWidget); // 5 + 1 + }); + + testWidgets('saving is disabled when the total is zero', (tester) async { + await tester.pumpWidget( + _host(const [ + SeedLabelEntry(varietyLabel: 'Tomate', suggestedCopies: 1), + ]), + ); + await open(tester); + + await tester.enterText( + find.byKey(const Key('printLabels.copies.field.Tomate')), + '0', + ); + await tester.pumpAndSettle(); + expect(find.text('0 labels'), findsOneWidget); + + final save = tester.widget( + find.byKey(const Key('printLabels.save')), + ); + expect(save.onPressed, isNull); + }); +} diff --git a/apps/app_seeds/test/ui/legal_screen_test.dart b/apps/app_seeds/test/ui/legal_screen_test.dart new file mode 100644 index 0000000..a5d9964 --- /dev/null +++ b/apps/app_seeds/test/ui/legal_screen_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/ui/legal_screen.dart'; + +void main() { + Widget app() => TranslationProvider( + child: const MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: LegalScreen(), + ), + ); + + testWidgets('shows the three summaries with their full-text links', ( + tester, + ) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget(app()); + await tester.pump(); + + expect(find.text('Your privacy'), findsOneWidget); + // No account, no tracker, plain-words copy is on screen. + expect(find.textContaining('without an account'), findsOneWidget); + expect(find.text('Read the full documents online'), findsWidgets); + + // The later sections start below the fold of the test viewport. + await tester.scrollUntilVisible( + find.text('About sharing seeds and seedlings'), + 300, + ); + expect(find.text('Rules of the road'), findsOneWidget); + expect(find.text('About sharing seeds and seedlings'), findsOneWidget); + }); + + testWidgets('renders under RTL without overflowing', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget( + TranslationProvider( + child: const MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Directionality( + textDirection: TextDirection.rtl, + child: LegalScreen(), + ), + ), + ), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(find.byType(LegalScreen), findsOneWidget); + }); +} diff --git a/apps/app_seeds/test/ui/market_gate_test.dart b/apps/app_seeds/test/ui/market_gate_test.dart new file mode 100644 index 0000000..7cce096 --- /dev/null +++ b/apps/app_seeds/test/ui/market_gate_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/ui/market_gate.dart'; + +import '../support/test_support.dart'; + +void main() { + Widget host(OnboardingStore store, void Function(bool) onResult) => + TranslationProvider( + child: MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + onResult(await ensureMarketRulesAccepted(context, store)); + }, + child: const Text('enter'), + ), + ), + ), + ), + ); + + testWidgets('accepting persists the flag and returns true', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + bool? result; + + await tester.pumpWidget(host(store, (r) => result = r)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + + expect(find.text('Before you join the market'), findsOneWidget); + await tester.tap(find.text('I agree')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(await store.marketRulesAccepted(), isTrue); + + // Second entry: already accepted, no sheet. + result = null; + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + expect(find.text('Before you join the market'), findsNothing); + expect(result, isTrue); + }); + + testWidgets('declining returns false and persists nothing', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + bool? result; + + await tester.pumpWidget(host(store, (r) => result = r)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + expect(await store.marketRulesAccepted(), isFalse); + }); + + testWidgets('the sheet shows the three rules and the public note', ( + tester, + ) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget( + TranslationProvider( + child: const MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Scaffold(body: MarketGateSheet()), + ), + ), + ); + await tester.pump(); + + expect(find.text('Be honest about the seeds you offer'), findsOneWidget); + expect( + find.text("Only share what you're allowed to share where you live"), + findsOneWidget, + ); + expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget); + expect(find.textContaining('public'), findsWidgets); + expect(find.text('Privacy & rules'), findsOneWidget); + }); +} diff --git a/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart new file mode 100644 index 0000000..120d6d5 --- /dev/null +++ b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart @@ -0,0 +1,153 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/saved_offers_store.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/ui/market_offer_detail_screen.dart'; +import 'package:tane/ui/market_widgets.dart'; + +import '../support/test_support.dart'; + +Widget _wrap(MarketOfferDetailScreen screen) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: screen, + ), + ); +} + +Offer _offer({bool organic = false, String? imageUrl}) => Offer( + id: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa de Barbastro', + type: OfferType.gift, + category: 'Solanaceae', + approxGeohash: 'sp3e9', + isOrganic: organic, + imageUrl: imageUrl, + ); + +void main() { + testWidgets('renders the offer and a message button for a stranger', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: no network in the test + final connection = SocialConnection(social: social, settings: settings); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(organic: true), + connection: connection, + ))); + await tester.pumpAndSettle(); + + expect(find.text('Tomate rosa de Barbastro'), findsWidgets); + expect(find.text('Solanaceae'), findsOneWidget); + expect(find.text('Organic'), findsOneWidget); // eco badge + expect(find.byKey(const Key('offerDetail.contact')), findsOneWidget); + }); + + testWidgets('hides the message button on my own listing', (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(), + connection: connection, + mine: true, + ))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('offerDetail.contact')), findsNothing); + }); + + testWidgets('shows a hero image when the offer has a photo', (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(imageUrl: 'https://media.example/abc.jpg'), + connection: connection, + ))); + await tester.pumpAndSettle(); + + expect(find.byType(OfferHeroImage), findsOneWidget); + }); + + testWidgets('shows no hero image when the offer has none', (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(), + connection: connection, + ))); + await tester.pumpAndSettle(); + + expect(find.byType(OfferHeroImage), findsNothing); + }); + + testWidgets('the heart saves and unsaves a stranger\'s offer', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); + final saved = SavedOffersStore(InMemorySecretStore()); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(), + connection: connection, + savedOffers: saved, + ))); + await tester.pumpAndSettle(); + + final heart = find.byKey(const Key('offerDetail.save')); + expect(heart, findsOneWidget); + expect(await saved.isSaved('${'ab' * 32}:lot-1'), isFalse); + + await tester.tap(heart); + await tester.pumpAndSettle(); + expect(await saved.isSaved('${'ab' * 32}:lot-1'), isTrue); + + await tester.tap(heart); + await tester.pumpAndSettle(); + expect(await saved.isSaved('${'ab' * 32}:lot-1'), isFalse); + }); + + testWidgets('no heart on my own listing', (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); + final saved = SavedOffersStore(InMemorySecretStore()); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(), + connection: connection, + mine: true, + savedOffers: saved, + ))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('offerDetail.save')), findsNothing); + }); +} diff --git a/apps/app_seeds/test/ui/market_screen_test.dart b/apps/app_seeds/test/ui/market_screen_test.dart new file mode 100644 index 0000000..b063201 --- /dev/null +++ b/apps/app_seeds/test/ui/market_screen_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/coarse_location.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/state/offers_cubit.dart'; +import 'package:tane/ui/market_screen.dart'; + +import '../support/test_support.dart'; + +// NOTE: discovery/offer-list rendering is covered by offers_cubit_test.dart +// (plain `test` + pumpEventQueue). We deliberately do NOT drive discovery +// through `testWidgets` here: the transient "searching" CircularProgressIndicator +// never settles under pumpAndSettle and hangs the runner. These widget tests +// only cover the offline/setup paths, which have no live stream. + +/// A location provider returning a fixed coarse position. +class FakeLocation implements CoarseLocationProvider { + FakeLocation(this.lat, this.lon); + final double lat; + final double lon; + @override + Future<({double lat, double lon})?> currentCoarseLatLon() async => + (lat: lat, lon: lon); +} + +Widget _wrapBody(OffersCubit cubit) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: MarketBody(onConfigure: () {}), + ), + ), + ), + ); +} + +Widget _wrapMarket(SocialService social, SocialSettings settings, + {CoarseLocationProvider? location}) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: MarketScreen( + social: social, + settings: settings, + connection: SocialConnection(social: social, settings: settings), + location: location, + ), + ), + ); +} + +void main() { + testWidgets('offline (no transport) shows the "can\'t reach servers" state', + (tester) async { + final cubit = OffersCubit(null); + addTearDown(cubit.close); + + await tester.pumpWidget(_wrapBody(cubit)); + await tester.pumpAndSettle(); + + expect(find.textContaining("Can't reach the community servers"), + findsOneWidget); + }); + + testWidgets('MarketScreen with no relays configured degrades to offline', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: don't hit the network + + await tester.pumpWidget(_wrapMarket(social, settings)); + await tester.pumpAndSettle(); + + expect(find.text('Seeds near you'), findsOneWidget); // app bar title + expect(find.text('Retry'), findsOneWidget); // can't-reach state offers retry + }); + + testWidgets('the range selector persists the chosen search precision', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: don't hit the network + + await tester.pumpWidget(_wrapMarket(social, settings)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('market.config'))); + await tester.pumpAndSettle(); + + // Defaults to the wide "Around here" (precision 4); narrow to "My region". + expect(await settings.searchPrecision(), 4); + await tester.tap(find.text('My region')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('market.save'))); + await tester.pumpAndSettle(); + + expect(await settings.searchPrecision(), 3); + }); + + testWidgets('community servers are offered as toggles, not a URL box', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline + nothing selected + + await tester.pumpWidget(_wrapMarket(social, settings)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('market.config'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('market.advanced'))); + await tester.pumpAndSettle(); + + // Every built-in server shows as a checkbox instead of a wss:// text field. + for (final url in SocialSettings.defaultRelays) { + expect(find.byKey(Key('market.server.$url')), findsOneWidget); + } + final firstKey = Key('market.server.${SocialSettings.defaultRelays.first}'); + await tester.ensureVisible(find.byKey(firstKey)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(firstKey)).value, isFalse); + + // Tapping the toggle selects it (no wss:// typing required). + await tester.tap(find.byKey(firstKey)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(firstKey)).value, isTrue); + }); + + testWidgets('"use my location" fills the area with a coarse geohash', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: don't hit the network + + await tester.pumpWidget( + _wrapMarket(social, settings, location: FakeLocation(41.39, 2.16)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('market.config'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('market.useLocation'))); + await tester.pumpAndSettle(); + + // The area status flips to "set" once a coarse location is captured. + expect(find.textContaining('Your area is set'), findsOneWidget); + }); +} diff --git a/apps/app_seeds/test/ui/market_widgets_test.dart b/apps/app_seeds/test/ui/market_widgets_test.dart new file mode 100644 index 0000000..7a57f23 --- /dev/null +++ b/apps/app_seeds/test/ui/market_widgets_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/ui/market_widgets.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: Center(child: child))); + +/// A 1×1 PNG as an inline data URI — the shape offers actually publish. +const _dataUri = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lE' + 'QVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +void main() { + testWidgets('OfferThumbnail renders an inline data-URI from memory', + (tester) async { + await tester.pumpWidget( + _wrap(const OfferThumbnail(url: _dataUri, semanticLabel: 'Photo')), + ); + await tester.pump(); + + expect(find.byType(Image), findsOneWidget); + // Decoded fine — no broken-image placeholder. + expect(find.byIcon(Icons.image_not_supported_outlined), findsNothing); + }); + + testWidgets('OfferThumbnail builds a network image', (tester) async { + await tester.pumpWidget( + _wrap(const OfferThumbnail( + url: 'https://media.example/abc.jpg', + semanticLabel: 'Photo', + )), + ); + // The image widget is present; its loadingBuilder/errorBuilder handle the + // remote fetch, so we only assert the widget is wired (no network in tests). + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('OfferThumbnail falls back to a placeholder when the load fails', + (tester) async { + await tester.pumpWidget( + _wrap(const OfferThumbnail( + url: 'https://media.example/missing.jpg', + semanticLabel: 'Photo', + )), + ); + // Let the (failed) network fetch resolve; the errorBuilder then shows a + // neutral icon instead of a broken image. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byIcon(Icons.image_not_supported_outlined), findsOneWidget); + }); + + testWidgets('OfferHeroImage builds a network image', (tester) async { + await tester.pumpWidget( + _wrap(const OfferHeroImage( + url: 'https://media.example/abc.jpg', + semanticLabel: 'Photo', + )), + ); + expect(find.byType(Image), findsOneWidget); + }); +} diff --git a/apps/app_seeds/test/ui/peer_avatar_test.dart b/apps/app_seeds/test/ui/peer_avatar_test.dart new file mode 100644 index 0000000..64546d6 --- /dev/null +++ b/apps/app_seeds/test/ui/peer_avatar_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/profile_cache.dart'; +import 'package:tane/ui/avatar.dart'; +import 'package:tane/ui/peer_avatar.dart'; +import 'package:tane/ui/seed_glyph.dart'; + +import '../support/test_support.dart'; + +void main() { + group('avatar value scheme', () { + test('legacy seed-illustration names still map to a glyph; unknown → null', + () { + expect(avatarGlyphChar('jars'), isNotNull); + expect(avatarGlyphChar('sack'), isNotNull); + expect(avatarGlyphChar('nope'), isNull); + }); + }); + + group('peerAvatarColor', () { + test('is deterministic for the same pubkey', () { + expect(peerAvatarColor('ab' * 32), peerAvatarColor('ab' * 32)); + }); + + test('differs for different pubkeys', () { + expect(peerAvatarColor('ab' * 32), isNot(peerAvatarColor('cd' * 32))); + }); + + test('is fully opaque (legible disc)', () { + expect(peerAvatarColor('feed').a, 1.0); + }); + }); + + group('PeerAvatar', () { + Widget host(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), + ); + + testWidgets('shows the name initial when known', (tester) async { + await tester.pumpWidget(host(PeerAvatar(pubkey: 'aa', name: 'rosa'))); + expect(find.text('R'), findsOneWidget); // uppercased first letter + }); + + testWidgets('falls back to a person icon when the name is unknown', ( + tester, + ) async { + await tester.pumpWidget(host(const PeerAvatar(pubkey: 'aa'))); + expect(find.byIcon(Icons.person_outline), findsOneWidget); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('renders a seed illustration when the avatar is a token', ( + tester, + ) async { + await tester.pumpWidget(host(const PeerAvatar( + pubkey: 'aa', name: 'rosa', picture: 'tane:seed:jars'))); + expect(find.byType(SeedGlyph), findsOneWidget); + expect(find.text('R'), findsNothing); // the initial isn't used + }); + }); + + group('CachedAvatar', () { + Widget host(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), + ); + + testWidgets('paints the cached illustration for the peer', (tester) async { + final cache = ProfileCache(InMemorySecretStore()); + await cache.setPicture('peer1', 'tane:seed:sack'); + await tester.pumpWidget(host( + CachedAvatar(pubkey: 'peer1', name: 'Bea', cache: cache))); + await tester.pumpAndSettle(); + expect(find.byType(SeedGlyph), findsOneWidget); + }); + + testWidgets('falls back to the initial when nothing is cached', ( + tester, + ) async { + final cache = ProfileCache(InMemorySecretStore()); + await tester.pumpWidget(host( + CachedAvatar(pubkey: 'peer2', name: 'Bea', cache: cache))); + await tester.pumpAndSettle(); + expect(find.text('B'), findsOneWidget); + }); + + testWidgets('no cache → the coloured-initial disc', (tester) async { + await tester.pumpWidget(host(const CachedAvatar(pubkey: 'x', name: 'Bea'))); + expect(find.text('B'), findsOneWidget); + }); + }); +} diff --git a/apps/app_seeds/test/ui/photo_crop_test.dart b/apps/app_seeds/test/ui/photo_crop_test.dart new file mode 100644 index 0000000..d7b8510 --- /dev/null +++ b/apps/app_seeds/test/ui/photo_crop_test.dart @@ -0,0 +1,48 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:tane/ui/photo_crop.dart'; + +void main() { + // A small but real PNG the cropper can decode. + final bytes = Uint8List.fromList( + img.encodePng(img.Image(width: 32, height: 32)), + ); + + testWidgets('cropToSquare shows a cancel/confirm UI and cancel returns null', ( + tester, + ) async { + Uint8List? result; + var returned = false; + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + result = await cropToSquare(context, bytes); + returned = true; + }, + child: const Text('open'), + ), + ), + ), + ), + )); + + await tester.tap(find.text('open')); + await tester.pump(); // push the crop route + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byKey(const Key('crop.confirm')), findsOneWidget); + expect(find.byKey(const Key('crop.cancel')), findsOneWidget); + + await tester.tap(find.byKey(const Key('crop.cancel'))); + await tester.pump(); // pop the crop route + + expect(returned, isTrue); + expect(result, isNull); // cancelling yields no image + }); +} diff --git a/apps/app_seeds/test/ui/plantare_propose_sheet_test.dart b/apps/app_seeds/test/ui/plantare_propose_sheet_test.dart new file mode 100644 index 0000000..f06bcdf --- /dev/null +++ b/apps/app_seeds/test/ui/plantare_propose_sheet_test.dart @@ -0,0 +1,159 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/plantare_service.dart'; +import 'package:tane/ui/plantare_propose_sheet.dart'; + +import '../support/test_support.dart'; + +/// A records-only transport so the sheet can be driven without a relay. +class _CapturingTransport implements PlantareTransport { + final proposed = []; + @override + Future propose(PlantarePledge pledge) async => proposed.add(pledge); + @override + Future accept(PlantarePledge pledge) async {} + @override + Future decline({ + required String toPubkey, + required String pledgeId, + String reason = '', + }) async {} + @override + Stream incoming() => const Stream.empty(); + @override + Future close() async {} +} + +void main() { + // NEVER pumpAndSettle these: the sheet subscribes to a live Drift stream + // (watchInventory) and a focused TextField blinks its cursor forever, so + // pumpAndSettle never returns (see CLAUDE.md testing note). Use a tall surface + // so the whole sheet fits without scrolling, and bounded pumps only. + PlantareService serviceFor(AppDatabase db, _CapturingTransport tx) => + PlantareService( + repo: newTestRepository(db), + selfPubkey: 'a' * 64, + selfSecretKey: + '0000000000000000000000000000000000000000000000000000000000000001', + )..bindTransport(tx); + + Widget host(PlantareService service, VarietyRepository repo) => + TranslationProvider( + child: MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showProposePlantareSheet( + context, + service: service, + repository: repo, + peerPubkey: 'b' * 64, + peerName: 'Ana', + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + Future useTallSurface(WidgetTester tester) async { + tester.view.physicalSize = const Size(1400, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + /// Opens the sheet and lets the slide-in + inventory load settle (bounded). + Future openSheet(WidgetTester tester) async { + await tester.tap(find.text('open')); + await tester.pump(); // start the sheet route + await tester.pump(const Duration(milliseconds: 400)); // slide-in + load + await tester.pump(const Duration(milliseconds: 200)); + } + + /// Taps send and lets the async propose (DB write + sign + pop) drain. + Future tapSend(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('propose.send'))); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + } + + testWidgets('a new seed name proposes a signed pledge linked to a Variety', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await useTallSurface(tester); + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + final tx = _CapturingTransport(); + final service = serviceFor(db, tx); + + await tester.pumpWidget(host(service, repo)); + await openSheet(tester); + expect(find.byKey(const Key('propose.seed')), findsOneWidget); + + await tester.enterText( + find.byKey(const Key('propose.seed')), 'Tomate rosa'); + await tester.pump(); + await tapSend(tester); + + expect(tx.proposed, hasLength(1)); + expect(tx.proposed.single.label, 'Tomate rosa'); + // Default direction is owedToMe → I'm the creditor and signed my stub. + expect(tx.proposed.single.creditorSignature, isNotNull); + expect(tx.proposed.single.debtorSignature, isNull); + // The local row is recorded as proposed AND tied to a real Variety — a new + // name created one on send. + final row = await repo.plantareByPledgeId(tx.proposed.single.pledgeId); + expect(row!.remoteState, PlantareRemoteState.proposed); + expect(row.varietyId, isNotNull); + final linked = await repo.varietyLabels(); + expect(linked.map((v) => v.id), contains(row.varietyId)); + + await service.stop(); + }); + + testWidgets('typing an existing seed links the pledge to that variety', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await useTallSurface(tester); + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + final existingId = await repo.addQuickVariety(label: 'Maíz rojo'); + final tx = _CapturingTransport(); + final service = serviceFor(db, tx); + + await tester.pumpWidget(host(service, repo)); + await openSheet(tester); + + // A matching name resolves to the existing variety — no duplicate created. + await tester.enterText(find.byKey(const Key('propose.seed')), 'Maíz rojo'); + await tester.pump(); + await tapSend(tester); + + expect(tx.proposed, hasLength(1)); + final row = await repo.plantareByPledgeId(tx.proposed.single.pledgeId); + expect(row!.varietyId, existingId); // linked, not duplicated + expect((await repo.varietyLabels()).length, 1); + + await service.stop(); + }); +} diff --git a/apps/app_seeds/test/ui/plantares_screen_test.dart b/apps/app_seeds/test/ui/plantares_screen_test.dart new file mode 100644 index 0000000..66b84a6 --- /dev/null +++ b/apps/app_seeds/test/ui/plantares_screen_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tane/app.dart' show materialLocaleFor; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/ui/plantares_screen.dart'; + +import '../support/test_support.dart'; + +/// The Plantares screen used to hide the data it stored: no date, no link to +/// the seed. These pin the surfaced version — a named, dated, tappable +/// commitment with an overdue nudge. +void main() { + late AppDatabase db; + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + testWidgets('a tile names the linked seed and flags an overdue return-by', + (tester) async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Tomate rosa'); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: vid, + counterparty: 'Ana', + dueBy: DateTime(2000, 1, 1).millisecondsSinceEpoch, // long past → overdue + ); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const PlantaresScreen()), + ); + await tester.pump(); // let the Drift stream emit + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.textContaining('Tomate rosa'), findsOneWidget); + expect(find.textContaining('Ana'), findsOneWidget); + expect(find.textContaining('Return by'), findsOneWidget); + expect(find.textContaining('overdue'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('an open commitment with no due date shows no overdue flag', + (tester) async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Maíz'); + await repo.createPlantare( + direction: PlantareDirection.owedToMe, + varietyId: vid, + ); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const PlantaresScreen()), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.textContaining('overdue'), findsNothing); + expect(find.textContaining('Return by'), findsNothing); + await disposeTree(tester); + }); + + testWidgets('a fully-signed bilateral pledge shows the "signed by both" badge', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final repo = newTestRepository(db); + await repo.createPlantare( + direction: PlantareDirection.owedToMe, + counterparty: 'Ana', + pledgeId: 'p-1', + debtorKey: 'dk', + creditorKey: 'ck', + debtorSignature: 'ds', + creditorSignature: 'cs', + remoteState: PlantareRemoteState.accepted, + ); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const PlantaresScreen()), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Signed by both'), findsOneWidget); + // No accept/decline for a closed deal. + expect(find.byKey(const Key('plantare.accept.')), findsNothing); + await disposeTree(tester); + }); + + testWidgets('a pending proposal shows the "awaiting signature" badge', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final repo = newTestRepository(db); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + counterparty: 'Ben', + pledgeId: 'p-2', + debtorKey: 'dk', + creditorKey: 'ck', + creditorSignature: 'cs', // proposer (creditor) signed; I (debtor) haven't + remoteState: PlantareRemoteState.proposed, + ); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const PlantaresScreen()), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Awaiting signature'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('tapping a linked commitment opens its variety', (tester) async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Judía'); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: vid, + ); + + LocaleSettings.setLocaleSync(AppLocale.en); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const PlantaresScreen(), + ), + GoRoute( + path: '/variety/:id', + builder: (_, state) => + Scaffold(body: Text('variety ${state.pathParameters['id']}')), + ), + ], + ); + await tester.pumpWidget( + TranslationProvider( + child: RepositoryProvider.value( + value: repo, + child: MaterialApp.router( + routerConfig: router, + locale: materialLocaleFor(AppLocale.en.flutterLocale), + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.byType(ListTile).first); + await tester.pumpAndSettle(); + + expect(find.text('variety $vid'), findsOneWidget); + await disposeTree(tester); + }); +} diff --git a/apps/app_seeds/test/ui/quantity_picker_test.dart b/apps/app_seeds/test/ui/quantity_picker_test.dart new file mode 100644 index 0000000..65b4eae --- /dev/null +++ b/apps/app_seeds/test/ui/quantity_picker_test.dart @@ -0,0 +1,83 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/ui/quantity_picker.dart'; + +import '../support/test_support.dart'; + +/// A countable amount must never be blank: picking cob/pod/… defaults to 1 and +/// the stepper can't drop below 1 — you can't save "a cob" with no figure. +void main() { + Quantity? emitted; + + Future pumpPicker(WidgetTester tester, + {LotType type = LotType.seed}) async { + emitted = null; + final db = newTestDatabase(); + addTearDown(db.close); + await tester.pumpWidget(wrapScreen( + repository: newTestRepository(db), + child: Scaffold( + body: QuantityPicker( + type: type, + onChanged: (q) => emitted = q, + ), + ), + )); + await tester.pump(); + } + + testWidgets('selecting a countable unit defaults the count to 1', + (tester) async { + await pumpPicker(tester); + await tester.tap(find.byKey(const Key('quantity.kind.cob'))); + await tester.pump(); + + expect(emitted, isNotNull); + expect(emitted!.kind, QuantityKind.cob); + expect(emitted!.count, 1); + expect( + find.widgetWithText(TextField, '1'), + findsOneWidget, + reason: 'the count field shows 1, not an empty box', + ); + }); + + testWidgets('the minus button never takes a countable amount below 1', + (tester) async { + await pumpPicker(tester); + await tester.tap(find.byKey(const Key('quantity.kind.cob'))); + await tester.pump(); + // Two decrements from 1 must clamp at 1, never 0 or blank. + await tester.tap(find.byIcon(Icons.remove)); + await tester.pump(); + await tester.tap(find.byIcon(Icons.remove)); + await tester.pump(); + + expect(emitted!.count, 1); + }); + + testWidgets('a vibe unit (a few) carries no number and shows no stepper', + (tester) async { + await pumpPicker(tester); + await tester.tap(find.byKey(const Key('quantity.kind.aFew'))); + await tester.pump(); + + expect(emitted!.kind, QuantityKind.aFew); + expect(emitted!.count, isNull); + expect(find.byKey(const Key('quantity.count')), findsNothing); + }); + + testWidgets('typing then clearing the box still stores 1, never empty', + (tester) async { + await pumpPicker(tester); + await tester.tap(find.byKey(const Key('quantity.kind.pod'))); + await tester.pump(); + await tester.enterText(find.byKey(const Key('quantity.count')), ''); + await tester.pump(); + + expect(emitted!.kind, QuantityKind.pod); + expect(emitted!.count, 1); + }); +} diff --git a/apps/app_seeds/test/ui/rating_sheet_test.dart b/apps/app_seeds/test/ui/rating_sheet_test.dart new file mode 100644 index 0000000..4b82a8d --- /dev/null +++ b/apps/app_seeds/test/ui/rating_sheet_test.dart @@ -0,0 +1,124 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/state/peer_rating_cubit.dart'; +import 'package:tane/ui/rating_sheet.dart'; + +/// In-memory [RatingTransport] (one live rating per pair). +class FakeRatingTransport implements RatingTransport { + FakeRatingTransport(this.selfId); + final String selfId; + final Map byRater = {}; + + @override + Future rate({ + required String subjectPubkey, + required int stars, + String comment = '', + }) async => + byRater[selfId] = Rating( + rater: selfId, + subject: subjectPubkey, + stars: stars, + ratedAt: DateTime(2026), + comment: comment, + ); + + @override + Future retract({required String subjectPubkey}) async => + byRater.remove(selfId); + + @override + Future> ratingsOf(String subjectPubkey) async => + List.of(byRater.values); + + @override + Future myRatingOf(String subjectPubkey) async => byRater[selfId]; + + @override + Future close() async {} +} + +void main() { + const me = 'me'; + const peer = 'peer'; + + late FakeRatingTransport transport; + late PeerRatingCubit cubit; + + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + transport = FakeRatingTransport(me); + cubit = PeerRatingCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + }); + + tearDown(() => cubit.close()); + + Widget host() => TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => showRatingSheet(context, cubit), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + testWidgets('picking stars and saving publishes the rating', + (tester) async { + await tester.pumpWidget(host()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Rate this person'), findsOneWidget); + // Save is disabled until stars are picked. + expect( + tester.widget(find.byKey(const Key('rating.save'))).enabled, + isFalse, + ); + + await tester.tap(find.byKey(const Key('rating.star.4'))); + await tester.pump(); + await tester.enterText( + find.byKey(const Key('rating.comment')), 'Great swap'); + await tester.tap(find.byKey(const Key('rating.save'))); + await tester.pumpAndSettle(); + + final mine = transport.byRater[me]!; + expect(mine.stars, 4); + expect(mine.comment, 'Great swap'); + expect(find.text('Rating saved'), findsOneWidget); // snackbar + }); + + testWidgets('opens pre-filled when you already rated; retract removes', + (tester) async { + await cubit.rate(3, comment: 'Fine'); + + await tester.pumpWidget(host()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Edit your rating'), findsOneWidget); + expect(find.text('Fine'), findsOneWidget); // comment pre-filled + + await tester.tap(find.byKey(const Key('rating.retract'))); + await tester.pumpAndSettle(); + + expect(transport.byRater, isEmpty); + }); +} diff --git a/apps/app_seeds/test/ui/report_sheet_test.dart b/apps/app_seeds/test/ui/report_sheet_test.dart new file mode 100644 index 0000000..4f5bbf6 --- /dev/null +++ b/apps/app_seeds/test/ui/report_sheet_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/ui/report_sheet.dart'; + +import '../support/test_support.dart'; + +void main() { + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + + // A connection that can never open — the sheet degrades to "couldn't send". + Future offlineConnection() async => SocialConnection( + social: await SocialService.fromRootSeedHex(seedHex), + settings: SocialSettings(InMemorySecretStore()), + open: (_) async => throw StateError('offline'), + online: const Stream.empty(), + ); + + Widget host(SocialConnection connection, SocialSettings settings) => + TranslationProvider( + child: MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Scaffold( + body: ReportSheet( + connection: connection, + subjectPubkey: 'ab' * 32, + settings: settings, + ), + ), + ), + ); + + testWidgets('shows the reason picker, details field and block option', ( + tester, + ) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget( + host(await offlineConnection(), SocialSettings(InMemorySecretStore())), + ); + await tester.pump(); + + expect(find.text('Spam or a scam'), findsOneWidget); + expect(find.text('Abusive or disrespectful'), findsOneWidget); + expect(find.text("Seeds that shouldn't be offered"), findsOneWidget); + expect(find.text('Something else'), findsOneWidget); + expect(find.text('Also block this person'), findsOneWidget); + expect(find.text('Send report'), findsOneWidget); + }); + + testWidgets('offline send fails softly and keeps the sheet open', ( + tester, + ) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final settings = SocialSettings(InMemorySecretStore()); + await tester.pumpWidget(host(await offlineConnection(), settings)); + await tester.pump(); + + await tester.tap(find.byKey(const Key('report.send'))); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.text("Couldn't send the report — check your connection"), + findsOneWidget, + ); + // Nothing was blocked as a side effect of the failed send. + expect(await settings.blockedPubkeys(), isEmpty); + expect(find.byType(ReportSheet), findsOneWidget); + }); +} diff --git a/apps/app_seeds/test/ui/rtl_smoke_test.dart b/apps/app_seeds/test/ui/rtl_smoke_test.dart index 06d636e..0fcefe9 100644 --- a/apps/app_seeds/test/ui/rtl_smoke_test.dart +++ b/apps/app_seeds/test/ui/rtl_smoke_test.dart @@ -93,6 +93,9 @@ void main() { ); await tester.pumpAndSettle(); + // The language selector opens a picker listing every language. + await tester.tap(find.byKey(const Key('settings.language'))); + await tester.pumpAndSettle(); expect(find.text('Português'), findsOneWidget); await disposeTree(tester); }); diff --git a/apps/app_seeds/test/ui/seed_saving_section_test.dart b/apps/app_seeds/test/ui/seed_saving_section_test.dart new file mode 100644 index 0000000..a80f7d1 --- /dev/null +++ b/apps/app_seeds/test/ui/seed_saving_section_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/seed_saving_catalog.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/domain/seed_saving.dart'; + +import '../support/test_support.dart'; + +/// The variety detail shows "how to save this seed" when the bundled catalog +/// has guidance for the crop's family, and hides it otherwise. +void main() { + late AppDatabase db; + setUp(() { + db = newTestDatabase(); + SeedSavingCatalog.data = parseSeedSaving(''' + { + "sources": [ { "title": "Seed to Seed" } ], + "families": { + "Solanaceae": { "pollination": "self", "isolationMinM": 3, "isolationMaxM": 6, "processing": "wet", "difficulty": "easy" } + }, + "species": {} + } + '''); + }); + tearDown(() { + SeedSavingCatalog.data = null; + return db.close(); + }); + + Future pumpDetailFor(WidgetTester tester, String? category) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Tomate', category: category); + await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpAndSettle(); + } + + testWidgets('shows the seed-saving section for a known family', (tester) async { + await pumpDetailFor(tester, 'Solanaceae'); + + // The section is a collapsed expander — its title shows, its body doesn't + // until opened. + expect(find.text('Saving its seed'), findsOneWidget); + expect(find.textContaining('Self-pollinating'), findsNothing); + + await tester.tap(find.byKey(const Key('detail.seedSaving'))); + await tester.pumpAndSettle(); + + // The pollination line is a Text.rich (label + value), so match the run. + expect(find.textContaining('Self-pollinating'), findsOneWidget); + expect(find.text('Easy'), findsOneWidget); // the difficulty pill + // Advisory note + a light source credit. + expect(find.textContaining('adapt it to your climate'), findsOneWidget); + expect(find.textContaining('Seed to Seed'), findsOneWidget); + + await disposeTree(tester); + }); + + testWidgets('hides the section when nothing matches', (tester) async { + await pumpDetailFor(tester, 'Unknownaceae'); + + expect(find.text('Saving its seed'), findsNothing); + + await disposeTree(tester); + }); +} diff --git a/apps/app_seeds/test/ui/share_flow_test.dart b/apps/app_seeds/test/ui/share_flow_test.dart index f0c1a69..3618249 100644 --- a/apps/app_seeds/test/ui/share_flow_test.dart +++ b/apps/app_seeds/test/ui/share_flow_test.dart @@ -47,40 +47,6 @@ void main() { await disposeTree(tester); }); - testWidgets('declaring plenty reveals the share choice with a nudge', ( - tester, - ) async { - final repo = newTestRepository(db); - final id = await repo.addQuickVariety(label: 'Maize'); - - await tester.pumpWidget( - wrapDetail( - repository: repo, - varietyId: id, - species: newTestSpeciesRepository(db), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('detail.addLot'))); - await tester.pumpAndSettle(); - - // Reveal abundance and declare plenty. - await tester.ensureVisible(find.byKey(const Key('lot.addAbundance'))); - await tester.tap(find.byKey(const Key('lot.addAbundance'))); - await tester.pumpAndSettle(); - await tester.ensureVisible( - find.byKey(const Key('abundance.plentyToShare')), - ); - await tester.tap(find.byKey(const Key('abundance.plentyToShare'))); - await tester.pumpAndSettle(); - - // The share section reveals itself, still private, with a soft nudge. - expect(find.byKey(const Key('share.private')), findsOneWidget); - expect(find.byKey(const Key('share.nudge')), findsOneWidget); - await disposeTree(tester); - }); - testWidgets('the sharing filter narrows the list to what I share', ( tester, ) async { diff --git a/apps/app_seeds/test/ui/small_screen_overflow_test.dart b/apps/app_seeds/test/ui/small_screen_overflow_test.dart new file mode 100644 index 0000000..744def7 --- /dev/null +++ b/apps/app_seeds/test/ui/small_screen_overflow_test.dart @@ -0,0 +1,330 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/profile_store.dart'; +import 'package:tane/services/social_account_store.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:tane/domain/crop_calendar.dart'; +import 'package:tane/ui/about_screen.dart'; +import 'package:tane/ui/calendar_screen.dart'; +import 'package:tane/ui/chat_screen.dart'; +import 'package:tane/ui/home_screen.dart'; +import 'package:tane/ui/intro_screen.dart'; +import 'package:tane/ui/inventory_list_screen.dart'; +import 'package:tane/ui/market_screen.dart'; +import 'package:tane/ui/plantares_screen.dart'; +import 'package:tane/ui/profile_screen.dart'; +import 'package:tane/ui/sales_screen.dart'; +import 'package:tane/ui/settings_screen.dart'; + +import '../support/test_support.dart'; + +/// Small-phone layout guard. A Flutter `RenderFlex overflowed` (a clipped title, +/// a save button pushed off a non-scrolling column, a Row too wide) is reported +/// as an error and FAILS the test — so pumping each screen at a tiny viewport, +/// in the languages whose text runs longest (es/pt/ast), catches exactly the +/// "the save button / title isn't fully visible on a small phone" reports. +/// +/// FULL SCREENS ONLY. Modal bottom sheets (quick-add, the lot editor) are NOT +/// checked here: `showModalBottomSheet` gets an unbounded height in a widget +/// test, which fabricates a huge false overflow. Those sheets already wrap their +/// body in a `SingleChildScrollView`, so their save buttons stay reachable; +/// verify them on a real small device. +void main() { + // iPhone-SE-class logical viewport: narrow AND short, where clipping shows. + const small = Size(320, 568); + // The locales whose strings are longest — English rarely overflows first. + // German is included for its long compound nouns; French for its verbosity. + const longLocales = [ + AppLocale.es, + AppLocale.pt, + AppLocale.ast, + AppLocale.de, + AppLocale.fr, + ]; + + setUpAll(() => PackageInfo.setMockInitialValues( + appName: 'Tane', + packageName: 'org.comunes.tane', + version: '0.1.0', + buildNumber: '1', + buildSignature: '', + )); + + Future pumpSmall(WidgetTester tester, Widget widget) async { + tester.view.physicalSize = small; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + // flutter_test's headless engine can't decode our real PNG assets (the + // logo), so `Image.asset` throws from the "image resource service". That's + // unrelated to layout — swallow ONLY those, and let every other FlutterError + // (crucially the "rendering library" RenderFlex overflow this guard exists + // to catch) still fail the test. Installed here, inside the running test, + // because testWidgets reinstalls its own onError at test start (a setUp + // override would be clobbered). + final previous = FlutterError.onError; + FlutterError.onError = (details) { + if (details.library == 'image resource service') return; + previous?.call(details); + }; + addTearDown(() => FlutterError.onError = previous); + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); + } + + for (final locale in longLocales) { + final tag = locale.languageCode; + + testWidgets('home menu fits a small screen ($tag)', (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + await pumpSmall( + tester, + wrapScreen( + repository: newTestRepository(db), + locale: locale, + child: const HomeScreen(marketEnabled: true), + ), + ); + await disposeTree(tester); + }); + + testWidgets('settings (language, backup, about) fits ($tag)', (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + await pumpSmall( + tester, + wrapScreen( + repository: newTestRepository(db), + locale: locale, + child: const SettingsScreen(), + ), + ); + await disposeTree(tester); + }); + + testWidgets('about (title, version, license) fits ($tag)', (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + await pumpSmall( + tester, + wrapScreen( + repository: newTestRepository(db), + locale: locale, + child: const AboutScreen(), + ), + ); + await disposeTree(tester); + }); + + testWidgets('the intro carousel fits ($tag)', (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + await pumpSmall( + tester, + wrapScreen( + repository: newTestRepository(db), + locale: locale, + child: IntroScreen(onDone: () {}), + ), + ); + await disposeTree(tester); + }); + } + + testWidgets('profile (identity card + save button) fits a small screen', + (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final social = await SocialService.fromRootSeedHex('00' * 32); + final connection = SocialConnection( + social: social, + settings: SocialSettings(InMemorySecretStore()), + open: (_) async => throw StateError('offline'), + online: const Stream.empty(), + ); + await pumpSmall( + tester, + wrapScreen( + repository: newTestRepository(db), + locale: AppLocale.es, + child: ProfileScreen( + social: social, + connection: connection, + profileStore: ProfileStore(InMemorySecretStore()), + accounts: SocialAccountStore(InMemorySecretStore()), + ), + ), + ); + await disposeTree(tester); + }); + + // The market/chat are "live" screens; checked here in their OFFLINE state + // (the shared connection can't reach a relay) — a static layout, and the one + // a user without signal actually sees. + Future offlineConn() async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + return SocialConnection( + social: social, + settings: settings, + open: (_) async => throw StateError('offline'), + online: const Stream.empty(), + ); + } + + testWidgets('market (offline "set up sharing") fits a small screen', + (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final connection = await offlineConn(); + await pumpSmall( + tester, + wrapScreen( + repository: newTestRepository(db), + locale: AppLocale.es, + child: MarketScreen( + social: await SocialService.fromRootSeedHex('00' * 32), + settings: SocialSettings(InMemorySecretStore()), + connection: connection, + ), + ), + ); + await disposeTree(tester); + }); + + testWidgets('chat (offline "set up sharing") fits a small screen', + (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final connection = await offlineConn(); + await pumpSmall( + tester, + wrapScreen( + repository: newTestRepository(db), + locale: AppLocale.es, + child: ChatScreen( + social: await SocialService.fromRootSeedHex('00' * 32), + connection: connection, + peerPubkey: 'ab' * 32, + ), + ), + ); + await disposeTree(tester); + }); + + testWidgets('inventory (empty) fits a small screen', (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + await pumpSmall( + tester, + wrapScreen( + repository: newTestRepository(db), + locale: AppLocale.es, + child: const InventoryListScreen(), + ), + ); + await disposeTree(tester); + }); + + testWidgets('inventory with a long-named seed fits a small screen', + (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + final id = await repo.addQuickVariety( + label: 'Tomate rosa de Barbastro extraordinariamente larguísimo', + category: 'Solanáceas', + ); + await repo.addLot( + varietyId: id, + harvestYear: 2024, + quantity: const Quantity(kind: QuantityKind.handful), + offerStatus: OfferStatus.shared, + ); + await pumpSmall( + tester, + wrapScreen( + repository: repo, + locale: AppLocale.es, + child: const InventoryListScreen(), + ), + ); + await disposeTree(tester); + }); + + testWidgets('the Plantares screen (with a commitment) fits a small screen', + (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + counterparty: 'Colectivo semillero de la comarca', + owedDescription: 'un puñado la próxima temporada, si germina bien', + ); + await pumpSmall( + tester, + wrapScreen( + repository: repo, + locale: AppLocale.es, + child: const PlantaresScreen(), + ), + ); + await disposeTree(tester); + }); + + testWidgets('the Sales screen (with a sale) fits a small screen', + (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + await repo.createSale( + direction: SaleDirection.iSold, + counterparty: 'Feria de intercambio de la comarca', + amount: 12.5, + currency: '€', + ); + await pumpSmall( + tester, + wrapScreen( + repository: repo, + locale: AppLocale.es, + child: const SalesScreen(), + ), + ); + await disposeTree(tester); + }); + + testWidgets('the "this month" calendar fits a small screen', (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + final id = await repo.addQuickVariety( + label: 'Lechuga maravilla de verano', category: 'Asteráceas'); + await repo.updateVariety(id: id, sowMonths: Value(monthsToMask([3]))); + await pumpSmall( + tester, + wrapScreen( + repository: repo, + locale: AppLocale.es, + child: const CalendarScreen(initialMonth: 3), + ), + ); + await disposeTree(tester); + }); + + // NOTE: the variety-detail screen is deliberately NOT overflow-checked here. + // It reports a ~2px overflow on a RenderFlex that is already DISPOSED/DEFUNCT + // — a transient stale frame while its cubit's Drift stream rebuilds during + // pumpAndSettle, not the stable rendered layout. Guarding it would be flaky; + // its RTL/build coverage lives in rtl_smoke_test.dart. +} diff --git a/apps/app_seeds/test/ui/theme_contrast_test.dart b/apps/app_seeds/test/ui/theme_contrast_test.dart index 1b514ac..682f3ef 100644 --- a/apps/app_seeds/test/ui/theme_contrast_test.dart +++ b/apps/app_seeds/test/ui/theme_contrast_test.dart @@ -48,5 +48,10 @@ void main() { expectAa(Colors.white, seedGreen, 'white on primary green'); expectAa(Colors.white, seedAppBar, 'white on app-bar green'); }); + + test('overdue warning text on every background it sits on', () { + expectAa(seedWarning, Colors.white, 'seedWarning on white'); + expectAa(seedWarning, seedCanvas, 'seedWarning on canvas'); + }); }); } diff --git a/apps/app_seeds/test/ui/unread_badge_test.dart b/apps/app_seeds/test/ui/unread_badge_test.dart new file mode 100644 index 0000000..b83517b --- /dev/null +++ b/apps/app_seeds/test/ui/unread_badge_test.dart @@ -0,0 +1,74 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/message_store.dart'; +import 'package:tane/services/unread_service.dart'; +import 'package:tane/ui/unread_badge.dart'; + +import '../support/test_support.dart'; + +/// The badge is driven by [UnreadService]: it shows a count when there are +/// unread messages and clears when they're read — live, over the service's +/// `changes` stream. Injecting the service avoids the `getIt` lookup. The store +/// reads are real async, so mutations run inside `runAsync`. +void main() { + late MessageStore store; + late UnreadService unread; + + setUp(() { + store = MessageStore(newTestChatDatabase()); + unread = UnreadService(store, InMemorySecretStore()); + }); + + tearDown(() => unread.dispose()); + + Widget host() => MaterialApp( + home: Scaffold( + body: UnreadBadge( + peer: 'alice', + service: unread, + child: const Icon(Icons.chat_bubble), + ), + ), + ); + + Future receive(int atMs) async { + final m = PrivateMessage( + fromPubkey: 'alice', + text: 'hi', + at: DateTime.fromMillisecondsSinceEpoch(atMs)); + await store.append('alice', m); + await unread.onMessageReceived('alice', m); + } + + // Lets pending async (store reads + the widget's stream-driven refresh) + // resolve in the real zone, then renders a frame. + Future pumpRefresh(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + } + + testWidgets('shows an existing unread count on build', (tester) async { + await tester.runAsync(() => receive(1000)); + await tester.pumpWidget(host()); + await pumpRefresh(tester); + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('reacts to a new message and to being read', (tester) async { + await tester.pumpWidget(host()); + await pumpRefresh(tester); + expect(find.text('1'), findsNothing); + + await tester.runAsync(() => receive(1000)); + await pumpRefresh(tester); + expect(find.text('1'), findsOneWidget); + + await tester.runAsync(() => unread.markRead('alice')); + await pumpRefresh(tester); + expect(find.text('1'), findsNothing); + }); +} diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index b03377e..e3c841d 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -610,4 +610,35 @@ void main() { expect(find.byIcon(Icons.star_border), findsNothing); await disposeTree(tester); }); + + testWidgets('shows a commitments section only when the variety has one', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + // No commitments yet → the section stays hidden (progressive disclosure). + expect(find.byKey(const Key('detail.plantares')), findsNothing); + + await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: id, + counterparty: 'Ana', + owedDescription: 'un puñado', + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail.plantares')), findsOneWidget); + expect(find.textContaining('un puñado'), findsOneWidget); + expect(find.textContaining('Ana'), findsOneWidget); + await disposeTree(tester); + }); } diff --git a/apps/app_seeds/test/ui/your_people_screen_test.dart b/apps/app_seeds/test/ui/your_people_screen_test.dart new file mode 100644 index 0000000..1b7d2ba --- /dev/null +++ b/apps/app_seeds/test/ui/your_people_screen_test.dart @@ -0,0 +1,180 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/profile_cache.dart'; +import 'package:tane/services/social_connection.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/ui/your_people_screen.dart'; + +import '../support/test_support.dart'; + +/// In-memory [TrustTransport] with a mutable certification graph. +class FakeTrustTransport implements TrustTransport { + FakeTrustTransport(this.selfId); + final String selfId; + final List certs = []; + + void addCert(String issuer, String subject) => certs.add(Certification( + issuer: issuer, + subject: subject, + issuedAt: DateTime(2026), + )); + + @override + Future> allCertifications() async => List.of(certs); + + @override + Future> certifiersOf(String subjectPubkey) async => { + for (final c in certs) + if (c.subject == subjectPubkey) c.issuer, + }; + + @override + Future certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }) async => + addCert(selfId, subjectPubkey); + + @override + Future revoke({required String subjectPubkey}) async => certs + .removeWhere((c) => c.issuer == selfId && c.subject == subjectPubkey); + + @override + Future close() async {} +} + +/// A [SocialSession] stand-in that only carries the trust transport — the +/// screen under test never touches the other transports. +class FakeSession implements SocialSession { + FakeSession(this.trust); + + @override + final TrustTransport trust; + + @override + OfferTransport get offers => throw UnimplementedError(); + @override + MessageTransport get messages => throw UnimplementedError(); + @override + RatingTransport get ratings => throw UnimplementedError(); + @override + ReportTransport get reports => throw UnimplementedError(); + @override + ProfileTransport get profile => throw UnimplementedError(); + @override + PlantareTransport get plantares => throw UnimplementedError(); + @override + SyncTransport get sync => throw UnimplementedError(); + @override + Future close() async {} +} + +void main() { + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + final peerA = 'aa' * 32; + final peerB = 'bb' * 32; + + late SocialService social; + + setUp(() async { + social = await SocialService.fromRootSeedHex(seedHex); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + Widget app(SocialConnection connection, {ProfileCache? cache}) => + TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: YourPeopleScreen( + social: social, + connection: connection, + profileCache: cache, + ), + ), + ); + + SocialConnection online(TrustTransport trust) => SocialConnection( + social: social, + settings: SocialSettings(InMemorySecretStore()), + open: (_) async => FakeSession(trust) as SocialSession, + ); + + testWidgets('offline shows the friendly note', (tester) async { + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: don't hit the network + final connection = SocialConnection(social: social, settings: settings); + + await tester.pumpWidget(app(connection)); + await tester.pumpAndSettle(); + + expect( + find.text("You're offline — try again when you're connected"), + findsOneWidget, + ); + }); + + testWidgets('lists who you vouch for and who vouches for you, with names', + (tester) async { + final trust = FakeTrustTransport(social.publicKeyHex) + ..addCert(social.publicKeyHex, peerA) // you vouch for A + ..addCert(peerB, social.publicKeyHex) // B vouches for you + ..addCert(peerA, peerB); // unrelated edge, must not show + final cache = ProfileCache(InMemorySecretStore()); + await cache.setName(peerA, 'Alice'); + + await tester.pumpWidget(app(online(trust), cache: cache)); + await tester.pumpAndSettle(); + + expect(find.text('You vouch for'), findsOneWidget); + expect(find.text('They vouch for you'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); // cached name wins + expect(find.text(shortPubkey(peerB)), findsOneWidget); // fallback + expect(find.byKey(Key('yourPeople.given.$peerA')), findsOneWidget); + expect(find.byKey(Key('yourPeople.received.$peerB')), findsOneWidget); + }); + + testWidgets('empty states show gentle notes', (tester) async { + await tester + .pumpWidget(app(online(FakeTrustTransport(social.publicKeyHex)))); + await tester.pumpAndSettle(); + + expect( + find.textContaining("You don't vouch for anyone yet"), + findsOneWidget, + ); + expect(find.text('No one vouches for you yet'), findsOneWidget); + }); + + testWidgets('revoking removes the vouch after confirming', (tester) async { + final trust = FakeTrustTransport(social.publicKeyHex) + ..addCert(social.publicKeyHex, peerA); + + await tester.pumpWidget(app(online(trust))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Stop vouching')); + await tester.pumpAndSettle(); + expect(find.text('Stop vouching for this person?'), findsOneWidget); + + await tester.tap(find.text('Stop vouching').last); // dialog action + await tester.pumpAndSettle(); + + expect(trust.certs, isEmpty); + expect( + find.textContaining("You don't vouch for anyone yet"), + findsOneWidget, + ); + }); +} diff --git a/apps/app_seeds/tool/collect_screenshots.sh b/apps/app_seeds/tool/collect_screenshots.sh new file mode 100755 index 0000000..3b887b7 --- /dev/null +++ b/apps/app_seeds/tool/collect_screenshots.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Copies the localized golden screenshots into the two places that consume them: +# 1. the Hugo landing site (site/assets/screenshots//) +# 2. the Play Store metadata (fastlane/metadata/android//images/phoneScreenshots/) +# +# Regenerate the PNGs first: +# flutter test --update-goldens --tags screenshots test/screenshots/screenshots_test.dart +# then run this from apps/app_seeds/: +# tool/collect_screenshots.sh +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # apps/app_seeds +repo_root="$(cd "$here/../.." && pwd)" +goldens="$here/test/screenshots/goldens" +site_dir="$repo_root/site/assets/screenshots" +fastlane_dir="$here/fastlane/metadata/android" + +if [[ ! -d "$goldens" ]]; then + echo "No goldens at $goldens — run the screenshots test with --update-goldens first." >&2 + exit 1 +fi + +# App locale (golden dir) -> Play Store locale (fastlane dir). 'rtl' is a layout +# demo only (not a real locale) and goes to the site, not the stores. +declare -A play_locale=( + [en]=en-US [es]=es-ES [fr]=fr-FR [de]=de-DE [pt]=pt-PT [ja]=ja-JP +) + +for dir in "$goldens"/*/; do + locale="$(basename "$dir")" + + # 1) Landing site: every locale, including the RTL demo. + mkdir -p "$site_dir/$locale" + cp "$dir"*.png "$site_dir/$locale/" + + # 2) Play Store: only locales that ALREADY have text metadata (a title.txt). + # A locale dir with images but no store text is invalid for `fastlane supply`, + # so we don't create image-only locales — add the text first, then it's picked up. + play="${play_locale[$locale]:-}" + if [[ -n "$play" && -f "$fastlane_dir/$play/title.txt" ]]; then + shots="$fastlane_dir/$play/images/phoneScreenshots" + mkdir -p "$shots" + # Number them so the store shows them in a deliberate order. + i=1 + for name in home inventory market calendar detail; do + [[ -f "$dir$name.png" ]] && cp "$dir$name.png" "$shots/$i.png" && i=$((i + 1)) + done + echo " store: $play ($((i - 1)) shots)" + fi + echo "site: $locale" +done + +echo "Done. To add a Play locale beyond en-US/es-ES, create its title/short/full" +echo "description text under fastlane/metadata/android// first, then re-run." diff --git a/apps/app_seeds/tool/gen_species_catalog.dart b/apps/app_seeds/tool/gen_species_catalog.dart index f0bda85..b0e7846 100644 --- a/apps/app_seeds/tool/gen_species_catalog.dart +++ b/apps/app_seeds/tool/gen_species_catalog.dart @@ -39,7 +39,7 @@ const String _endpoint = 'https://query.wikidata.org/sparql'; /// Wikimedia asks bots to send a descriptive, contactable User-Agent. const String _userAgent = - 'TanemakiSpeciesCatalogBot/1.0 (https://tanemaki.app; vjrj@comunes.org)'; + 'TaneSpeciesCatalogBot/1.0 (https://tane.comunes.org; vjrj@comunes.org)'; const String _catalogNote = 'Edible / cultivated plant species, generated from Wikidata (CC0) by ' diff --git a/apps/app_seeds/web/index.html b/apps/app_seeds/web/index.html index 94ce61d..cede84b 100644 --- a/apps/app_seeds/web/index.html +++ b/apps/app_seeds/web/index.html @@ -24,13 +24,13 @@ - + - Tanemaki + Tane diff --git a/apps/app_seeds/web/manifest.json b/apps/app_seeds/web/manifest.json index a7db6ff..3630c6e 100644 --- a/apps/app_seeds/web/manifest.json +++ b/apps/app_seeds/web/manifest.json @@ -1,5 +1,5 @@ { - "name": "Tanemaki", + "name": "Tane", "short_name": "Tane", "start_url": ".", "display": "standalone", diff --git a/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc b/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc index 15293a4..8162265 100644 --- a/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc +++ b/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc @@ -6,16 +6,22 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/apps/app_seeds/windows/flutter/generated_plugins.cmake b/apps/app_seeds/windows/flutter/generated_plugins.cmake index b7ef470..3db1f1a 100644 --- a/apps/app_seeds/windows/flutter/generated_plugins.cmake +++ b/apps/app_seeds/windows/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus file_selector_windows flutter_secure_storage_windows + geolocator_windows sqlcipher_flutter_libs url_launcher_windows ) diff --git a/apps/app_seeds/windows/runner/Runner.rc b/apps/app_seeds/windows/runner/Runner.rc index 41772ee..9dbe41d 100644 --- a/apps/app_seeds/windows/runner/Runner.rc +++ b/apps/app_seeds/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "Comunes" "\0" - VALUE "FileDescription", "Tanemaki — local-first traditional-seed inventory" "\0" + VALUE "FileDescription", "Tane — local-first traditional-seed inventory" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "tane" "\0" VALUE "LegalCopyright", "Copyright (C) 2026 Comunes. Licensed under AGPL-3.0." "\0" VALUE "OriginalFilename", "tane.exe" "\0" - VALUE "ProductName", "Tanemaki" "\0" + VALUE "ProductName", "Tane" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/docs/design/backup-and-recovery.md b/docs/design/backup-and-recovery.md index 7735abc..9535dcd 100644 --- a/docs/design/backup-and-recovery.md +++ b/docs/design/backup-and-recovery.md @@ -1,16 +1,16 @@ -# Tanemaki — Copias de seguridad y recuperación +# Tane — Copias de seguridad y recuperación *Nota de diseño (discusión). Responde al hueco crítico nº 1 de [VISION.md](../../VISION.md): si cada persona es su propio banco, perder el móvil no puede significar perder el banco.* ## El planteamiento -"El banco eres tú." Tu inventario, tu identidad (tu clave) y tu historial viven en tu teléfono. Si el móvil se rompe, se pierde o se cambia, hace falta poder **recuperarlo todo** en otro. Y como Tanemaki **no tiene servidor central** (por sostenibilidad y por privacidad), la copia no puede vivir "en la nube de Tanemaki": tiene que ir a un sitio que **tú** elijas y controles. +"El banco eres tú." Tu inventario, tu identidad (tu clave) y tu historial viven en tu teléfono. Si el móvil se rompe, se pierde o se cambia, hace falta poder **recuperarlo todo** en otro. Y como Tane **no tiene servidor central** (por sostenibilidad y por privacidad), la copia no puede vivir "en la nube de Tane": tiene que ir a un sitio que **tú** elijas y controles. Qué debe entrar en una copia completa: los datos del inventario (variedades, lotes, movimientos, notas), las **fotos**, la **identidad (par de claves)** y la red de confianza/Plantares que tienes. Sin la clave, recuperarías los datos pero perderías tu identidad y tu reputación; por eso viajan juntos, cifrados. ## Principios (coherentes con el proyecto) -- **Sin infraestructura de Tanemaki.** El destino de la copia lo pone la persona: un archivo, su propia nube, otro dispositivo. Nada obligatorio en servidores nuestros. +- **Sin infraestructura de Tane.** El destino de la copia lo pone la persona: un archivo, su propia nube, otro dispositivo. Nada obligatorio en servidores nuestros. - **Privada por defecto.** La copia va cifrada (lleva tu clave privada y tus datos). Nadie más debería poder leerla. - **Para humanos de 10 a 80.** "Guarda una copia de tu banco", no "exporta la base de datos cifrada". Metáfora amable: guardar las semillas a buen recaudo. Recordatorios suaves ("hace 2 meses que no haces copia"). - **Degrada con elegancia.** La copia más simple (un archivo) funciona sin red y sin cuentas. Las opciones más cómodas se añaden encima, nunca como requisito. @@ -18,7 +18,7 @@ Qué debe entrar en una copia completa: los datos del inventario (variedades, lo ## Mecanismos, en capas (de lo simple a lo potente) ### 1. Exportar / importar un archivo *(base, Fase 1)* -Un botón: **"Guardar una copia de mi banco"** → un único archivo `.tanemaki` (un zip con los datos + fotos + la clave, cifrado con una frase de paso). La persona lo guarda donde quiera: se lo manda a su propio correo, a un USB, a su nube, lo copia a otro móvil. **"Restaurar desde una copia"** en el móvil nuevo lo devuelve todo, identidad incluida. Sin infraestructura, sin cuentas, totalmente privado. Es la red de seguridad mínima y debe existir desde el inventario (Fase 1). +Un botón: **"Guardar una copia de mi banco"** → un único archivo `.tane` (un zip con los datos + fotos + la clave, cifrado con una frase de paso). La persona lo guarda donde quiera: se lo manda a su propio correo, a un USB, a su nube, lo copia a otro móvil. **"Restaurar desde una copia"** en el móvil nuevo lo devuelve todo, identidad incluida. Sin infraestructura, sin cuentas, totalmente privado. Es la red de seguridad mínima y debe existir desde el inventario (Fase 1). *Contra:* es manual (la gente se olvida) y el archivo es sensible (por eso, cifrado). ### 2. Copia a una carpeta que tú sincronizas *(muy alineado con tu ethos)* diff --git a/docs/design/chat-storage.md b/docs/design/chat-storage.md new file mode 100644 index 0000000..502d74d --- /dev/null +++ b/docs/design/chat-storage.md @@ -0,0 +1,88 @@ +# Chat storage — spec + +*Where 1:1 chat history lives on each device, and why it is a separate encrypted database from the inventory.* + +## Problem + +Chat history was persisted as a **JSON blob per conversation in the OS keystore** +(`flutter_secure_storage`), via `MessageStore`. That is the wrong tool once a +conversation is used: + +- **Read-modify-write on every message.** Each incoming/sent message read the + whole conversation JSON, decoded it, checked duplicates in O(n), re-encoded + the whole list, and wrote it back — O(n) per message, O(n²) over a + conversation. +- **Silent data loss.** A hard cap of 200 messages per peer dropped the oldest + without warning. It was a recent-cache, not storage. +- **Keystore misuse.** The OS keystore (Android `EncryptedSharedPreferences`, + iOS Keychain) is for small secrets (keys, tokens), not growing blobs. +- **Manual index.** Peers were tracked in a `\n`-joined string because the + keystore can't enumerate keys; the inbox (`conversations()`) then read every + peer's full blob just to get its last message. + +## Decision + +Move chat history to an **encrypted Drift/SQLCipher database**, in its own file +(`tane_chat.sqlite`) — `ChatDatabase`, separate from the inventory +`AppDatabase` (`tane_inventory.sqlite`). Same SQLCipher key (from the OS +keystore), so "no plaintext at rest" holds. + +### Why a *separate* database, not a new table in `AppDatabase` + +Chat is **not inventory**. It is an ephemeral, per-device cache of what the +relays delivered; it never enters an inventory snapshot, a `.tane` backup, +or the device-to-device sync. Keeping it out of `AppDatabase` means: + +- It carries **no CRDT metadata** (no HLC `updatedAt`, tombstones, `lastAuthor`, + `schemaRowVersion`) — those are meaningless for a network cache. +- It stays out of the inventory's **versioned migration series** (no `v9` bump, + no `drift_schema_v9.json`, no new `migration_test` case). `ChatDatabase` is + `schemaVersion = 1`, created fresh; there is nothing to migrate. +- It never trips the inventory **sync stream** (`AppDatabase.tableUpdates()` + feeds `SyncService`); a message arriving can't spuriously republish the + inventory. + +### Schema — `Messages` + +One flat, indexed table (see `apps/app_seeds/lib/db/chat_database.dart`): + +| column | notes | +|-----------------|----------------------------------------------------| +| `id` | autoincrement — local rowid, also the stable tie-break for equal timestamps | +| `accountScope` | namespaces rows per social identity (was the keystore key prefix) | +| `peerPubkey` | the conversation | +| `fromPubkey` | sender (self vs peer → bubble side) | +| `body` | message text | +| `sentAt` | ms since epoch (NIP-17 timestamp) — ordering + read mark | + +- **Index** `(accountScope, peerPubkey, sentAt)` serves history and the inbox. +- **Unique key** `(accountScope, peerPubkey, fromPubkey, sentAt, body)` makes + de-duplication a DB invariant: a relay re-delivers the same gift wrap on every + (re)subscribe, and an identical row simply isn't inserted twice. + +### Behaviour vs. the old store + +`MessageStore` keeps its public API (`history` / `append` / `conversations`), so +`MessagesCubit`, `InboxService`, `UnreadService` and the inbox screen are +unchanged. What changes: + +- **`append`** is a single indexed insert (dedup-checked in a transaction), + not a whole-conversation rewrite. Returns `true` only when newly stored. +- **`history`** is uncapped, ordered by `sentAt` then `id` (stable insertion + tie-break for equal timestamps). +- **`conversations`** is one indexed scan; the newest message per peer is picked + in Dart (peer counts are small). + +## Not migrating old data + +Pre-release: there are no users, so the old keystore blobs are **not** migrated. +The keystore keys (`tane.social.chat.*`, `tane.social.chats`) are simply +abandoned. If that changes before release, a one-shot import can be added. + +## Related, deliberately not changed + +The same keystore-blob anti-pattern exists, at trivial scale, in `OfferOutbox` +(pending lot ids), `TrustReferents` (WoT referent set) and `SocialSettings` +(relay URLs). These are small, bounded, rarely-written lists — the keystore is +acceptable there. Only chat grows with everyday use, so only chat moved. If any +of those grows unexpectedly, the same Drift treatment applies. diff --git a/docs/design/core-domain-boundary.md b/docs/design/core-domain-boundary.md index 370f5aa..e702338 100644 --- a/docs/design/core-domain-boundary.md +++ b/docs/design/core-domain-boundary.md @@ -1,8 +1,8 @@ -# Tanemaki — Frontera `commons_core` ↔ dominio semillas +# Tane — Frontera `commons_core` ↔ dominio semillas *Nota de diseño (discusión). Desarrolla la Parte B de [usability-and-architecture.md](usability-and-architecture.md) y reparte las entidades de [data-model.md](data-model.md) entre el motor genérico y el dominio de semillas.* -Aquí decidimos **qué vive en el motor común y qué en la app de semillas**, y con qué mecanismo se enchufan. Es la decisión que más amarra el resto, porque fija la costura que permitirá añadir mañana la "biblioteca de cosas" sin tocar Tanemaki. +Aquí decidimos **qué vive en el motor común y qué en la app de semillas**, y con qué mecanismo se enchufan. Es la decisión que más amarra el resto, porque fija la costura que permitirá añadir mañana la "biblioteca de cosas" sin tocar Tane. --- @@ -78,7 +78,7 @@ tane/ (repo, hogar: ~/dev/tane) commons_ui/ (opcional) ← kit de widgets accesibles (targets grandes, foto-first, icono+palabra) reutilizable por futuras apps. apps/ - app_seeds/ ← Tanemaki: Species, SeedItem/SeedHolding, germinación, + app_seeds/ ← Tane: Species, SeedItem/SeedHolding, germinación, cosecha, unidades por familia, UI e i18n de semillas. ``` @@ -88,7 +88,7 @@ Drift: tablas del core en `commons_core`, tablas de extensión en `app_seeds`, m ## 7. Pragmatismo: no sobre-diseñar el motor ahora -- **Construye Tanemaki concreto primero.** Mete en `commons_core` solo lo *ya conocido-compartido* (identidad, Party, Offer, Pledge, trust, sync, discovery). Para `Item/Holding`, empieza con tabla base del core + tabla de extensión de semillas; si al llegar la app de cosas resulta incómodo, **refactorizar dentro del monorepo es barato** (todo el código está junto y se mueve de un tirón). +- **Construye Tane concreto primero.** Mete en `commons_core` solo lo *ya conocido-compartido* (identidad, Party, Offer, Pledge, trust, sync, discovery). Para `Item/Holding`, empieza con tabla base del core + tabla de extensión de semillas; si al llegar la app de cosas resulta incómodo, **refactorizar dentro del monorepo es barato** (todo el código está junto y se mueve de un tirón). - No inventes interfaces genéricas "por si acaso" para partes cuya forma real solo conocerás cuando exista la segunda app. La regla del §1 ("en la duda, al dominio") protege de la abstracción prematura. --- @@ -110,7 +110,7 @@ tane/ transport (OfferTransport/Nostr), CRDT+sync, geohash. (Item/Holding se añadirán aquí al llegar la 2ª app.) apps/ - app_seeds/ ← Tanemaki: Variety, Lot, Movement, Species, germinación, + app_seeds/ ← Tane: Variety, Lot, Movement, Species, germinación, cosecha, unidades por familia, UI e i18n, commons_ui embebido. ``` diff --git a/docs/design/data-model.md b/docs/design/data-model.md index 7016f16..1879524 100644 --- a/docs/design/data-model.md +++ b/docs/design/data-model.md @@ -1,4 +1,4 @@ -# Tanemaki — Data model (v0 draft) +# Tane — Data model (v0 draft) *Technical spec feeding the Flutter/Drift implementation. Written in English (project convention). Implements the concepts discussed in [data-notes.md](data-notes.md) and PLAN §3 / §5-bis.* @@ -151,6 +151,11 @@ The digital version of the paper Plantare. Bilateral, signed, held by both parti | `debtor_signature` / `creditor_signature` | TEXT | Cryptographic signatures (both stubs of the paper). | | `status` | enum | `open` / `returned` / `forgiven`. Framed as a promise, not a debt (microcopy). | +> **Shipped vs. planned.** `app_seeds` ships the local, one-sided version (variety link + +> dates + `direction`/`counterparty` free text); the key/signature/`movement_id` columns +> and the two-party handshake are **deferred**. Full spec of the bilateral signed record: +> [plantare-bilateral.md](plantare-bilateral.md). + ### 2.8 `Offer` — a published, discoverable listing The *shop window*: a signed statement published to the network so others can discover what you share. Distinct from `Lot` on purpose — publishing reveals only what you choose (privacy). Serializes to Nostr NIP-99 (`kind:30402`) or ActivityPub. Full rationale in [sharing-model.md](sharing-model.md). @@ -172,7 +177,7 @@ The *shop window*: a signed statement published to the network so others can dis | `author_key` | TEXT | Publishing public key. | | *(+ common columns)* | | | -A closed deal may produce a `Movement` (`given`) and, if it is an exchange with a return promise, a `Plantare`. The app never processes payments, takes commissions, or holds funds (keeps Tanemaki neutral infrastructure, not a central market operator). +A closed deal may produce a `Movement` (`given`) and, if it is an exchange with a return promise, a `Plantare`. The app never processes payments, takes commissions, or holds funds (keeps Tane neutral infrastructure, not a central market operator). ## 3. Entity map @@ -229,7 +234,7 @@ Together: Drift handles the *local database* migration; the compatibility rules ## 7. Interchange export/import (Phase 1) — decided & implemented -The user-triggered **interchange export** (Settings → Backup). Distinct from the future encrypted full backup (`.tanemaki`, [backup-and-recovery.md](backup-and-recovery.md) mechanism 1): the interchange files are the *only* plaintext the app ever writes, and only because the user explicitly asks for them. Import reads the picked file straight into memory — no temp copies. +The user-triggered **interchange export** (Settings → Backup). Distinct from the future encrypted full backup (`.tane`, [backup-and-recovery.md](backup-and-recovery.md) mechanism 1): the interchange files are the *only* plaintext the app ever writes, and only because the user explicitly asks for them. Import reads the picked file straight into memory — no temp copies. Two formats, one canonical: diff --git a/docs/design/data-notes.md b/docs/design/data-notes.md index 9684065..0d5fde1 100644 --- a/docs/design/data-notes.md +++ b/docs/design/data-notes.md @@ -1,4 +1,4 @@ -# Tanemaki — Notas de diseño: nombres, ciencia, notas y registro de banco +# Tane — Notas de diseño: nombres, ciencia, notas y registro de banco *Documento de reflexión previo al modelo de datos formal. Lengua: español (el código irá en inglés).* *Refina y expande la Capa 1 del [PLAN.md](../../PLAN.md) §3.* @@ -73,7 +73,7 @@ Con eso, el **catálogo empaquetado se licencia CC-BY** (Wikidata CC0 + GBIF CC- ### Consecuencia de diseño: los nombres de variedad los pone la comunidad -El **banco de nombres de especie** (capa 4 de §2) viene de Wikidata/GBIF. Pero la **variedad/cultivar** (capa 3, la que más importa a quien guarda semillas) **la teclea la gente**, no una autoridad. Con el tiempo, los nombres de variedad compartidos entre usuarios de Tanemaki (de forma agregada/opcional) van formando **el propio banco folclórico de nombres de Tanemaki** — descentralizado, vivo, y que ninguna base oficial puede darte. Es coherente con todo el proyecto: la ciencia formal se toma prestada (CC0/CC-BY), pero el conocimiento de las variedades tradicionales es de la red, no de un registro. +El **banco de nombres de especie** (capa 4 de §2) viene de Wikidata/GBIF. Pero la **variedad/cultivar** (capa 3, la que más importa a quien guarda semillas) **la teclea la gente**, no una autoridad. Con el tiempo, los nombres de variedad compartidos entre usuarios de Tane (de forma agregada/opcional) van formando **el propio banco folclórico de nombres de Tane** — descentralizado, vivo, y que ninguna base oficial puede darte. Es coherente con todo el proyecto: la ciencia formal se toma prestada (CC0/CC-BY), pero el conocimiento de las variedades tradicionales es de la red, no de un registro. Nota (posible puente futuro, no ahora): como anclamos a GBIF/Wikidata, ese banco folclórico —taxonomía de variedades y nombres vernáculos— es justo lo que los portales abiertos de biodiversidad (GBIF, atlas ALA) tienen más flojo. Deja la puerta a un export *opt-in* estilo Darwin Core, con guardas estrictas de privacidad. No es fase ni modelo; detalle e intención en [open-decisions.md](open-decisions.md) §C. @@ -95,7 +95,7 @@ Principios (coherentes con todo lo demás): - **Fuentes y privacidad.** Prioriza datos abiertos (Wikidata/GBIF/Permapeople). Si en el futuro se usa reconocimiento de foto (sugerir especie a partir de una imagen de semilla/planta) o un servicio de IA para resumir cuidados, es **opcional, con consentimiento** (la foto/el nombre solo salen del dispositivo si la persona lo pide) y **sustituible** (nada atado a un proveedor cerrado). - **Datos de conservación** (viabilidad en años, secado, guardado) merecen una pequeña tabla curada por especie/familia, empaquetada y ampliable online — es justo el saber que se está perdiendo y que la app puede ayudar a preservar. -Encaje con la arquitectura: la varilla es **de dominio** (`app_seeds`), porque "cuidados" y "conservación" son específicos de semillas; el motor genérico no sabe de eso. Es una de las piezas que más diferencian a Tanemaki de una libreta: reduce a un toque lo que en papel era investigar y copiar a mano. +Encaje con la arquitectura: la varilla es **de dominio** (`app_seeds`), porque "cuidados" y "conservación" son específicos de semillas; el motor genérico no sabe de eso. Es una de las piezas que más diferencian a Tane de una libreta: reduce a un toque lo que en papel era investigar y copiar a mano. ### ¿Es viable la "varilla mágica" con datos abiertos? Sí diff --git a/docs/design/first-sprint.md b/docs/design/first-sprint.md index 134fb02..bd543ca 100644 --- a/docs/design/first-sprint.md +++ b/docs/design/first-sprint.md @@ -1,4 +1,4 @@ -# Tanemaki — Primer sprint (Fase 1: inventario) +# Tane — Primer sprint (Fase 1: inventario) *Guía de arranque para desarrollar en **Claude Code** (en la máquina, donde Flutter corre y git funciona). Objetivo: un **inventario usable, offline y cifrado**. Nada de capa social todavía. Convenciones y decisiones en [`../../CLAUDE.md`](../../CLAUDE.md) y [`open-decisions.md`](open-decisions.md).* diff --git a/docs/design/g1-integration.md b/docs/design/g1-integration.md index 9d92826..042ebd7 100644 --- a/docs/design/g1-integration.md +++ b/docs/design/g1-integration.md @@ -1,4 +1,4 @@ -# Tanemaki — Integración opcional de Ğ1 (moneda libre) +# Tane — Integración opcional de Ğ1 (moneda libre) *Nota de diseño (discusión). Explora integrar opcionalmente la moneda libre Ğ1 (Duniter) para facilitar intercambios, sin excluir el regalo, el Plantare, el trueque ni otras monedas. Conecta con [sharing-model.md](sharing-model.md) §4 y con [network-trust.md](network-trust.md) §2.* @@ -6,13 +6,13 @@ No es un añadido oportunista: hay **tres coincidencias profundas**. -1. **Misma red de confianza.** Tanemaki ya adopta una WoT **estilo Duniter** (network-trust §2). Ğ1 *es* esa WoT hecha moneda. Integrarla no es pegar un sistema ajeno: es la misma filosofía expresada como dinero. +1. **Misma red de confianza.** Tane ya adopta una WoT **estilo Duniter** (network-trust §2). Ğ1 *es* esa WoT hecha moneda. Integrarla no es pegar un sistema ajeno: es la misma filosofía expresada como dinero. 2. **Misma ética.** Ğ1 es libre, descentralizada, no especulativa (dividendo universal, no acumulativa). Casa con el proyecto donde el euro no llega, y resuena con el espíritu del Plantare ("mantener en circulación, no acaparar"). -3. **Mismo stack, y experiencia propia.** Duniter v2 (lanzado el 7 de marzo de 2026, sobre Substrate) trae **Ğecko**, cartera móvil en **Flutter** —igual que Tanemaki—, y **Ğ1nkgo**, del propio autor de este proyecto. Hay código Dart/Flutter y conocimiento directo que reutilizar: la integración **no parte de cero**. +3. **Mismo stack, y experiencia propia.** Duniter v2 (lanzado el 7 de marzo de 2026, sobre Substrate) trae **Ğecko**, cartera móvil en **Flutter** —igual que Tane—, y **Ğ1nkgo**, del propio autor de este proyecto. Hay código Dart/Flutter y conocimiento directo que reutilizar: la integración **no parte de cero**. ## El gradiente de reciprocidad -Tanemaki no elige "regalo vs. dinero": ofrece un **abanico**, del más procomún al más convencional, y la interfaz lo presenta en ese orden (lo comunitario primero): +Tane no elige "regalo vs. dinero": ofrece un **abanico**, del más procomún al más convencional, y la interfaz lo presenta en ese orden (lo comunitario primero): **regalo → Plantare (devolver algo similar) → trueque → Ğ1 (moneda libre) → euro (moneda convencional)** @@ -20,7 +20,7 @@ Tanemaki no elige "regalo vs. dinero": ofrece un **abanico**, del más procomún ## Ğ1 como primer campo de prueba (y solución al arranque en frío) -En la comunidad Ğ1 **ya hay grupos de semillas**, pero lo que falla es justo **la app y la red para intercambiar**. Es decir: existe una comunidad motivada, afín en valores, **ya en red y con confianza montada (la WoT) y con moneda (Ğ1)** — a la que solo le falta la herramienta que Tanemaki construye. Esto convierte a los grupos de semillas de Ğ1 en el **piloto perfecto**: +En la comunidad Ğ1 **ya hay grupos de semillas**, pero lo que falla es justo **la app y la red para intercambiar**. Es decir: existe una comunidad motivada, afín en valores, **ya en red y con confianza montada (la WoT) y con moneda (Ğ1)** — a la que solo le falta la herramienta que Tane construye. Esto convierte a los grupos de semillas de Ğ1 en el **piloto perfecto**: - **Resuelve el arranque en frío (hueco 2 de VISION)** para ese subconjunto casi de un plumazo: no hay que crear la comunidad ni la confianza, ya existen; se les da la pieza que falta. - **Prueba las tres capas a la vez con gente real:** inventario, compartición y —único sitio donde podemos probarlo de verdad— la **integración Ğ1** (niveles 1–3) y la WoT compartida. @@ -31,9 +31,9 @@ Estrategia sugerida: **primer piloto dentro de los grupos de semillas de Ğ1**, ## Principios de la integración - **Opcional y plural.** Ğ1 es *una* opción más. Nunca obligatoria, nunca el defecto. El regalo y el Plantare siguen siendo de primera clase. -- **Sin custodia ni rieles de pago en la app** (coherente con sharing-model §4.3). Tanemaki **no** mueve fondos: anuncia y conecta; el valor se transfiere en la **cartera del usuario** (Ğecko/Cesium²/Ğ1nkgo), fuera de la app. Así seguimos siendo infraestructura neutral, sin KYC/AML. -- **Inclusión intacta.** Usar Tanemaki **no** exige ser miembro de Ğ1. La persona de 80 que solo cambia tomates no toca Ğ1 jamás. Ğ1 es una **capa opcional** para quien ya está en ese mundo. -- **Acoplamiento con cuidado.** Reusar la WoT/identidad de Ğ1 es potente, pero no debe volver a Tanemaki dependiente de Duniter para funcionar. Ğ1 informa/enriquece; no manda. +- **Sin custodia ni rieles de pago en la app** (coherente con sharing-model §4.3). Tane **no** mueve fondos: anuncia y conecta; el valor se transfiere en la **cartera del usuario** (Ğecko/Cesium²/Ğ1nkgo), fuera de la app. Así seguimos siendo infraestructura neutral, sin KYC/AML. +- **Inclusión intacta.** Usar Tane **no** exige ser miembro de Ğ1. La persona de 80 que solo cambia tomates no toca Ğ1 jamás. Ğ1 es una **capa opcional** para quien ya está en ese mundo. +- **Acoplamiento con cuidado.** Reusar la WoT/identidad de Ğ1 es potente, pero no debe volver a Tane dependiente de Duniter para funcionar. Ğ1 informa/enriquece; no manda. ## Niveles de integración (de menos a más) @@ -41,19 +41,19 @@ Estrategia sugerida: **primer piloto dentro de los grupos de semillas de Ğ1**, 2. **Enlace a la cartera (deep-link).** Botón "Pagar en Ğ1" que abre **Ğecko / Cesium² / Ğ1nkgo** con destinatario e importe ya rellenos (URI de pago). La app no toca fondos; solo entrega el testigo. Riesgo bajo, UX buena — y conoces esos esquemas de URI de primera mano. -3. **Identidad y confianza compartidas (la sinergia profunda).** Como la WoT de Duniter v2 está en cadena e indexada (duniter-squid, GraphQL), Tanemaki podría **opcionalmente** vincular tu identidad Ğ1 e **importar tu pertenencia y certificaciones** como señal de confianza: un miembro Ğ1 llega **pre-avalado** a Tanemaki. Pero manteniendo la WoT propia y ligera para quien no es de Ğ1 (inclusión). Ğ1 como **fuente opcional de confianza que suma**, no como requisito. +3. **Identidad y confianza compartidas (la sinergia profunda).** Como la WoT de Duniter v2 está en cadena e indexada (duniter-squid, GraphQL), Tane podría **opcionalmente** vincular tu identidad Ğ1 e **importar tu pertenencia y certificaciones** como señal de confianza: un miembro Ğ1 llega **pre-avalado** a Tane. Pero manteniendo la WoT propia y ligera para quien no es de Ğ1 (inclusión). Ğ1 como **fuente opcional de confianza que suma**, no como requisito. -4. **Pago dentro de la app (descartado).** Firmar transferencias Ğ1 desde Tanemaki cruzaría la línea de "sin rieles de pago" y añadiría custodia/complejidad. Mejor el nivel 2 (delegar en la cartera). +4. **Pago dentro de la app (descartado).** Firmar transferencias Ğ1 desde Tane cruzaría la línea de "sin rieles de pago" y añadiría custodia/complejidad. Mejor el nivel 2 (delegar en la cartera). ## La WoT: independiente pero compatible (respuesta a "¿podría ser compatible?") -La disyuntiva no es "depender de Ğ1" (fácil, pero excluye a los no-Ğ1 y acopla) contra "independiente" (inclusivo, pero costoso de construir). Hay un **punto medio**, como el correo electrónico —tu propio servidor, pero compatible con SMTP—: **una WoT propia de Tanemaki, estructuralmente compatible con la de Duniter.** Y ser compatible **abarata** lo difícil, porque reutilizas diseño probado y tu propio código. +La disyuntiva no es "depender de Ğ1" (fácil, pero excluye a los no-Ğ1 y acopla) contra "independiente" (inclusivo, pero costoso de construir). Hay un **punto medio**, como el correo electrónico —tu propio servidor, pero compatible con SMTP—: **una WoT propia de Tane, estructuralmente compatible con la de Duniter.** Y ser compatible **abarata** lo difícil, porque reutilizas diseño probado y tu propio código. Cómo se logra: -1. **Mismo primitivo de identidad.** Usar el mismo tipo de clave que Duniter v2 (cuentas Substrate). Así una identidad Ğ1 es técnicamente comprensible por Tanemaki y **reutilizas la cripto de Ğ1nkgo/Ğecko**, sin depender de la red Ğ1. -2. **Mismo modelo de certificación.** Modelar la WoT de Tanemaki como la de Duniter (certificaciones firmadas "A avala a B", regla de N certificaciones + distancia). Es una WoT estilo Duniter, pero **tu propia instancia/grafo**. Esto abarata el "hazlo independiente": reutilizas un diseño probado desde 2017, no inventas uno. -3. **Importar la WoT de Ğ1 en un solo sentido.** Leer el grafo on-chain de Ğ1 (duniter-squid, GraphQL) para *informar* la confianza en Tanemaki: un miembro Ğ1 llega **pre-avalado**. Tanemaki **no escribe** en la cadena de Ğ1 (eso sí acoplaría). Import unidireccional = compatible **sin** dependencia. +1. **Mismo primitivo de identidad.** Usar el mismo tipo de clave que Duniter v2 (cuentas Substrate). Así una identidad Ğ1 es técnicamente comprensible por Tane y **reutilizas la cripto de Ğ1nkgo/Ğecko**, sin depender de la red Ğ1. +2. **Mismo modelo de certificación.** Modelar la WoT de Tane como la de Duniter (certificaciones firmadas "A avala a B", regla de N certificaciones + distancia). Es una WoT estilo Duniter, pero **tu propia instancia/grafo**. Esto abarata el "hazlo independiente": reutilizas un diseño probado desde 2017, no inventas uno. +3. **Importar la WoT de Ğ1 en un solo sentido.** Leer el grafo on-chain de Ğ1 (duniter-squid, GraphQL) para *informar* la confianza en Tane: un miembro Ğ1 llega **pre-avalado**. Tane **no escribe** en la cadena de Ğ1 (eso sí acoplaría). Import unidireccional = compatible **sin** dependencia. 4. **Una sola clave para todo (como en Duniter).** Una única identidad —una clave Duniter-v2/Substrate— hace *todo*: perfil, ofertas, mensajes, certificaciones y, si te opt-in, pagos Ğ1 y pertenencia a la WoT. Nada de claves separadas por función. **Tener la clave ≠ ser miembro de Ğ1:** una persona no-Ğ1 (la abuela) tiene una clave que simplemente **no está certificada** en la WoT (= "desconocida", encaja con network-trust §2) y nunca toca la moneda. Un miembro Ğ1 **reusa su misma clave** y llega pre-avalado. Así, una sola clave *e* inclusión total. *Privacidad y peor caso legal:* usar la misma clave enlaza tu actividad de semillas con tu identidad Ğ1 pública. Para la mayoría (tomates en España) es indiferente. Para el caso sensible, la protección real es que **tener no es publicar**: las variedades delicadas viven **locales y cifradas**, y publicar una oferta es opt-in por ítem — así la clave nunca las expone en la red. Como escape adicional, quien *publique* algo sensible y no quiera ligarlo a su Ğ1 puede usar una **clave seudónima distinta** (opción avanzada), pero la norma es **una sola clave**. diff --git a/docs/design/network-trust.md b/docs/design/network-trust.md index 25efda6..8461c7c 100644 --- a/docs/design/network-trust.md +++ b/docs/design/network-trust.md @@ -1,4 +1,4 @@ -# Tanemaki — Red, confianza y mensajería (capa social descentralizada) +# Tane — Red, confianza y mensajería (capa social descentralizada) *Nota de diseño (discusión). Resuelve los huecos 2–6 del anexo de [VISION.md](../../VISION.md): cómo arranca la red, cómo se teje la confianza, quién sostiene el descubrimiento y cómo se comunican las personas. Se apoya en la Capa 3–4 del [PLAN.md](../../PLAN.md) y en [sharing-model.md](sharing-model.md).* @@ -13,16 +13,26 @@ Una app de compartición local no sirve si eres la única persona en 50 km. Dos **Primer campo de prueba: los grupos de semillas de Ğ1.** En la comunidad Ğ1 ya hay grupos de semillas, pero les falla la app y la red para intercambiar. Son el **piloto ideal**: comunidad motivada, afín, **ya en red y con confianza (WoT) y moneda (Ğ1) montadas** — solo les falta la herramienta. Para ese subconjunto, el arranque en frío está casi resuelto. Detalle en [g1-integration.md](g1-integration.md). -## 2. Red de confianza estilo Duniter (huecos 3 y 4) +## 2. Confianza egocéntrica (huecos 3 y 4) -Modelo inspirado en **Duniter / Ğ1**: cualquiera participa desde el minuto uno, pero empieza como **"desconocido"**. Cuando **N personas** que ya son miembros te **certifican** (una firma: "doy fe de esta persona"), entras en el área de **"gente conocida"** (miembro de la red de confianza). Duniter usa ~5 certificaciones + una regla de distancia; el umbral es un **parámetro ajustable**. +> **Revisión 2026-07-11.** La primera versión de esta sección proponía la regla *global* de membresía de Duniter (N certificaciones + distancia desde referentes fundadores curados). Se descartó: esa regla resuelve identidad sybil-proof para una renta básica — un problema que Tane no tiene — y exige curar referentes por comunidad/país, inviable en una app internacional (¿quién cura los fundadores de Japón, Brasil, Marruecos?). Un set de referentes malo es peor que ninguno. Ver `open-decisions.md`. -- **Certificar = acto presencial y consciente** ("te conocí, respondo por ti"). Encaja con §1: las ferias generan certificaciones. -- **Resuelve el arranque de confianza (hueco 3):** al recién llegado no se le bloquea, solo se le marca como desconocido hasta acumular avales. -- **Resuelve la moderación (hueco 4):** spammers y estafadores se quedan en "desconocido" y se filtran o despriorizan solos; la red de confianza pone en cuarentena **sin autoridad central**. Encima se suma la **reputación** (valoraciones tras un trato cerrado). -- **UI para 10–80:** mostrar "conocido / desconocido" con lenguaje humano ("aún nadie de tu confianza responde por esta persona"), nunca jerga de grafos. +**Modelo egocéntrico:** los avales ("doy fe de esta persona") son públicos, firmados y compartidos — todo el mundo ve el mismo grafo — pero **no hay veredicto global**: cada persona calcula la confianza **desde su propia posición**. Es el modelo que funcionó en la práctica en PGP (la validez se calcula desde TU clave) y el que usa el ecosistema Nostr ("seguido por gente que sigues"). -*A decidir:* umbral de certificaciones (¿5?), regla de distancia, caducidad de certificaciones, y si un banco/colectivo puede certificar como entidad. +- **Certificar = acto presencial y consciente** ("te conocí, respondo por ti"). Encaja con §1: las ferias generan avales. En la UI es un toque: "Conozco a esta persona", en el chat. +- **Tu círculo:** tú, la gente que avalas, y la gente que ellos avalan (distancia ≤2). El chat lo muestra en humano: "En tu círculo" / "Avalada por N" / "Nadie la avala aún". +- **Cold-start resuelto de raíz (hueco 3):** valor desde el primer aval, día uno, en cualquier país. Un colectivo arranca su propio racimo sin pedir permiso ni esperar referentes. Al recién llegado no se le bloquea, solo se le marca como desconocido. +- **Moderación sin autoridad central (hueco 4):** un atacante puede crear mil claves que se avalen entre sí, pero ese racimo queda desconectado de *tu* vista — siguen siendo desconocidos para ti. Para colarse en tu círculo necesita un aval presencial de alguien de tu entorno (la escasez de "aristas de ataque" protege por observador, como en la investigación anti-sybil por grafo social). Los avales son públicos: avalar a un estafador tiene coste social. Caducan (365 días) y se renuevan, podando confianza rancia. +- **Reputación encima:** valoraciones tipo Wallapop (§2.1). +- **UI para 10–80:** "Tu gente" (a quién avalas / quién te avala), lenguaje humano, nunca jerga de grafos ni parámetros. + +**Qué se pierde honestamente:** el badge global "miembro de la red" y la garantía una-persona-una-identidad — herencia del problema de Duniter (RBU), no del nuestro. **Puerta no quemada:** el primitivo de certificación sigue siendo estructuralmente compatible con Duniter (una certificación viva por par, con caducidad); una capa futura de "confianza por comunidad" (una red de semillas define sus propias raíces y su propia regla) o una importación opcional de la WoT de Ğ1 pueden construirse encima del mismo motor sin migración. + +### 2.1 Reputación: valoraciones tipo Wallapop + +Los avales no cubren la estafa desde dentro del círculo; las valoraciones sí. **V1 (implementada):** estrellas 1–5 + comentario corto sobre una persona, públicas y firmadas; **una valoración viva por par** (re-valorar edita, no acumula — un mismo autor no puede amontonar reseñas); retirable. Anclaje blando: solo puedes valorar a alguien con quien tienes conversación. Se muestran en el detalle de oferta y en el chat, **ponderadas por tu círculo** ("N de gente que conoces") — las reseñas de desconocidos no se destacan, así que inflarlas con claves falsas no sirve de nada. **Evolución prevista:** anclaje fuerte a un trato cerrado con el formulario bilateral firmado (ver `sharing-model.md` §6). + +*A decidir:* si un banco/colectivo puede certificar como entidad (ver sharing-model §6). ## 3. Relays y descubrimiento: ¿la app como relay? (hueco 5) diff --git a/docs/design/open-decisions.md b/docs/design/open-decisions.md index 9b7f532..161b9b2 100644 --- a/docs/design/open-decisions.md +++ b/docs/design/open-decisions.md @@ -1,7 +1,9 @@ -# Tanemaki — Decisiones abiertas y "qué chirría" +# Tane — Decisiones abiertas y "qué chirría" *Revisión de conjunto antes de seguir construyendo. Reúne los cabos sueltos repartidos por los docs de diseño, ordenados por cuándo hay que decidirlos, y nombra las tensiones del plan que conviene mirar de frente.* +> **Actualización (2026-07-10) — se abre la ronda social (Block 2).** El spike de de-risking ([spike-block2-findings.md](spike-block2-findings.md)) validó el happy-path completo de la capa social: derivación de la clave Nostr desde la semilla Ğ1, `OfferTransport`/NIP-99, mensajería NIP-17 (privada y con metadatos ocultos) y red de confianza estilo Duniter, todo sobre **una `NostrConnection` compartida con tres interfaces**. Con eso, la puerta de "no empezar Block 2" queda **levantada**. Orden de construcción dentro de Block 2: **(1) base de transporte en `commons_core`** (sobre librerías contrastadas — `nostr`, Dart puro, LGPL) → (2) UI de ofertas → (3) endurecer mensajería (entrega offline, vectores NIP-44, multidispositivo) → (4) parámetros WoT + arranque en frío. Los ítems de §B siguen siendo las decisiones a cerrar; §D.3 y §D.7 quedan **resueltos** por el spike. + ## A) Decidir antes de escribir código de la Fase 1 (inventario) Estas fijan `schemaVersion = 1` y el arranque técnico: @@ -19,13 +21,18 @@ Estas fijan `schemaVersion = 1` y el arranque técnico: - **Transporte de la capa social → Nostr (confirmado).** No se renuncia a Nostr: **Duniter no tiene mensajería**, así que Nostr es la vía para mensajes y ofertas. Se mantiene "una sola identidad" derivando la clave **secp256k1** (Nostr) de la semilla raíz Ğ1. Falta: confirmar madurez de los NIPs y la ruta de derivación. (Datapods de Duniter descartados: no están en servicio.) → [g1-integration.md](g1-integration.md) - **Estrategia de relays:** app-as-relay oportunista + proximidad física + relays comunitarios. → network-trust §3 -- **Parámetros de la red de confianza:** umbral de certificaciones (¿5, estilo Duniter?), distancia, caducidad, certificación por colectivo. → network-trust §2 +- **Confianza egocéntrica sustituye a la WoT global — DECIDIDO/IMPLEMENTADO (2026-07-11), SUPERSEDE la decisión del 2026-07-10.** La membresía Duniter completa (referentes curados + `WotParams` sigQty/stepMax, pantalla de raíces npub y steppers) se retiró de la app: resolvía identidad sybil-proof para una RBU — problema que Tane no tiene —, exigía curar referentes por comunidad/país (inviable internacionalmente; un set de referentes malo es peor que ninguno) y su pantalla violaba "palabras humanas, nunca jerga". **Queda solo la vista egocéntrica**: tú avalas ("Conozco a esta persona", en el chat) / tu círculo (distancia ≤2 desde ti, umbral 1) / avalado por N / desconocido. Cero bootstrap, cero parámetros de usuario; los avales caducan (365 días) y se renuevan. Un racimo sybil queda desconectado de la vista de cada observador — el filtro antispam se conserva sin veredicto global. **Se mantiene sin cambios** el primitivo (`Certification`, kind Nostr 30777, una cert viva por par) y el motor puro `WebOfTrust` en `commons_core` (usado para el círculo; capacidad latente para una futura "confianza por comunidad" o importación opcional de la WoT Ğ1 — sin migración de eventos). **UI:** badge por `TrustTier` (en tu círculo > avalado > desconocido) en el chat; pantalla "Tu gente" (a quién avalas —revocable— / quién te avala) desde el perfil, sustituyendo a "Red de confianza". Eliminados `TrustReferents`, `WotSettings`, `TrustNetworkScreen` y el asset de referentes. Certificación por colectivo sigue en sharing-model §6. → network-trust §2 +- **Valoraciones tipo Wallapop v1 — DECIDIDO/IMPLEMENTADO (2026-07-11).** Estrellas 1–5 + comentario corto, públicas y firmadas (`Rating`, kind Nostr **30778** direccionable, `d` = sujeto): **una valoración viva por par** — re-valorar edita, no acumula; retirable. **Anclaje blando v1:** solo puedes valorar a alguien con quien tienes conversación (el anclaje fuerte a trato cerrado firmado sigue abierto, ver "Reputación" abajo). Se muestran en el detalle de oferta y en el chat **ponderadas por tu círculo** ("N de gente que conoces", mismo cálculo egocéntrico) — inflar reseñas con claves desconocidas no destaca nada; sin valoraciones no se muestra nada (la ausencia nunca es un reproche). Quinto transporte (`RatingTransport`) sobre el canal compartido. → network-trust §2.1 - **Mensajería:** alcance v1 (1:1 atada a oferta), apoyo en NIP-17. → network-trust §4 -- **Reputación** atada a un trato/oferta cerrada (evitar reseñas falsas). → sharing-model §6 + - **Almacenamiento del historial de chat — RESUELTO/IMPLEMENTADO (2026-07-11).** El historial 1:1 se guarda en una **BD Drift/SQLCipher aparte** (`tane_chat.sqlite` / `ChatDatabase`), no en el keystore (era un blob JSON por conversación con read-modify-write O(n²), tope silencioso de 200 e índice manual). BD separada a propósito: el chat es **caché de red efímera por dispositivo**, no inventario — sin metadatos CRDT, fuera de la serie de migraciones del inventario, fuera del sync/backup. `append` = un insert indexado (dedup por clave única), historial sin tope. Pre-release: no se migran los blobs viejos del keystore. Mismo antipatrón (a escala trivial) en `OfferOutbox`/`SocialSettings` → keystore aceptable ahí; solo el chat crece con el uso. → [chat-storage.md](chat-storage.md) +- **Reputación: anclaje fuerte** a un trato/oferta cerrada (evitar reseñas falsas del todo) — la v1 con anclaje blando por conversación ya está (ver arriba); el anclaje fuerte espera al formulario bilateral firmado. → sharing-model §6 - **Precio:** ¿monedas comunitarias / de tiempo además de dinero? → sharing-model §6 - **Integración Ğ1 (moneda libre):** niveles 1–2 (precio en Ğ1 + enlace a cartera Ğecko/Cesium²/Ğ1nkgo) en la capa social; nivel 3 (reusar la WoT de Ğ1 como fuente de confianza) como estudio aparte. → [g1-integration.md](g1-integration.md) - **Ofertas de banco colectivo** (publica la entidad, no la persona). → sharing-model §6 - **Caducidad/revocación** de ofertas ya replicadas en relays. → sharing-model §6 +- **Cambiar de identidad social — RESUELTO/IMPLEMENTADO (2026-07-10).** Se eligió una refinación de la vía (A) que **mantiene "backup UNA semilla"**: identidades seudónimas **derivadas de la misma semilla raíz con un índice de cuenta** (`NostrKeyDerivation.deriveFromSeed(seed, {account})`, HKDF one-way → no vinculables a Ğ1; **cuenta 0 = la identidad actual, byte-for-byte, sin rotar a nadie**). La cuenta activa se persiste en el keystore (`SocialAccountStore`), los stores por-identidad (chats/perfil/nombres) se **namespacian por cuenta** (cuenta 0 = claves legacy, sin migración), y `switchSocialAccount()` re-registra la porción social del DI y reinicia el inbox; la UI (perfil → "Tus identidades", crear/cambiar) aplica el cambio con un `RestartWidget` que reconstruye el árbol leyendo los singletons nuevos. La app NO toca la DB/inventario. Descartada la vía (B) reset total (perdía identidad+recuperación). → [[project-block2-spike]] + +- **Paquete legal + moderación mínima — DECIDIDO/IMPLEMENTADO (2026-07-13).** Se escribe la capa legal completa en [`docs/legal/`](../legal/README.md): política de privacidad, condiciones de uso, normas de la comunidad y aviso sobre legalidad de semillas (masters en inglés + espejo es), más docs internos de cumplimiento (Play Data Safety/UGC, notas Apple, memoria jurídica con calendario de revisión — el PRM europeo sigue en trílogos). Decisiones que fija: **(a)** consentimiento único **al entrar al mercado por primera vez** (hoja modal con las normas; el inventario local no pide nada — progressive disclosure), con la misma bandera guardando también publicar-oferta y primer DM; **(b)** **bloqueo local** de claves (keystore, sin migración de esquema): filtra ofertas, chats y DMs entrantes; **(c)** **denuncias estándar NIP-56** (kind 1984) publicadas a los relays + ocultación local; en `relay.comunes.org` (nuestro) se actúa sobre ellas — esa es la historia de "moderación con respuesta" para la revisión de Play; **(d)** se reafirma **cero comisiones** sobre semillas y **Ğ1 solo como etiqueta de precio** (sin flujos de pago in-app, precedente Damus/Apple); **(e)** borrado honesto: NIP-09 es best-effort y así se cuenta al usuario. Metadata de tienda en `fastlane/metadata/android/` (la reutiliza F-Droid). ## C) Puede esperar (no bloquea nada ahora) @@ -41,11 +48,11 @@ Estas fijan `schemaVersion = 1` y el arranque técnico: 1. **La capa social es indivisible y grande.** "MVP = inventario + compartición local" subestima que "compartir" de verdad = **ofertas + mensajería + relays + red de confianza**, todo junto e interdependiente. Propuesta: replantear las fases como **(1) inventario** —entregable, sólido, en solitario— y **(2) el salto social** —un bloque grande, no un incremento—. Ser honestos con esto en el plan y en la financiación. -2. **Casi toda la dificultad vive en `commons_core`.** Identidad, sync CRDT, ofertas, pledge, red de confianza, **mensajería**, relays… `app_seeds` es, en comparación, ligero. En el fondo Tanemaki es **infraestructura de procomún** con las semillas como primera aplicación. Es buena noticia para NLnet/NGI Zero (financian infraestructura), pero hay que asumir que lo "sencillo" (semillas) es la punta del iceberg y comunicar el proyecto en consecuencia. +2. **Casi toda la dificultad vive en `commons_core`.** Identidad, sync CRDT, ofertas, pledge, red de confianza, **mensajería**, relays… `app_seeds` es, en comparación, ligero. En el fondo Tane es **infraestructura de procomún** con las semillas como primera aplicación. Es buena noticia para NLnet/NGI Zero (financian infraestructura), pero hay que asumir que lo "sencillo" (semillas) es la punta del iceberg y comunicar el proyecto en consecuencia. 3. **Apuesta fuerte por Nostr.** Ofertas, mensajes y confianza dependerían de NIPs en evolución (99 / 17 / 85). La abstracción `OfferTransport` amortigua las ofertas, pero mensajería y confianza también se apoyan en Nostr → más acoplamiento del que sugería el plan. Revisar si un solo protocolo lo cubre bien o si conviene aislar también mensajería y confianza tras interfaces. -4. **Cifrado en reposo + sync CRDT + multidispositivo.** Hay que asegurar que la sincronización **no filtra la llave ni datos en claro**, y que la llave (QR) llega bien al segundo dispositivo. Encaja, pero es un punto técnico fino que conviene prototipar pronto. +4. **Cifrado en reposo + sync CRDT + multidispositivo — EN CURSO (base landed 2026-07-10).** El modelo ya está listo para CRDT (HLC, tombstones, LWW, Movement append-only) y el reconciliador de import prueba el merge; faltaba el **transporte de sync**. Primera rebanada en `commons_core`: `SyncTransport` (mueve snapshots opacos y cifrados entre los dispositivos de UNA identidad) + `NostrSyncTransport` (NIP-78 kind 30078 addressable/reemplazable por dispositivo, contenido **NIP-44 cifrado a sí mismo** → el relay solo ve cifrado; filtrado a tu propia clave de autor). Tests con MiniRelay: replica al otro dispositivo, el cable solo ve cifrado, otra identidad no recibe ni podría descifrar, re-push reemplaza. **Confirma la garantía de §4**: no filtra llave ni datos en claro. PENDIENTE (siguiente rebanada, app): `SyncService` que serialice el inventario (reusa `ExportImportService`), empuje en mutaciones, y en recepción corra `importInventory` (LWW idempotente ya existente); id de dispositivo estable por instalación (keystore, NO el nodeId derivado de la semilla, que es común a los dispositivos); integrar `sync` en `SocialSession` (5ª interfaz sobre la conexión compartida); UI de conflictos (raro). 5. **La "varilla" y los datos de conservación** necesitan una fuente y una curación que **aún no tenemos**. Puede ser más trabajo del que parece (y es, a la vez, de lo que más valor humano aporta). diff --git a/docs/design/plantare-bilateral.md b/docs/design/plantare-bilateral.md new file mode 100644 index 0000000..42a82ba --- /dev/null +++ b/docs/design/plantare-bilateral.md @@ -0,0 +1,201 @@ +# Plantaré — the bilateral signed record + +Status: **v1 shipped (2026-07-14).** The two-sided signed Plantaré — the "formulario +bilateral firmado" other docs waited on — is now built end-to-end: transport in +`commons_core`, schema v12 + repo, `PlantareService`, and UI (propose from a chat, +accept/decline on the ledger). What's left is noted under "Deferred to a follow-up" +below. This doc keeps the original design for reference; the "Open questions" are now +resolved inline. + +Built (commit series `feat(plantare): …`, 2026-07-14): +- **Transport** — `PlantarePledge` (canonical, byte-stable core + two BIP-340 Schnorr + stubs), `PlantareCrypto` (sign/verify over SHA-256 of the core), `PlantareTransport` + + `NostrPlantareTransport` (propose→accept→counter-sign / decline over NIP-17 gift wrap + carrying a tagged JSON payload; the message inbox skips these and vice-versa). +- **Schema v12** — the deferred columns on `Plantares` (`pledgeId`, `debtorKey`, + `creditorKey`, `debtorSignature`, `creditorSignature`, `movementId`, `remoteState`, + `returnKind`, `workHours`), all nullable/defaulted so v1 local rows are untouched; + guarded migration + schema dump + migration test. +- **`PlantareService`** — signs my stub, sends, and reconciles incoming moves into the + ledger (verifies stubs before storing; pending proposals held in memory keyed by + pledge id, relay redelivers on reconnect). +- **UI** — propose sheet reached from the chat overflow menu; accept/decline + handshake + badges (awaiting signature / signed by both / declined) on the Plantares screen. + +This doc originally specified the two-sided Plantaré so a later session could build it +without re-deciding. It is the "formulario bilateral firmado" that other docs wait on: + +- [`data-model.md` §2.7](data-model.md) — the `Plantare` entity, whose `debtor_key`, + `creditor_key`, `debtor_signature`/`creditor_signature` and `movement_id` columns are + specified there but **deferred** in the shipped Drift table. +- [`sharing-model.md` §1, §6](sharing-model.md) — the Plantaré as the *private bilateral + close* of an exchange; and the open question of anchoring reputation to a closed deal. +- [`PLAN.md` §5-bis](../../PLAN.md) — the paper original (BAH-Semillero, 2009): "partida + doble repartida entre dos bolsillos", device-to-device, provenance DAG. +- [`open-decisions.md`](open-decisions.md) — strong rating anchoring "espera al formulario + bilateral firmado". + +## What ships today (v1, local-only) + +A private local ledger only. `apps/app_seeds` `Plantares` table stores `varietyId`, +`direction` (`iReturn` / `owedToMe`), `counterparty` (**free text**), `owedDescription`, +`madeOn`, `dueBy`, `status`, `note`. No identity keys, no signatures, no transport — the +other party gets **no record**. The UI now surfaces the linked variety and the dates +(Phase 1), but it is still one-sided. + +## The paper original (v0.4, transcribed) + +Source: BAH-Semillero, *Plantaré — moneda comunitaria de intercambio de semilla*, v4.0, +© 2009–2010, CC-BY-SA 3.0 (`plantare.ourproject.org`). The printed form is **two matching +stubs** ("partida doble") on one sheet: + +**Stub A — "CORTAR Y ENTREGAR A CAMBIO DE LAS SEMILLAS"** (cut off and handed over with the +seeds; the **giver keeps it** as proof of what they're owed): +- **Yo** ______ (your name) +- **localizable en** ______ (*email, tel., etc.* — contact info) +- **recibo** ______ (*cantidad de semillas o lo que sea que recibo*) +- **y me comprometo a devolver antes de (mes/año):** __ / 20__ +- return options (*choose those that apply*): + - `[ ]` una **cantidad similar\*** de la recibida — *(\*) similar = no hibridada, no + transgénica, cultivada en ecológico* + - `[ ]` ó un **trabajo de ___ horas** equivalente + - `[ ]` ó ______ (free text — anything else) +- **Firmado,** ______ **el** __ / __ / 20__ (signature + date) + +**Stub B — "RESGUARDO QUE ME QUEDO"** (the receipt the **receiver keeps** to remember what +they owe): person/collective owed, quantity received, and "lo devolveré antes de __/20__". + +What the paper captures that v1 does **not** yet: +- **Contact** of the party ("localizable en") — v1 stores only a free-text name. Digitally + this becomes the **Nostr pubkey** (reachable by DM); keep a free-text contact for the + offline/paper case. +- **Structured return options** instead of one free-text line: *similar quantity* (default), + *equivalent work in N hours*, or *other*. The work-hours option is time-as-currency — it + overlaps Tane's hand-over payment model, which already allows a time/`tiempo` currency + ([project-sales]; €/Ğ1/time/none). +- **"Similar" is a defined term** — open-pollinated, non-GMO, organically grown — which ties + to Tane's existing organic/eco self-declaration flag. The default return preset should + carry this meaning, not just the word. +- **Return-by is month/year**, matched to cultivation cycles (v1 stores a full day; Phase 1's + picker is day-precision — display can round to month/year). + +## The goal + +Turn the one-sided note into the paper Plantaré's **two matching signed stubs**: when two +people close an exchange, both devices end up holding the *same* commitment, each signed by +both, so either can prove "recibí X, devolveré una cantidad similar antes de la fecha Y". + +Non-goals: interest, enforcement, debt framing (microcopy stays "promise, not debt"); +a central server or global ledger (it stays device-to-device / relay-as-enrichment). + +## Identity — counterparty becomes a real key + +Replace (or rather, *augment*) the free-text `counterparty` with the counterparty's **Nostr +pubkey / `Party`**, reusing the identity already present in the social layer (offers, chat). +The flow starts from an existing conversation or a closed offer, so the pubkey is in hand. +Keep the free-text field as the offline/paper fallback (a Plantaré made face-to-face with +someone who has no app, or ingested from a paper form / QR). + +## Schema delta (versioned Drift migration) + +Add the deferred columns to `Plantares`, nullable so v1 rows coexist untouched: + +- `debtorKey` TEXT — pubkey of who received and owes a return. +- `creditorKey` TEXT — pubkey of who gave. +- `debtorSignature` / `creditorSignature` TEXT — signatures over the canonical payload + (both "stubs of the paper"). +- `movementId` TEXT → `Movement` — the shared `given` event this accompanies (the + `Movement.plantareId` link already exists on the other side). +- `remoteState` enum — `proposed` / `accepted` / `declined`, distinct from `status` + (`open`/`returned`/`forgiven`), which tracks the *promise*, not the *handshake*. + +From the paper form: +- ~~`contact` (the paper's "localizable en", email/phone)~~ — **decided NOT needed** + (2026-07-13). It's a paper-era artifact: digitally the counterparty is a Nostr pubkey, + reachable by DM, so a manual contact field is redundant. Offline/paper Plantarés keep it + only inside the free-text `counterparty`. +- `returnKind` enum — `similar` / `workHours` / `other`, mirroring the form's checkboxes + (default `similar`, meaning open-pollinated · non-GMO · organically grown — surface that + definition, not just the word). `workHours` REAL for the "trabajo de N horas" option; + `owedDescription` still backs `other`. Note the work-hours option overlaps Tane's hand-over + time/`tiempo` currency — reconcile the two rather than duplicating. Kept for the Phase 2 + build; not added to v1. + +A v1 local row = both keys/signatures null, `remoteState` null. Migration test in CI per +[`data-model.md` §5](data-model.md). + +## Transport — `PlantareTransport` in `commons_core` + +A new interface alongside `OfferTransport` / `MessageTransport` / `TrustTransport` / +`RatingTransport` / `SyncTransport` / `ReportTransport` +(`packages/commons_core/lib/src/social/`), on the shared `NostrConnection`. Unlike ratings +(public signed events), a Plantaré is **private to the two parties**, so it is *encrypted*, +like DMs/sync. Sketch, mirroring the existing `*Transport` shape: + +```dart +abstract interface class PlantareTransport { + /// Send a signed proposal (my signature only) to [counterpartyPubkey]. + Future propose(PlantarePledge pledge); + /// Counter-sign a received proposal and send the fully-signed copy back. + Future accept(PlantarePledge pledge); + /// Decline a received proposal. + Future decline({required String pledgeId, String reason}); + /// Incoming proposals / accepts / declines addressed to this identity. + Stream incoming(); + Future close(); +} +``` + +**Handshake:** propose → accept → counter-sign. A proposes (signs, encrypts to B); B reviews, +accepts (adds B's signature), sends the doubly-signed copy back; both persist the identical +row (`remoteState = accepted`). Decline leaves A's row `declined`. + +**Channel:** prefer **NIP-17 encrypted DM** (already used for messaging) carrying a canonical +JSON payload — least new surface, rides the existing connection. A dedicated event kind is an +alternative if we want first-class filtering. **Offline QR/NFC** device-to-device (PLAN.md's +favoured, most-decentralized path) is the same payload exchanged without a relay — the +transport abstracts channel from payload. Relays are enrichment only; offline must fully work. + +## Provenance DAG + +Each accepted Plantaré links parent→child (this lot came from that exchange), forming the +variety's distributed **procedence DAG** ([PLAN.md §5-bis](../../PLAN.md)) — "un grafo de +commits, pero de semillas". `movementId` + the counterparty's prior movement give the edge. + +## Reputation anchor + +An `accepted` Plantaré is the **strong anchor** for a rating (both parties provably closed a +real deal), upgrading the current soft, per-conversation anchoring — closing the +[`open-decisions.md`](open-decisions.md) / [`network-trust.md`](network-trust.md) item. + +## Resolved in the v1 build (2026-07-14) + +- **Conflict / divergence:** RESOLVED as anticipated — the two signatures cover the + canonical core (everything except the signatures), and the `pledgeId` is part of that + core. Any edit changes the signing hash, so an accept always closes the *exact* payload + proposed; a changed pledge is a different id and must be re-proposed. `PlantareCrypto` + enforces this; the "editing after signing invalidates the stub" test pins it. +- **Revocation / forgiveness sync:** kept **one-sided** for v1 — `status` + (`open`/`returned`/`forgiven`) stays each party's own view, distinct from the shared + `remoteState` handshake. Propagating a forgive to the counterparty is a follow-up (it'd + need a signed "released" move). +- **Expiry:** `dueBy` passing is a **UI nudge only** (overdue styling), as in Phase 1 — it + does not change `remoteState`. +- **Paper ingest:** the schema already supports it (nullable keys/signatures + free-text + `counterparty`); no UI path yet — deferred. +- **Group/collective party:** deferred — still tied to the collective-identity question in + `sharing-model.md` §6. + +## Deferred to a follow-up + +- **Durable send outbox.** Propose/accept send best-effort over the current session; if + offline the local row is recorded but the move isn't queued for retry. A durable outbox + (like `offer_outbox`) should back it so a proposal made offline goes out on reconnect. +- **Accept after restart before redelivery.** Pending proposals live in memory (refilled by + NIP-17 redelivery on reconnect); accepting one the relay hasn't redelivered yet shows a + gentle "offline / reconnecting" nudge rather than reconstructing the pledge from the row. +- **Rating anchor.** An `accepted` Plantaré is now a provable closed deal, but the rating + flow isn't yet wired to require/prefer it (the "strong anchor" upgrade). +- **Provenance DAG.** `movementId` is stored; building the distributed procedence graph + from accepted pledges is not done. +- **Forgiveness/paper/group** as above. diff --git a/docs/design/security-privacy.md b/docs/design/security-privacy.md index fb9d728..64828a2 100644 --- a/docs/design/security-privacy.md +++ b/docs/design/security-privacy.md @@ -1,4 +1,4 @@ -# Tanemaki — Seguridad y privacidad (modelo de amenaza) +# Tane — Seguridad y privacidad (modelo de amenaza) *Nota de diseño (discusión). Nace del "peor caso legal": una persona en un país donde una variedad (p.ej. cannabis) está prohibida debe poder usar la app **sin dejar datos en claro**. Extiende y matiza [backup-and-recovery.md](backup-and-recovery.md) y la postura legal del [PLAN.md](../../PLAN.md) §6.* diff --git a/docs/design/sharing-model.md b/docs/design/sharing-model.md index fe969aa..ea960c9 100644 --- a/docs/design/sharing-model.md +++ b/docs/design/sharing-model.md @@ -1,4 +1,4 @@ -# Tanemaki — Modelo de compartición y mercado (nota de diseño) +# Tane — Modelo de compartición y mercado (nota de diseño) *Documento de reflexión. Lengua: español (discusión). Desarrolla la Capa 3 del [PLAN.md](../../PLAN.md) §3–§4 y §6, y conecta con el modelo de datos ([data-model.md](data-model.md)).* @@ -56,13 +56,13 @@ Permitir vender es legítimo y realista —hay quien produce semilla artesanal, ### 4.2 Legal: vender reengancha justo el problema Kokopelli Esto es lo delicado. La **venta** de variedades tradicionales no registradas es exactamente lo que multó a Kokopelli; el regalo/trueque entre aficionados o agricultores está mucho más protegido (PLAN §6). Al añadir "venta" aumentas la exposición legal *de tus usuarios*. Mitigaciones de diseño: -- **Tanemaki no es un mercado, es infraestructura neutral.** La app no opera el mercado, no publica un catálogo central, no pone en venta nada: son las personas, entre iguales (como flohmarkt). Esta neutralidad es la mejor defensa (replica por qué Kokopelli fue vulnerable —venta + operador central identificable— y evita ambas). +- **Tane no es un mercado, es infraestructura neutral.** La app no opera el mercado, no publica un catálogo central, no pone en venta nada: son las personas, entre iguales (como flohmarkt). Esta neutralidad es la mejor defensa (replica por qué Kokopelli fue vulnerable —venta + operador central identificable— y evita ambas). - **Aviso contextual por región, no policía.** Al marcar una Offer como "venta", la app puede mostrar una nota suave y dependiente del país ("vender semilla de variedades no registradas puede estar restringido en tu país"). Informar, no bloquear, no vigilar. - **La venta prioriza el marco de conservación/compartición** en el discurso y el diseño; el dinero es una opción, no la portada. ### 4.3 Nada de rieles de pago (esto es innegociable) - **La app NO procesa pagos, NO cobra comisión, NO retiene dinero, NO muestra publicidad.** El pago se acuerda **directamente entre las personas** (efectivo, su propia transferencia, lo que sea), *fuera* de la app. -- Meter pagos dentro convertiría a Tanemaki en operador central: obligaciones KYC/AML, custodia de fondos, y reintroduce justo la centralización que se quiere evitar. Además mataría la sostenibilidad (§7: financiación por subvenciones/donaciones, no por comisiones). +- Meter pagos dentro convertiría a Tane en operador central: obligaciones KYC/AML, custodia de fondos, y reintroduce justo la centralización que se quiere evitar. Además mataría la sostenibilidad (§7: financiación por subvenciones/donaciones, no por comisiones). - Resultado: es el "efecto Wallapop sin Wallapop" también para la venta — anuncia y conecta; el dinero cambia de manos entre personas. ### 4.4 Representación del precio @@ -103,7 +103,7 @@ Nueva entidad publicable (se serializa a NIP-99 / ActivityPub). En inglés, para ## 6. A decidir -- ¿Las valoraciones (reputación) se atan a un `Movement`/`Offer` cerrado para evitar reseñas falsas? (Probablemente sí, Capa 4.) +- ¿Las valoraciones (reputación) se atan a un `Movement`/`Offer` cerrado para evitar reseñas falsas? (Probablemente sí, Capa 4.) El ancla fuerte es el Plantaré bilateral firmado — spec en [plantare-bilateral.md](plantare-bilateral.md). - ¿Permitir monedas comunitarias/tiempo explícitamente en `price_currency` (guiño al espíritu Plantare como "moneda de semillas")? Interesante. - ¿Ofertas de banco colectivo (publica el banco, no la persona)? Afecta a identidad/permisos (Capa 2–3). - ¿Cómo se revoca/expira una Offer publicada en relays que ya la replicaron? (Estado `closed` + caducidad; los relays acaban soltando lo viejo.) diff --git a/docs/design/spike-block2-findings.md b/docs/design/spike-block2-findings.md new file mode 100644 index 0000000..1102749 --- /dev/null +++ b/docs/design/spike-block2-findings.md @@ -0,0 +1,295 @@ +# Spike — Block 2 (social layer) de-risking findings + +*Throwaway research spike. Language: English (findings), per code convention. +The throwaway spike code (`spike/block2_spike/`) has been moved out of this +public repo and preserved privately — it was **not production, not on the pub +workspace, no deps added to Block 1**. This document is the deliverable: what we learned, the risks, and a +recommendation on whether/how to take on the social round (Phase 3).* + +> **Status: this is a spike, not a green light to build Block 2.** CLAUDE.md and +> [open-decisions.md](open-decisions.md) §D.1 are explicit: the social layer is +> big, indivisible, and needs its own funded round. This spike only lowers the +> unknowns before that round is committed. + +## What the spike answered + +The three open decisions from [open-decisions.md](open-decisions.md) §D.3 and +[g1-integration.md](g1-integration.md) "A decidir": + +| # | Open question | Verdict | +|---|---|---| +| 1 | Can we derive a Nostr (secp256k1) key from the Ğ1 root seed, one-way & reproducible? | **Yes — low risk.** | +| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — and the "one connection, three interfaces" shape is now demonstrated in code.** | +| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** | +| 3b | Does NIP-17 private, metadata-hiding messaging actually work? | **Yes — prototyped end to end. Risk drops from "unbuilt unknown" to "production hardening".** | +| 4 | Does a Duniter-style web of trust work over Nostr, and can it filter spam? | **Yes — certify/discover + threshold-and-distance membership + offer filtering all prototyped.** | + +All of it runs with tests (30 passing). All three transport contracts — +offers, messaging, trust — plus the pure trust-graph logic are exercised. +See "How to run" at the end. + +--- + +## Q1 — Identity derivation (secp256k1 from the Ğ1 root seed) + +**Result: works, deterministic, one-way. This unblocks "one identity, one backup".** + +- Implemented `NostrKey.deriveFromSeed`: + `HKDF-SHA256(rootSeed, info: "org.comunes.tane/nostr/secp256k1/v1")` → reduce + to a valid secp256k1 scalar `d ∈ [1, n-1]` by rejection (bump a counter in the + HKDF info on the astronomically-rare miss, so the result stays a pure function + of the seed) → BIP340 x-only pubkey → NIP-19 `npub`/`nsec`. +- The root seed is exactly what + [`IdentityService.generateRootSeed`](../../packages/commons_core/lib/src/identity/identity_service.dart) + already produces (32 bytes). The spike test feeds a real `IdentityService` + seed straight in — no shape mismatch. +- **Reproducibility:** same seed → identical private key, pubkey, and `npub` + (test `is reproducible`). The user backs up ONE thing (the seed QR); the + secp256k1 key regenerates on demand. Confirms the [g1-integration.md](g1-integration.md) + §"Resuelto" bet. +- **One-wayness:** derivation runs the seed through HMAC-SHA256, which is not + invertible; the seed never appears in the derived bytes, and unrelated seeds + never collide (tested). So publishing an `npub` cannot expose the Ğ1 identity + or the seed. (Simultaneous *use* of both identities can still be correlated by + an observer — that residual risk is unchanged and is what the "pseudonymous + key" advanced option in [g1-integration.md](g1-integration.md) §"peor caso" + exists for.) +- **The derived key really signs:** BIP340 sign+verify round-trips (test), i.e. + a real relay/peer would accept events from this key. + +**Design decisions this firms up (for the social round):** +- Fix the derivation `info` string and version it (done: `…/v1`), the same + discipline `BackupBox` already uses. Changing it later would silently rotate + everyone's Nostr identity, so it is effectively part of `schemaVersion`. +- Derivation belongs in `commons_core` (identity is generic). In production it + should reuse the `cryptography` package's HKDF already vendored there, not the + spike's hand-rolled one. + +**Residual risk: low.** The only real decision left is cosmetic/standardisation: +whether to align the derivation path with an existing convention (e.g. a +SLIP-0010/BIP32-style path or Cesium's "derive encryption key from signing key" +precedent) so other tools could reproduce it. Not a blocker — our scheme is +self-consistent and one-way today. + +--- + +## Q2 — `OfferTransport` abstraction & the privacy seam + +**Result: the abstraction holds and the privacy seam is real. But messaging and +trust do NOT fit behind the same interface — they share a *connection*, not a +*contract*.** + +### The seam works (offers) +- `OfferTransport` = + `publish(Offer)` / `discover(DiscoveryQuery)` / `retract(id)`. The Nostr + NIP-99 backend (`NostrOfferTransport` + + `Nip99Codec`) sits entirely + behind it. A second backend (ActivityPub/FEP-0837) could replace it without + the domain noticing. +- `Offer` is agnostic by + construction: a chosen *summary* + coarse geohash, **no FK into seed tables, + no full inventory, no exact address** — exactly the Offer↔Lot split + [sharing-model.md](sharing-model.md) §2 and + [core-domain-boundary.md](core-domain-boundary.md) §4.3 call for. + +### The privacy seam is enforced on the wire (tested) +- The codec **coarsens the geohash** to ≤5 chars (≈±2.4 km) before it ever hits + the wire, and emits a NIP-52 prefix *ladder* (`u`, `u0`, `u09`, …) so area + queries match by exact tag while nothing finer than the cap leaks. +- Tests assert, byte-wise on the serialised event, that a slipped-in precise + geohash and a secret inventory marker **never appear**, and that there is no + `location`/`address` tag. The seam has no field that *could* carry them — + privacy is structural, not a runtime check that can be forgotten. + +### The caveat — coupling (this was the point of Q2) +[open-decisions.md](open-decisions.md) §D.3 worried Nostr couples messaging and +trust more than the plan implies. The spike confirms the worry is **real but +manageable**: + +- Offers (NIP-99, kind 30402), **messaging** (NIP-17 DMs, kind 1059 gift-wrap), + and **trust** (NIP-85 / certifications) all reuse the *same derived key* and + the *same relay socket* (the OK-handshake, REQ/EOSE plumbing in + `NostrOfferTransport` is identical for all three). +- But their **verbs differ**: publish/browse a public listing vs. send/receive a + private encrypted DM vs. assert/read a signed certification. Forcing DMs and + certifications behind `OfferTransport.publish/discover` would overload it. +- **Recommendation — now demonstrated in code, not just asserted:** + `NostrConnection` is + **one shared** socket + key + sign + REQ/EOSE lifecycle, and **all three thin + interfaces sit on top of the same instance** — + `OfferTransport` + (NIP-99), + `MessageTransport` + (NIP-17) and + `TrustTransport` + (custom WoT). The trust test even runs offer discovery and trust annotation + over one connection. This is the shape to lift into `commons_core`: shared + connection, per-concern contract. Trying to make `OfferTransport` also carry + DMs and certifications would have overloaded it — the split is real. + +**Residual risk: low.** The abstraction is right and all three contracts are +built. The remaining risk is **scope/hardening, not feasibility**: "the social +layer" is offers + messaging + trust + relays together (§D.1 "indivisible"), and +that whole happy path now runs — what's left is production hardening (below). + +--- + +## Q3 — Publish → discover by geohash against a relay + +**Result: the flow works end to end. The real risk is NIP maturity/ecosystem, +not the mechanics.** + +- Built a hermetic in-process `MiniRelay` + (NIP-01 subset: EVENT/REQ/EOSE/CLOSE, filter by `kinds`/`authors`/`#g`, + addressable-event replacement for kind 30402). No network → CI-safe, no flaky + public-relay dependency. +- End-to-end tests (`roundtrip_test.dart`): + - Alice publishes a gift offer; **Bob (a different derived identity) discovers + it by a coarser geohash** and gets the summary + Alice's pubkey back. ✅ + - A query for a **different** area returns nothing (geohash scoping works). ✅ + - **Re-publishing the same offer id replaces, not duplicates** (addressable + events — how a real relay behaves). ✅ + - Latency measured: **~34 ms** publish+discover, in-process, cold (includes + the OK handshake + REQ/EOSE; a floor value — a real relay adds network RTT). + +### On NIP maturity and the ActivityPub fallback +- **NIP-99 (classified listings, 30402):** stable enough and used in the wild + (e.g. Shopstr). Carries title/summary/price+currency/status of the box, so + gift/exchange/sale/wanted all fit; we add an `offer_type` tag for the + reciprocity mode NIP-99 lacks. **Low risk.** +- **NIP-17 (private DMs, gift-wrap):** newer, more moving parts (sealed + + gift-wrapped, per-message ephemeral keys). **The spike DID build it** — see §4 + below. Verdict moves from "biggest unbuilt risk" to **medium (production + hardening), not high (feasibility)**. +- **NIP-85 / WoT over Nostr:** least settled. The Duniter-style certification + model maps onto signed events, but there is no dominant standard. Likely a + **custom event kind mapped to the Duniter model** (as g1-integration.md §"WoT + propia pero compatible" already anticipates). **Higher risk; design work, not + just integration.** +- **ActivityPub / FEP-0837 as fallback:** worth keeping as the *second* + `OfferTransport` backend for reach/federation, but it does **not** solve the + hard parts (private DMs, WoT) any better than Nostr, and it adds instance + hosting. Recommendation: **stay Nostr-first**, keep the transport interface so + AP can be added later for offer federation, don't invest in AP now. + +--- + +## Q3b — NIP-17 private messaging (the piece that was flagged highest-risk) + +**Result: the metadata-private DM flow works end to end. Built, tested, and it +fits the shared-connection architecture.** + +The plan ([network-trust.md](network-trust.md) §4) calls messaging "grande" and +[open-decisions.md](open-decisions.md) §D.3 singled it out as the tightest +coupling to Nostr. So the spike built it rather than hand-waving: + +- **NIP-44 v2 encryption** (`Nip44`): + secp256k1 ECDH → HKDF conversation key → ChaCha20 + HMAC-SHA256 with + length-hiding padding. Conversation key is symmetric (A→B == B→A, tested); a + wrong key cannot decrypt (throws, tested); short messages pad to the same + bucket so ciphertext length doesn't leak plaintext length (tested). +- **NIP-17 / NIP-59 gift-wrap onion** + (`NostrMessageTransport`): + rumor (kind 14, unsigned) → seal (kind 13, signed by sender, NIP-44 to + recipient) → gift wrap (kind 1059, signed by a **throwaway ephemeral key**, + NIP-44 to recipient). +- **End-to-end over the relay (tested):** + - Alice → Bob: the message arrives and Bob **authenticates the sender as + Alice** — even though the wrap was signed by an ephemeral key. + - **The relay/eavesdropper sees neither the plaintext nor the real sender:** + snooping stored kind-1059 events yields only ciphertext, an ephemeral + author, and a `p` tag for the recipient. Alice's identity is hidden. + - A third party cannot read a message not addressed to them. + +**What this de-risks:** the hard, novel part (metadata-private messaging on the +*same derived key and relay* as offers) is feasible and architecturally clean. + +**What remains (why it's still medium, not solved):** +- The NIP-44 implementation here is **structurally faithful but NOT verified + against the reference test vectors** — cross-client interop must be proven + before shipping (a real client library should be used, not this spike code). +- **Offline delivery, retries, and multi-device** (a recipient who's offline + when the wrap is published) are untouched — this is the real "messaging is + large" weight. +- **Spam/abuse** on DMs leans on the (unbuilt) web of trust. + +## Q4 — Web of trust (the last big unknown) + +**Result: a Duniter-style WoT maps cleanly onto Nostr, computes correctly from +discovered events, and filters spam. Built and tested.** + +There is no settled NIP for a web of trust, so — exactly as +[g1-integration.md](g1-integration.md) §"WoT propia pero compatible" +anticipates — the spike models **its own WoT, Duniter-compatible**, over Nostr: + +- **Certifications as events** + (`NostrTrustTransport`): + a custom addressable kind (30777) keyed by (issuer, `d`=subject), so one live + "A vouches for B" per pair; re-certifying renews, revoking replaces (tested). + Certifications expire (Ğ1 semantics) and are public (like the on-chain Ğ1 WoT). +- **The membership rule is pure and separately tested** + (`WebOfTrust`) — the + `commons_core`-worthy piece: Duniter's two rules, **N certifications from + existing members** (sigQty) and **within distance D of the bootstrap + referents** (stepMax), iterated to a fixpoint (becoming a member can push + others over the line). Tests cover threshold, distance cut-off, transitive + growth, and exclusion of expired/revoked/self certs. +- **It filters spam (the payoff, network-trust.md §2):** in a test, three seed + members certify Alice; Eve stays uncertified. Bob discovers both their offers, + then annotates authors — **Alice is "known", Eve stays "unknown"** and gets + deprioritised. Trust and offers run **on the same shared connection**, closing + the loop between Q2's architecture and this filter. + +**Residual risk: high → medium.** Feasibility is proven; what's left is *policy +and bootstrap*, not code: choosing sigQty/stepMax/expiry (network-trust.md §2 +"a decidir"), the cold-start referent set (fairs, Ğ1 seed groups — +[g1-integration.md](g1-integration.md)), whether a collective can certify as an +entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust source. + +## Overall recommendation + +1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified; + move it into `commons_core` (reusing the vendored HKDF) when the social round + starts. Version the `info` string as part of the schema. +2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try + to make `OfferTransport` also carry messaging and trust. Keep `Offer` + agnostic and the geohash-coarsening seam exactly as prototyped. +3. **Q3/Q4: Nostr-first is the right bet, and all three contracts are now + proven.** Offers (NIP-99), private messaging (NIP-17) and the web of trust + (custom, Duniter-compatible) all work on the **same shared connection** — the + happy path of the entire social layer runs end to end in this spike. What + remains is **not feasibility but hardening and policy**: interop-exact NIP-44, + offline/multi-device delivery, and the WoT parameters + cold-start + ([open-decisions.md](open-decisions.md) §D.1, §D.6). That is what should drive + the Phase-3 estimate and the funding ask. +4. **Do not start Block 2 from this spike.** It is throwaway research code (not + vector-verified, no offline delivery, no persistence, single relay). The + `spike/block2_spike/` code has been moved out of this public repo (preserved + privately) now these findings are captured. The funded social + round should **rebuild on vetted client libraries in `commons_core`**, using + the shapes proven here — one `NostrConnection`, three interfaces, the pure + `WebOfTrust` rule — and spend its risk budget on hardening and bootstrap, not + on re-proving the happy path. + +## Risk summary + +| Area | Risk | Why | +|---|---|---| +| Seed→Nostr derivation | **Low** | Works, one-way, reproducible, tested. Only standardisation cosmetics left. | +| Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. | +| Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. | +| Messaging (NIP-17) | **Medium** | Flow + metadata-privacy prototyped & tested. Left: interop-exact NIP-44, offline delivery, multi-device. | +| Web of trust (custom, Ğ1-compatible) | **Medium** | Feasibility proven (certs + membership rule + spam filter tested). Left: parameters (sigQty/stepMax/expiry) + cold-start, not code. | +| Overall scope (§D.1) | **Medium** | Social layer is indivisible & large, but the whole happy path now runs end to end; what remains is hardening + policy, not unknowns. | + +## How to run + +The throwaway spike code has been moved out of this public repo and is preserved +privately; nothing here was wired into `app_seeds` or `commons_core`'s production +graph, and the Block 1 suite is untouched. For the record, it ran as: + +```sh +cd spike/block2_spike # in the private archive +dart pub get +dart test # derivation · privacy · roundtrip · messaging · trust · web_of_trust (30 tests) +``` diff --git a/docs/design/testing.md b/docs/design/testing.md index e08d1f1..49dbb84 100644 --- a/docs/design/testing.md +++ b/docs/design/testing.md @@ -1,4 +1,4 @@ -# Tanemaki — Estrategia de pruebas (casi-TDD, sin depender de pruebas manuales) +# Tane — Estrategia de pruebas (casi-TDD, sin depender de pruebas manuales) *Convención del proyecto. Objetivo: **cada comportamiento cubierto por una prueba automática**; las pruebas manuales son la excepción, no la red de seguridad.* diff --git a/docs/design/usability-and-architecture.md b/docs/design/usability-and-architecture.md index 0ab05e6..57ede51 100644 --- a/docs/design/usability-and-architecture.md +++ b/docs/design/usability-and-architecture.md @@ -1,4 +1,4 @@ -# Tanemaki — Usabilidad y generalización (nota de diseño) +# Tane — Usabilidad y generalización (nota de diseño) *Documento de reflexión. Lengua: español (discusión). Afecta a la estructura del primer commit de código, por eso conviene decidirlo ahora.* @@ -56,17 +56,17 @@ Prior art que lo confirma: el software de "Library of Things" (Lend Engine, myTu ### La reconciliación: generaliza el MOTOR, no el PRODUCTO -Usabilidad y reutilización apuntan **a la misma respuesta**. Tanemaki (semillas) y la futura app de cosas son **productos separados, simples, monotema**, cada uno con su nombre y su cara; por debajo comparten un **motor común** que resuelve una sola vez la fontanería P2P/confianza/préstamo. Generalizas donde nadie lo ve (el engine), no donde todo el mundo lo sufre (el producto). +Usabilidad y reutilización apuntan **a la misma respuesta**. Tane (semillas) y la futura app de cosas son **productos separados, simples, monotema**, cada uno con su nombre y su cara; por debajo comparten un **motor común** que resuelve una sola vez la fontanería P2P/confianza/préstamo. Generalizas donde nadie lo ve (el engine), no donde todo el mundo lo sufre (el producto). ### ¿Ahora o después? "Diseña para generalizar, implementa uno" -- **Construye solo Tanemaki ahora.** No hagas la plataforma genérica de forma especulativa (riesgo de abstracción prematura: te sale la abstracción equivocada). +- **Construye solo Tane ahora.** No hagas la plataforma genérica de forma especulativa (riesgo de abstracción prematura: te sale la abstracción equivocada). - **Pero estructura el repo desde el primer commit como workspace** con un paquete `core/` + `app_seeds/`, respetando una **costura de dominio limpia**. Extraer más adelante la app de cosas es barato si la costura se respeta; retrofitear un motor genérico dentro de un monolito es caro. - **En `core` va solo lo obviamente compartido** (identidad, sync, `Offer`, `Party`, confianza, `Pledge`/registro de préstamo). Lo específico de semillas (`Species`, germinación, cosecha) se queda en el dominio de semillas. Se sube más a `core` solo cuando la segunda app revele la forma real compartida (regla del tres — pero el núcleo P2P/confianza/pagaré es conocido-compartido de antemano, así que extraerlo ya está justificado). ### Cómo generalizan las entidades (mapa) -| Genérico (`core`) | Semillas (Tanemaki) | Cosas (futura) | +| Genérico (`core`) | Semillas (Tane) | Cosas (futura) | |---|---|---| | `Item` / holding | `Variety` + `Lot` | herramienta / objeto | | `Offer` (+ tipo **`lend`**: devolver *el mismo* objeto) | gift/exchange/sale/wanted | lend/gift/sale/wanted | @@ -78,7 +78,7 @@ Nota: el `Plantare` es el caso semillas de un concepto genérico **`Pledge`** (p ### Nombres -- **Tanemaki** = el producto de semillas. +- **Tane** = el producto de semillas. - El **núcleo** conviene que tenga nombre neutro. Tu organización **Comunes** encaja de libro ("motor del procomún"): p.ej. paquete `commons_core` o similar. La app de cosas tendrá su propio nombre cuando llegue. ### Consecuencia técnica inmediata (por eso se decide ahora) @@ -86,14 +86,14 @@ Nota: el `Plantare` es el caso semillas de un concepto genérico **`Pledge`** (p El **primer commit de código** no es "una app Flutter", sino un **workspace** (pub workspaces / melos) con: ``` -tanemaki/ (repo) +tane/ (repo) packages/ commons_core/ ← identidad, sync, Offer, Party, Pledge, confianza, discovery apps/ - app_seeds/ ← Tanemaki: Species, germinación, cosecha, UI de semillas, i18n + app_seeds/ ← Tane: Species, germinación, cosecha, UI de semillas, i18n ``` -Drift: tablas de `core` + tablas de dominio de semillas, con las migraciones versionadas (§5 del modelo) por paquete. Así, el día que exista `app_things`, reusa `commons_core` sin tocar Tanemaki. +Drift: tablas de `core` + tablas de dominio de semillas, con las migraciones versionadas (§5 del modelo) por paquete. Así, el día que exista `app_things`, reusa `commons_core` sin tocar Tane. --- diff --git a/docs/i18n.md b/docs/i18n.md index dd21deb..400dbf8 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -1,6 +1,6 @@ -# Translating Tanemaki +# Translating Tane -Tanemaki is multilingual **by design** — international, not any-country-first +Tane is multilingual **by design** — international, not any-country-first (see the golden rules in [`../CLAUDE.md`](../CLAUDE.md)). Translations live as one JSON file per locale, a format Weblate (and any translation platform that speaks nested JSON) handles natively. @@ -10,11 +10,15 @@ speaks nested JSON) handles natively. ``` apps/app_seeds/lib/i18n/ en.i18n.json ← base locale (source of truth for keys) - es.i18n.json - pt.i18n.json + es.i18n.json ast, pt, fr, de, ja … strings*.g.dart ← generated, do not edit ``` +Live locales: `es`, `ast`, `en`, `pt`, `fr`, `de`, `ja`. Japanese (`ja`) is the +CJK reference — fittingly, the app's own name is Japanese (種, *tane*, "seed"). +It ships only a core set of strings translated; the rest fall back to English, +which is exactly how a partial locale is meant to grow. + - Placeholders use braces: `"{count} entries added"`. Keep them verbatim. - Keys are code and never change per locale; only values are translated. - Missing keys fall back to English at runtime (`fallback_strategy: @@ -41,6 +45,16 @@ apps/app_seeds/lib/i18n/ Right-to-left locales (Arabic, Hebrew, Persian) are first-class: layouts use directional insets and are exercised by [`rtl_smoke_test.dart`](../apps/app_seeds/test/ui/rtl_smoke_test.dart). + +**Fonts cover the scripts we claim.** The UI text theme carries Noto Sans +Arabic + Noto Sans JP as per-glyph fallbacks (see +[`theme.dart`](../apps/app_seeds/lib/ui/theme.dart)), so RTL and CJK render on +every platform — including desktop, where the system font may lack them. Every +generated PDF shares the same fallbacks via +[`pdf_fonts.dart`](../apps/app_seeds/lib/services/pdf_fonts.dart), so labels and +catalogs print Arabic/CJK instead of tofu. Fonts are a per-locale resource, +extensible to any script; widen the fallback list as more scripts land. + Locale-specific resources ship with their locale, never hardcoded: OCR language packs (`assets/tessdata/`), PDF fonts (`assets/fonts/`), and species common names (`assets/catalog/species.json`, a locale-keyed map). diff --git a/docs/intro-app.md b/docs/intro-app.md index d72cdb0..7a46dd6 100644 --- a/docs/intro-app.md +++ b/docs/intro-app.md @@ -42,9 +42,9 @@ Con el Plantare, quien recibe promete devolver semilla cuando pueda. Y como para ## Texto para "Acerca de" (una sola pantalla) -> **Tanemaki** (種まき, "sembrar" en japonés) es una app libre para cuidar y compartir semillas tradicionales. +> **Tane** (種 "semilla"; de *tanemaki*, 種まき, "sembrar" en japonés) es una app libre para cuidar y compartir semillas tradicionales. > -> Las semillas son un bien común: llevan miles de años pasando de mano en mano, mejorando en cada vuelta. Tanemaki te ayuda a seguir esa cadena: tu inventario vive cifrado en tu dispositivo —sin cuenta, sin internet, sin rastreadores— y compartes solo lo que eliges, con quien está cerca, sin intermediarios. +> Las semillas son un bien común: llevan miles de años pasando de mano en mano, mejorando en cada vuelta. Tane te ayuda a seguir esa cadena: tu inventario vive cifrado en tu dispositivo —sin cuenta, sin internet, sin rastreadores— y compartes solo lo que eliges, con quien está cerca, sin intermediarios. > > Heredera del Plantare (2009) y de las tradiciones de ayuda mutua que le dan nombre. Software libre (AGPL-3.0), sin publicidad, sin comisiones, sin venta de datos. > diff --git a/docs/intro.md b/docs/intro.md index 88343a7..acdd513 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -1,4 +1,4 @@ -# Tanemaki — Una introducción para humanos +# Tane — Una introducción para humanos *Texto de presentación (web, ferias, folleto). Deriva de [VISION.md](../VISION.md); esta versión es más narrativa y para todos los públicos.* @@ -16,11 +16,11 @@ Ese común está en peligro. Hoy un puñado de empresas controla buena parte de Y hay algo más extraño todavía: compartir semillas, la práctica más antigua y natural de la agricultura, se ha vuelto en algunos sitios sospechoso. En Francia multaron a la asociación Kokopelli por distribuir variedades no inscritas en el catálogo oficial. La ley tiende a proteger lo registrado y lo privativo, y a arrinconar lo que siempre fue de todos. -No hace falta gritar consignas para ver el problema: cuando guardar e intercambiar semilla necesita valentía, algo se ha torcido. Tanemaki nace para destorcerlo un poco. +No hace falta gritar consignas para ver el problema: cuando guardar e intercambiar semilla necesita valentía, algo se ha torcido. Tane nace para destorcerlo un poco. -## Qué es Tanemaki +## Qué es Tane -**Tanemaki** (種まき, "sembrar" en japonés; para las amigas, **Tane**) es una app libre y gratuita para **cuidar y compartir semillas tradicionales**. Hace dos cosas, y las hace fáciles: +**Tane** (種, "semilla" en japonés; nombre completo **Tanemaki**, 種まき, "sembrar") es una app libre y gratuita para **cuidar y compartir semillas tradicionales**. Hace dos cosas, y las hace fáciles: **Gestionar.** Tu banco de semillas en el bolsillo: qué tienes, de qué año, cuánto, de dónde vino. Con el nombre que tú usas —no hace falta saber latín—. Añades una semilla con una foto y un nombre en veinte segundos; la profundidad (procedencia, germinación, notas, nombre científico) está ahí solo si la quieres. Sirve igual para quien guarda cuatro sobres en un cajón que para un banco comunitario con cientos de variedades. @@ -37,7 +37,7 @@ No hace falta gritar consignas para ver el problema: cuando guardar e intercambi ## Tu privacidad, en serio -Tanemaki está construida al revés de casi todas las apps: +Tane está construida al revés de casi todas las apps: - **Funciona sin internet y sin cuenta.** No te pedimos correo, ni teléfono, ni nombre. Abres la app y siembras. - **Tus datos viven en tu dispositivo**, cifrados. No hay servidor central que los guarde, los analice o los venda. No hay publicidad ni rastreadores. @@ -49,20 +49,20 @@ Y todo el código es **software libre** (AGPL): cualquiera puede leerlo, mejorar ## El Plantare: prestar semilla hace crecer el común -Cuando das semillas con Tanemaki, puedes acompañarlas de un **Plantare**: una promesa —heredera de un pagaré de papel real que circuló en 2009— de devolver, cuando puedas, una cantidad parecida de semilla libre. No es una deuda con intereses; es un compromiso de mantener la semilla viva y en movimiento. Y aquí está la magia: para devolver semilla hay que cultivarla, y al cultivarla se multiplica. **Cada préstamo agranda el común en vez de agotarlo.** Es exactamente lo contrario de la semilla diseñada para no reproducirse. +Cuando das semillas con Tane, puedes acompañarlas de un **Plantare**: una promesa —heredera de un pagaré de papel real que circuló en 2009— de devolver, cuando puedas, una cantidad parecida de semilla libre. No es una deuda con intereses; es un compromiso de mantener la semilla viva y en movimiento. Y aquí está la magia: para devolver semilla hay que cultivarla, y al cultivarla se multiplica. **Cada préstamo agranda el común en vez de agotarlo.** Es exactamente lo contrario de la semilla diseñada para no reproducirse. ## Una herencia -El nombre es un homenaje a las tradiciones japonesas de ayuda mutua en torno al arroz: el *yui*, el trabajo comunitario compartido, y el *tanomoshi*, los fondos vecinales de reciprocidad. Tanemaki continúa el camino del Plantare (BAH-Semillero, 2009) y de la cultura libre. No inventamos nada: le ponemos bolsillo a algo muy viejo. +El nombre es un homenaje a las tradiciones japonesas de ayuda mutua en torno al arroz: el *yui*, el trabajo comunitario compartido, y el *tanomoshi*, los fondos vecinales de reciprocidad. Tane continúa el camino del Plantare (BAH-Semillero, 2009) y de la cultura libre. No inventamos nada: le ponemos bolsillo a algo muy viejo. ## Para quién Para todo el mundo, de los 10 a los 80 años. Para la persona mayor que guarda la semilla de toda la vida y apenas usa el móvil, y para quien coordina un banco comunitario y necesita potencia. Para hortelanas y hortelanos, redes de semillas, colectivos agroecológicos, ferias de intercambio, escuelas. -Tanemaki no tiene modelo de negocio, y es a propósito: sin cuotas, sin comisiones, sin venta de datos. Se sostiene con trabajo voluntario, comunidad y financiación de bien común. +Tane no tiene modelo de negocio, y es a propósito: sin cuotas, sin comisiones, sin venta de datos. Se sostiene con trabajo voluntario, comunidad y financiación de bien común. ## Siembra -Una semilla guardada en un cajón es un recuerdo. Una semilla compartida es un futuro. Tanemaki existe para lo segundo: para que guardar sea fácil, compartir sea seguro, y la memoria de diez mil años siga pasando de mano en mano. +Una semilla guardada en un cajón es un recuerdo. Una semilla compartida es un futuro. Tane existe para lo segundo: para que guardar sea fácil, compartir sea seguro, y la memoria de diez mil años siga pasando de mano en mano. *Las semillas del conocimiento libre ya están plantadas.* diff --git a/docs/legal/README.md b/docs/legal/README.md new file mode 100644 index 0000000..fc962e8 --- /dev/null +++ b/docs/legal/README.md @@ -0,0 +1,33 @@ +# Legal documents + +Public, user-facing documents (masters in English, Spanish mirror in `es/`): + +| Document | Spanish | Published at (target) | +|---|---|---| +| [privacy-policy.md](privacy-policy.md) | [es/politica-de-privacidad.md](es/politica-de-privacidad.md) | `https://tane.comunes.org/legal/privacy` | +| [terms-of-use.md](terms-of-use.md) | [es/condiciones-de-uso.md](es/condiciones-de-uso.md) | `https://tane.comunes.org/legal/terms` | +| [community-rules.md](community-rules.md) | [es/normas-de-la-comunidad.md](es/normas-de-la-comunidad.md) | `https://tane.comunes.org/legal/rules` | +| [seed-legality-notice.md](seed-legality-notice.md) | [es/aviso-sobre-semillas.md](es/aviso-sobre-semillas.md) | `https://tane.comunes.org/legal/seeds` | + +Internal documents (not published, for the team): + +- [internal/play-compliance.md](internal/play-compliance.md) — Google Play Data Safety answers, UGC checklist, content rating notes. +- [internal/apple-notes.md](internal/apple-notes.md) — future App Store submission notes. +- [internal/legal-analysis.md](internal/legal-analysis.md) — the legal research memo behind these documents (seed law, GDPR, DSA), with review-by dates. + +## Publishing checklist (outside this repo) + +1. Publish the four public documents (EN + ES) under `https://tane.comunes.org/legal/…`. + Google Play **requires** the privacy policy URL to be live before the store listing is submitted. +2. Paste the privacy policy URL into Play Console → App content → Privacy policy. +3. Fill the Data Safety form from [internal/play-compliance.md](internal/play-compliance.md). +4. TODO: confirm the public contact address used in the documents (currently + `info@comunes.org`) with the Comunes association before publishing. + +## Conventions + +- Masters are English; the Spanish mirror must be kept in sync (same section numbers). +- The in-app "Privacy & rules" screen shows short human-language summaries and links here; + the app never shows jargon (no "GDPR", "NIP", "geohash" in UI copy). +- Any change that affects what data leaves the device requires updating: the privacy + policy, the in-app summary (i18n), and the Data Safety form answers. diff --git a/docs/legal/community-rules.md b/docs/legal/community-rules.md new file mode 100644 index 0000000..61ece57 --- /dev/null +++ b/docs/legal/community-rules.md @@ -0,0 +1,42 @@ +# Tane — Community Rules + +**Version 1.0 — 13 July 2026** + +The market and messages in Tane are shared spaces built on trust between neighbours. +These rules are what everyone accepts before joining. They also define what counts as +content that can be reported and removed from the servers we operate. + +## The rules + +1. **Be honest about your seeds.** Describe what you actually have: the variety as you + know it, the harvest year, anything relevant about how it was grown. Don't invent + provenance. +2. **Share only what you may share.** Don't offer: + - seed of **commercially protected varieties** (plant variety rights, patents) + unless you are authorized; + - **invasive or protected species** where their exchange is prohibited; + - anything else that is illegal to share or sell where you or the recipient live. +3. **Seeds and seedlings, not anything else.** The market is for seeds, seedlings and + other plant reproductive material (cuttings, bulbs, tubers) in the spirit of the app. + It is not a general classifieds board. +4. **Respect people.** No harassment, threats, hate, or discrimination — in offers, + profiles, ratings, or messages. +5. **No spam or scams.** No repeated postings, misleading offers, phishing, or + pressure to move people onto shady deals. +6. **Honest reputation.** Vouch only for people you have actually met or exchanged + with; rate only real experiences. Don't game the trust system. + +## What happens if someone breaks them + +- Anyone can **block** you — your offers and messages disappear for them. +- Anyone can **report** an offer or a person. Reports travel to the servers that carry + the content; on the relay operated by Comunes (`relay.comunes.org`), content and keys + that break these rules are removed. Other servers apply their own policies. +- There are no appeals or account suspensions because there are no accounts: the + network simply stops carrying and showing what breaks the rules. + +## A note on good faith + +Most seed sharing is between amateurs, in small quantities, for the joy of keeping +varieties alive. When in doubt, prefer **gifting and swapping** over selling, label +things clearly, and ask. That's how it's always been done. diff --git a/docs/legal/es/aviso-sobre-semillas.md b/docs/legal/es/aviso-sobre-semillas.md new file mode 100644 index 0000000..c5d1b90 --- /dev/null +++ b/docs/legal/es/aviso-sobre-semillas.md @@ -0,0 +1,76 @@ +# Tane — Aviso sobre la legalidad del intercambio de semillas y plantones + +**Versión 1.0 — 13 de julio de 2026** + +Tane ayuda a conservar y compartir semillas y plantones tradicionales. Las leyes sobre +semillas varían mucho entre países, y distinguen con claridad entre **regalar o +intercambiar** y **vender**. Este aviso es información general, no asesoramiento +jurídico; eres responsable de cumplir la ley allí donde vives y allí donde van tus +semillas o plantas. + +## Regalar e intercambiar entre aficionados + +Intercambiar semillas **sin ánimo comercial** — regalos, trueques, bibliotecas de +semillas, redes de conservación — está ampliamente reconocido y es legal en la mayoría +de lugares: + +- **Unión Europea.** La normativa de semillas de la UE regula la *comercialización* de + semilla. El intercambio en especie entre particulares y la labor de las redes de + conservación y bancos de germoplasma quedan fuera o están expresamente exentos; la + reforma en curso del material de reproducción vegetal (propuesta COM(2023) 414) + mantiene exenciones para aficionados y redes de conservación, siempre que la + actividad no sea comercial. +- **España.** La Ley 30/2006 regula el comercio de semillas; el intercambio no + comercial entre aficionados y el intercambio con fines de conservación son práctica + reconocida (art. 24). +- **Estados Unidos.** Tras la enmienda de 2016 a la ley uniforme estatal de semillas + (RUSSL), la mayoría de estados eximen el **intercambio no comercial** (bibliotecas de + semillas, trueques) de los requisitos comerciales de etiquetado y análisis. + +## Vender semillas + +Vender es distinto. En la UE y en muchas otras jurisdicciones, **comercializar semilla +de variedades no inscritas en un catálogo oficial puede estar restringido o +prohibido**, y las cantidades, el etiquetado o las especies pueden estar regulados +incluso para vendedores pequeños. Existen exenciones para variedades de conservación y +cantidades de aficionado, pero varían. Si usas la opción de Tane de pedir un precio, +**consulta antes tu normativa nacional** — la aplicación no puede hacerlo por ti. + +## Variedades protegidas + +Las variedades cubiertas por **títulos de obtención vegetal (PVR/PBR) o patentes** no +pueden propagarse ni venderse sin autorización del titular — y esto puede aplicarse +incluso a semilla comprada legalmente. Las variedades tradicionales y de herencia +normalmente no están protegidas; las variedades comerciales modernas casi siempre sí. +Ante la duda, no la ofrezcas. + +## Enviar semillas entre países + +La mayoría de países restringen la importación de semillas: **certificados +fitosanitarios**, listas de especies prohibidas y normas de cuarentena se aplican con +frecuencia — incluso a sobres pequeños entre particulares, y también **dentro** de la +UE para ciertas especies (pasaporte fitosanitario). Antes de enviar semillas a otro +país, consulta las normas del país de destino. Los intercambios más seguros son los +locales — que es justo para lo que está pensada Tane. + +## Plantones y plantas vivas + +Los mismos principios valen para plantones, esquejes, bulbos y otro material vegetal +vivo — pero la planta viva suele estar regulada **con más rigor** que la semilla. Las +normas fitosanitarias, el pasaporte fitosanitario dentro de la UE y la cuarentena en la +importación se aplican a menudo a las plantas vivas aunque la semilla equivalente esté +exenta. Mantén los intercambios de plantón locales y consulta las normas fitosanitarias +antes de mover plantas vivas entre regiones o países. + +## Especies invasoras y protegidas + +Algunas especies no pueden intercambiarse en absoluto en ciertas regiones, bien porque +allí son **invasoras**, bien porque están **protegidas**. Las listas son regionales; +consulta la tuya. + +--- + +*Tane no cobra comisiones, no media en transacciones y no verifica ofertas: los +intercambios son tratos privados entre las personas implicadas (ver las +[Condiciones de uso](condiciones-de-uso.md)). Este aviso existe para que esos tratos +sean informados.* diff --git a/docs/legal/es/condiciones-de-uso.md b/docs/legal/es/condiciones-de-uso.md new file mode 100644 index 0000000..70c148e --- /dev/null +++ b/docs/legal/es/condiciones-de-uso.md @@ -0,0 +1,78 @@ +# Tane — Condiciones de uso + +**Versión 1.0 — 13 de julio de 2026** + +Estas condiciones regulan el uso de la aplicación Tane, publicada por la **Asociación +Comunes** (España) — , contacto . Al usar las +funciones de intercambio de Tane (el mercado y la mensajería) aceptas estas condiciones +y las [Normas de la comunidad](normas-de-la-comunidad.md). + +## 1. Qué es Tane — y qué no es + +Tane es una **herramienta personal**: un inventario de semillas que vive en tu +dispositivo, más una forma de descubrir personas cercanas que comparten semillas y plantones. Tane +**no es un operador de mercado, ni una tienda, ni parte de ningún intercambio**: + +- No alojamos anuncios — las ofertas las publicas tú, con tu propia clave, en relés + gestionados por comunidades. +- No cobramos **ninguna comisión** ni procesamos pagos. Todo intercambio, regalo o + venta se acuerda y se realiza directamente entre las personas implicadas. +- No verificamos semillas, personas ni ofertas, y no podemos eliminar contenido de + servidores que no operamos. + +## 2. Tus responsabilidades + +Eres la única persona responsable de: + +- **Lo que ofreces e intercambias.** Asegúrate de que puedes compartir o vender las + semillas que publicas. Las normas cambian según el país — mira el + [Aviso sobre semillas](aviso-sobre-semillas.md). En particular, vender semilla de + variedades no registradas puede estar restringido donde vives, y propagar variedades + protegidas comercialmente sin autorización es ilegal en la mayoría de países. +- **Los envíos.** Enviar semillas entre países suele estar restringido (normas + fitosanitarias). Infórmate antes de mandar nada al extranjero. +- **Lo que publicas.** Las ofertas, tu perfil, los avales y las valoraciones son + públicos y van firmados por ti. Sigue las + [Normas de la comunidad](normas-de-la-comunidad.md). +- **Tus claves y copias de seguridad.** No hay recuperación de cuenta: si pierdes tu + dispositivo y tu código de recuperación, no podemos restaurar nada. + +## 3. Sin garantías + +Tane se ofrece **"tal cual"**, sin garantía de ningún tipo, como software libre bajo la +[licencia AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html). En particular, nadie — +ni Comunes, ni las personas con las que intercambias a través de la aplicación — +garantiza la **identidad, pureza, germinación, sanidad o legalidad** de ninguna semilla o planta +intercambiada. La información de cultivo que muestra la aplicación (calendarios, +consejos para extraer semilla, estimaciones de viabilidad) es orientación general, no +asesoramiento profesional. + +## 4. Conflictos entre personas usuarias + +Los intercambios son tratos privados entre las personas implicadas. Comunes no actúa +como mediadora y no acepta responsabilidad por intercambios fallidos, disputas de +calidad o pérdidas derivadas de ellos. Los avales y las valoraciones existen para que +cada comunidad construya su propia confianza. + +## 5. Moderación + +No hay moderación central: tu aplicación filtra lo que ves a través de la gente en la +que confías, y puedes **bloquear** a cualquiera y **denunciar** ofertas o personas +desde la propia aplicación. Las denuncias se comparten con los servidores implicados; +en el relé que operamos (`relay.comunes.org`), el contenido que incumple las normas de +la comunidad se elimina. Los relés de terceros aplican sus propias políticas. + +## 6. Responsabilidad + +En la máxima medida que permita la ley, la responsabilidad total de la Asociación +Comunes derivada de la aplicación se limita a lo que pagaste por ella (Tane es +gratuita: cero). Nada en estas condiciones limita la responsabilidad que no pueda +limitarse por ley, ni tus derechos como consumidor. + +## 7. General + +Si una parte de estas condiciones resultara inválida, el resto sigue en vigor. Estas +condiciones se rigen por el derecho español y de la UE, sin privarte de las +protecciones de la ley de tu país de residencia. Podemos actualizar estas condiciones; +los cambios relevantes se señalarán en las notas de versión de la aplicación, y la +versión vigente está siempre en . diff --git a/docs/legal/es/normas-de-la-comunidad.md b/docs/legal/es/normas-de-la-comunidad.md new file mode 100644 index 0000000..436229b --- /dev/null +++ b/docs/legal/es/normas-de-la-comunidad.md @@ -0,0 +1,48 @@ +# Tane — Normas de la comunidad + +**Versión 1.0 — 13 de julio de 2026** + +El mercado y los mensajes de Tane son espacios compartidos construidos sobre la +confianza entre vecinas y vecinos. Estas normas son lo que todo el mundo acepta antes +de entrar. También definen qué contenido puede denunciarse y eliminarse de los +servidores que operamos. + +## Las normas + +1. **Sé honesto con tus semillas.** Describe lo que realmente tienes: la variedad tal + y como la conoces, el año de cosecha, lo relevante sobre cómo se cultivó. No + inventes procedencias. +2. **Comparte solo lo que puedas compartir.** No ofrezcas: + - semilla de **variedades protegidas comercialmente** (obtenciones vegetales, + patentes) salvo que tengas autorización; + - **especies invasoras o protegidas** allí donde su intercambio esté prohibido; + - cualquier otra cosa que sea ilegal compartir o vender donde vives tú o donde vive + quien la recibe. +3. **Semillas y plantones, no cualquier cosa.** El mercado es para semillas, plantones + y otro material de reproducción vegetal (esquejes, bulbos, tubérculos), en el + espíritu de la aplicación. No es un tablón de anuncios general. +4. **Respeta a las personas.** Nada de acoso, amenazas, odio o discriminación — ni en + ofertas, ni en perfiles, ni en valoraciones, ni en mensajes. +5. **Sin spam ni engaños.** Nada de publicaciones repetidas, ofertas engañosas, + phishing o presiones para llevar a la gente a tratos turbios. +6. **Reputación honesta.** Avala solo a personas que conozcas de verdad o con las que + hayas intercambiado; valora solo experiencias reales. No hagas trampas al sistema de + confianza. + +## Qué pasa si alguien las incumple + +- Cualquiera puede **bloquearte** — tus ofertas y mensajes desaparecen para esa + persona. +- Cualquiera puede **denunciar** una oferta o a una persona. Las denuncias llegan a los + servidores que transportan el contenido; en el relé operado por Comunes + (`relay.comunes.org`), el contenido y las claves que incumplen estas normas se + eliminan. Otros servidores aplican sus propias políticas. +- No hay apelaciones ni suspensiones de cuenta porque no hay cuentas: sencillamente, la + red deja de transportar y de mostrar lo que incumple las normas. + +## Una nota sobre la buena fe + +La mayor parte del intercambio de semillas ocurre entre aficionados, en pequeñas +cantidades, por el gusto de mantener vivas las variedades. Ante la duda, prefiere +**regalar e intercambiar** antes que vender, etiqueta las cosas con claridad y +pregunta. Así se ha hecho siempre. diff --git a/docs/legal/es/politica-de-privacidad.md b/docs/legal/es/politica-de-privacidad.md new file mode 100644 index 0000000..323cfc3 --- /dev/null +++ b/docs/legal/es/politica-de-privacidad.md @@ -0,0 +1,96 @@ +# Tane — Política de privacidad + +**Versión 1.0 — 13 de julio de 2026** + +Tane está publicada por la **Asociación Comunes** (España) — — +contacto: . Tane es software libre (AGPL-3.0); su código fuente es +público, así que todo lo que se describe aquí puede verificarse. + +## La versión corta + +- Tane funciona **sin cuenta**. No sabemos quién eres. +- Todo lo que registras se queda **en tu dispositivo, cifrado**. No tenemos servidores + que lo reciban ni forma de leerlo. +- Nada sale de tu dispositivo salvo que **tú** decidas compartirlo (una oferta, un + mensaje, tu perfil). Lo que compartes viaja por servidores comunitarios que no + controlamos. +- **Sin analíticas, sin publicidad, sin rastreadores.** Tane no recoge nada sobre ti. + +## 1. Sin cuentas, sin recogida por nuestra parte + +Tane no pide tu nombre, correo ni teléfono. No hay registro ni servidor central de +Tane. Tu identidad en la aplicación es una clave criptográfica creada en tu dispositivo +y guardada en el almacén seguro del sistema. La Asociación Comunes no recoge, recibe ni +trata tus datos personales a través de la aplicación. + +## 2. Datos guardados en tu dispositivo + +Tu inventario de semillas (variedades, cantidades, notas, fotos), tus mensajes, los +avales y valoraciones de tus contactos y tus claves se guardan **solo en tu +dispositivo**, en una base de datos cifrada (SQLCipher). La clave de cifrado vive en el +almacén seguro del sistema. Las copias de seguridad que crees también van cifradas; tú +eliges dónde guardarlas y nunca pasan por nosotros. + +## 3. Datos que salen de tu dispositivo — solo cuando tú quieres + +Nada se publica automáticamente. Cada una de estas cosas ocurre solo por tu acción +explícita: + +- **Ofertas.** Cuando ofreces semillas o plantones, la aplicación publica el título, la + descripción, una foto opcional, un precio opcional y una **zona aproximada** (un área + de entre 2 y 80 km, según la configures — nunca tu dirección ni tu ubicación + precisa). Las ofertas son públicas. +- **Perfil.** Si lo rellenas, el nombre que elijas, tu biografía y tu avatar son + públicos. +- **Mensajes.** Los mensajes directos van **cifrados de extremo a extremo**: solo tú y + la persona a la que escribes podéis leerlos. Los servidores que los transportan solo + ven sobres cifrados. +- **Avales y valoraciones.** Si avalas o valoras a alguien, esa declaración es pública + y va firmada con tu clave. +- **Sincronización entre tus dispositivos.** Si enlazas un segundo dispositivo, tus + datos viajan entre ellos cifrados de forma que solo tus dispositivos pueden leerlos. + +## 4. A dónde van los datos compartidos: servidores comunitarios + +Las funciones sociales de Tane usan servidores abiertos gestionados por comunidades +("relés"). Por defecto la aplicación usa un pequeño conjunto que incluye +`relay.comunes.org` (operado por la Asociación Comunes) y relés públicos conocidos; +puedes cambiar esa lista, o vaciarla para desconectar la red por completo. Los relés +distintos de `relay.comunes.org` son **infraestructura de terceros**: sus operadores +deciden su propia retención y sus políticas, y esta política no los cubre. + +## 5. Borrado — una nota honesta + +Puedes borrar al instante todo lo local, y puedes retirar una oferta en cualquier +momento. Cuando retiras o borras algo que habías publicado, la aplicación pide a los +relés que también lo borren. Los relés que no operamos pueden conservar copias pese a +esa petición, y lo publicado pudo copiarse a otros sitios mientras fue visible. +**Trata todo lo que publiques como potencialmente permanente.** En `relay.comunes.org` +atendemos las peticiones de borrado. + +## 6. Permisos del dispositivo + +- **Fotos / cámara** — solo cuando adjuntas una imagen a una variedad, oferta o perfil. +- **Ubicación aproximada** (opcional) — solo si usas "fijar mi zona desde donde estoy", + para elegir tu zona amplia de intercambio. Tane nunca pide la ubicación precisa. +- **Notificaciones** (opcional) — para avisarte de mensajes nuevos. + +## 7. Tus derechos + +Como tus datos viven en tu dispositivo y bajo tu control, la mayoría de derechos los +ejerces tú directamente: consultar, corregir, exportar o borrar todo desde la propia +aplicación. Para los datos en `relay.comunes.org`, o cualquier duda sobre esta +política, escribe a . Si estás en la UE, también tienes derecho a +reclamar ante tu autoridad de protección de datos (en España, la AEPD). + +## 8. Menores + +El inventario de semillas puede usarlo cualquiera. El mercado y la mensajería no están +dirigidos a menores; al usarlos confirmas que tienes edad suficiente para usar +funciones sociales según las leyes de tu país. + +## 9. Cambios + +Actualizaremos esta política si cambia el comportamiento de Tane, y lo señalaremos en +las notas de cada versión. La versión vigente está siempre en +, con su historial en el repositorio público. diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md new file mode 100644 index 0000000..6c8926b --- /dev/null +++ b/docs/legal/privacy-policy.md @@ -0,0 +1,90 @@ +# Tane — Privacy Policy + +**Version 1.0 — 13 July 2026** + +Tane is published by **Asociación Comunes** (Spain) — — +contact: . Tane is free software (AGPL-3.0); its source code is +public, so everything described here can be verified. + +## The short version + +- Tane works **without an account**. We don't know who you are. +- Everything you record stays **on your device, encrypted**. We run no servers that + receive it, and we have no way to read it. +- Nothing leaves your device unless **you** choose to share it (an offer, a message, + your profile). What you share travels through community-run servers we don't control. +- **No analytics, no ads, no trackers.** Tane collects nothing about you. + +## 1. No accounts, no collection by us + +Tane does not ask for your name, email, or phone number. There is no registration and +no central Tane server. Your identity in the app is a cryptographic key created on your +device and stored in your device's secure keystore. Asociación Comunes does not collect, +receive, or process your personal data through the app. + +## 2. Data stored on your device + +Your seed inventory (varieties, quantities, notes, photos), your messages, your +contacts' vouches and ratings, and your keys are stored **only on your device**, in an +encrypted database (SQLCipher). The encryption key lives in your device's system +keystore. Backups you create are also encrypted; you choose where to keep them, and +they never pass through us. + +## 3. Data that leaves your device — only when you choose + +Nothing is published automatically. Each of these happens only by your explicit action: + +- **Offers.** When you offer seeds or seedlings, the app publishes the offer's title, description, + optional photo, optional price, and an **approximate area** (a zone of roughly 2–80 km, + as you configure it — never your address or precise location). Offers are public. +- **Profile.** If you fill it in, your chosen name, bio, and avatar are public. +- **Messages.** Direct messages are **end-to-end encrypted**: only you and the person + you write to can read them. The servers that carry them see only encrypted envelopes. +- **Vouches and ratings.** If you vouch for or rate someone, that statement is public + and signed by your key. +- **Sync between your devices.** If you link a second device, your data travels between + them encrypted so that only your devices can read it. + +## 4. Where shared data goes: community relays + +Tane's social features use open, community-run servers ("relays"). By default the app +uses a small set that includes `relay.comunes.org` (operated by Asociación Comunes) and +well-known public relays; you can change this list, or empty it to turn the network off +entirely. Relays other than `relay.comunes.org` are **third-party infrastructure**: +their operators decide their own retention and policies, and this policy does not cover +them. + +## 5. Deletion — an honest note + +You can delete anything local instantly, and you can withdraw an offer at any time. +When you withdraw or delete something you had published, the app asks the relays to +delete it too. Relays we don't operate may keep copies despite that request, and public +posts may have been copied elsewhere while they were visible. **Treat anything you +publish as potentially permanent.** On `relay.comunes.org` we honour deletion requests. + +## 6. Device permissions + +- **Photos / camera** — only when you attach a picture to a variety, offer, or profile. +- **Approximate location** (optional) — only if you use "set my area from where I am", + to pick your coarse sharing zone. Tane never requests precise location. +- **Notifications** (optional) — to tell you about new messages. + +## 7. Your rights + +Because your data lives on your device under your control, you exercise most rights +yourself: read, correct, export, or delete everything from within the app. For data on +`relay.comunes.org`, or any question about this policy, write to . +If you are in the EU, you also have the right to complain to your data protection +authority (in Spain, the AEPD). + +## 8. Children + +The seed inventory can be used by anyone. The market and messaging features are not +directed at children; by using them you confirm you are old enough to use social +features under the laws of your country. + +## 9. Changes + +We will update this policy if Tane's behaviour changes, and note the changes in the +app's release notes. The current version always lives at +, with history in the public repository. diff --git a/docs/legal/seed-legality-notice.md b/docs/legal/seed-legality-notice.md new file mode 100644 index 0000000..4fe68fd --- /dev/null +++ b/docs/legal/seed-legality-notice.md @@ -0,0 +1,70 @@ +# Tane — Notice on the Legality of Exchanging Seeds and Seedlings + +**Version 1.0 — 13 July 2026** + +Tane helps people keep and share traditional seeds and seedlings. Laws about seeds +differ a lot between countries, and they distinguish sharply between **giving or +swapping** and **selling**. This notice is general information, not legal advice; you +are responsible for following the law where you live and where your seeds or plants +are going. + +## Gifting and swapping between amateurs + +Exchanging seeds **without commercial purpose** — gifts, swaps, seed libraries, +conservation networks — is broadly recognized and lawful in most places: + +- **European Union.** EU seed marketing law regulates *commercial marketing* of seed. + Exchange in kind between individuals, and the work of conservation networks and gene + banks, fall outside or are expressly exempted; the plant-reproductive-material reform + underway (proposal COM(2023) 414) keeps exemptions for amateurs and conservation + networks, provided the activity is not commercial. +- **Spain.** Ley 30/2006 regulates the *commercial* seed trade; non-commercial exchange + between amateurs and conservation-oriented exchange are recognized practice (art. 24). +- **United States.** Following the 2016 amendment to the Recommended Uniform State Seed + Law, most states exempt **non-commercial seed sharing** (seed libraries, swaps) from + commercial labeling and testing requirements. + +## Selling seeds + +Selling is different. In the EU and many other jurisdictions, **marketing seed of +varieties not registered in an official catalogue may be restricted or prohibited**, +and quantities, labeling, or species may be regulated even for small sellers. +Exemptions for conservation varieties and amateur quantities exist but vary. If you +use Tane's option to ask a price, **check your national rules first** — the app cannot +do this for you. + +## Protected varieties + +Varieties covered by **plant variety rights (PVR/PBR) or patents** may not be +propagated or sold without the holder's authorization — this can apply even to seed +you bought legally. Traditional and heirloom varieties are generally not protected, +but modern commercial varieties usually are. When in doubt, don't offer it. + +## Sending seeds across borders + +Most countries restrict importing seeds: **phytosanitary certificates**, prohibited +species lists, and quarantine rules commonly apply — even to small envelopes between +individuals, and also **within** the EU for certain species (plant passport rules). +Before mailing seeds to another country, check the rules of the destination country. +The safest exchanges are local ones — which is what Tane is designed for. + +## Seedlings and live plants + +The same principles apply to seedlings, cuttings, bulbs and other live plant material — +but live plants are usually regulated **more strictly** than seed. Plant-health +(phytosanitary) rules, plant-passport requirements inside the EU, and quarantine on +import commonly apply to living plants even when the equivalent seed is exempt. Keep +seedling exchanges local, and check plant-health rules before moving live plants +between regions or across borders. + +## Invasive and protected species + +Some species may not be exchanged at all in some regions, either because they are +**invasive** there or because they are **protected**. Lists are regional; check yours. + +--- + +*Tane takes no commission, mediates no transaction, and verifies no offer: exchanges +are private dealings between the people involved (see the +[Terms of use](terms-of-use.md)). This notice exists so those dealings are informed +ones.* diff --git a/docs/legal/terms-of-use.md b/docs/legal/terms-of-use.md new file mode 100644 index 0000000..fac45ea --- /dev/null +++ b/docs/legal/terms-of-use.md @@ -0,0 +1,75 @@ +# Tane — Terms of Use + +**Version 1.0 — 13 July 2026** + +These terms cover the use of the Tane application, published by **Asociación Comunes** +(Spain) — , contact . By using Tane's sharing +features (the market and messaging) you accept these terms and the +[Community rules](community-rules.md). + +## 1. What Tane is — and what it is not + +Tane is a **personal tool**: a seed inventory that lives on your device, plus a way to +discover people near you who share seeds and seedlings. Tane is **not a marketplace operator, not a +shop, and not a party to any exchange**: + +- We host no listings — offers are published by you, under your own key, to + community-run relays. +- We take **no commission** and process no payments. Any exchange, gift, or sale is + agreed and carried out directly between the people involved. +- We do not verify seeds, users, or offers, and we cannot remove content from servers + we do not operate. + +## 2. Your responsibilities + +You are solely responsible for: + +- **What you offer and exchange.** Make sure you are allowed to share or sell the + seeds you list. Rules differ by country — see the + [Seed legality notice](seed-legality-notice.md). In particular, selling seed of + varieties that are not registered may be restricted where you live, and propagating + commercially protected varieties without authorization is illegal in most countries. +- **Shipping.** Sending seeds across borders is often restricted (phytosanitary rules). + Check before you mail anything abroad. +- **What you publish.** Offers, your profile, vouches, and ratings are public and + signed by you. Follow the [Community rules](community-rules.md). +- **Your keys and backups.** There is no account recovery: if you lose your device and + your recovery code, we cannot restore anything. + +## 3. No warranties + +Tane is provided **"as is"**, without warranty of any kind, as free software under the +[AGPL-3.0 license](https://www.gnu.org/licenses/agpl-3.0.html). In particular, nobody — +not Comunes, not the people you exchange with through the app — guarantees the +**identity, purity, germination, health, or legality** of any seed or plant exchanged. Growing +information shown in the app (calendars, seed-saving tips, viability estimates) is +general guidance, not professional advice. + +## 4. Disputes between users + +Exchanges are private dealings between the people involved. Comunes is not a mediator +and accepts no liability for failed exchanges, quality disputes, or losses arising from +them. The vouching and rating features exist so communities can build their own trust. + +## 5. Moderation + +There is no central moderation: your app filters what you see through the people you +trust, and you can **block** anyone and **report** offers or people from within the +app. Reports are shared with the servers involved; on the relay we operate +(`relay.comunes.org`), content that breaks the community rules is removed. Relays run +by others apply their own policies. + +## 6. Liability + +To the maximum extent permitted by law, Asociación Comunes' total liability arising +from the app is limited to the amount you paid for it (Tane is gratis: zero). Nothing +in these terms limits liability that cannot be limited by law, or your statutory rights +as a consumer. + +## 7. General + +If a part of these terms is found invalid, the rest remains in force. These terms are +governed by Spanish and EU law, without depriving you of protections of the law of +your country of residence. We may update these terms; material changes will be noted in +the app's release notes, and the current version always lives at +. diff --git a/docs/mockups/seedees_material3.html b/docs/mockups/seedees_material3.html index e367969..c5b98fe 100644 --- a/docs/mockups/seedees_material3.html +++ b/docs/mockups/seedees_material3.html @@ -4,7 +4,7 @@ -Tanemaki — Material mockups (v3) +Tane — Material mockups (v3) @@ -23,9 +23,9 @@
- Tanemaki + Tane
-
Tanemaki · Material mockups
+
Tane · Material mockups
Aligned with the real app — green app bars, botanical content, search, batches and action sheets.
@@ -36,7 +36,7 @@
9:41network_wifisignal_cellular_altbattery_full
-
menuTanemakisearch
+
menuTanesearch
a @@ -45,8 +45,8 @@ b
-
Tanemaki
-
Tanemaki
+
Tane
+
Tane
Share and grow local seeds
@@ -159,7 +159,7 @@
-
Tanemaki
+
Tane