Compare commits
186 commits
5b64fe14b3
...
a0302632a3
| Author | SHA1 | Date | |
|---|---|---|---|
| a0302632a3 | |||
| 0d4d19658c | |||
| f844d7c4f6 | |||
| ea17ced152 | |||
| 8ed1387792 | |||
| a511db9062 | |||
| 15385a26cf | |||
| ab027a8779 | |||
| 416fd7153b | |||
| 6292c741be | |||
| eb8c75cf44 | |||
| f10db611e8 | |||
| 36e44fef00 | |||
| 05947e03ee | |||
| 67f1c0da0c | |||
| 485d35c916 | |||
| dc20104c87 | |||
| 4ecf9b9811 | |||
| 3fa50a7a46 | |||
| 8d471507be | |||
| 7030f4a0bd | |||
| 621902612d | |||
| 13a9bead15 | |||
| f9c3dc018d | |||
| 2e25e34d11 | |||
| bec0f78fa1 | |||
| 516f1f50bc | |||
| baa8867287 | |||
| 41ea6735d2 | |||
| 7ef3de04d7 | |||
| dd36c3ac81 | |||
| f1e4fe377a | |||
| 37761a029c | |||
| 435b4db101 | |||
| 7869a7163b | |||
| b41dfd4248 | |||
| cd9f09048d | |||
| 668ed28246 | |||
| 8417a545a2 | |||
| 6b154ec717 | |||
| 184c15bcec | |||
| c4421f768a | |||
| 9560d0d18f | |||
| d4a9142ee2 | |||
| d85b557f1e | |||
| f31f58cc7e | |||
| 51f8177f70 | |||
| 9913d03c7a | |||
| 5d0da128b1 | |||
| 2f7095ca59 | |||
| ec5dca1e1c | |||
| adbe6b00fb | |||
| 064248144a | |||
| d42ed0df1a | |||
| b22db7d0cb | |||
| be5977d688 | |||
| 514d3c7364 | |||
| 461bc3cb36 | |||
| d28454b010 | |||
| ceb606ae57 | |||
| 248b239c48 | |||
| 456cc8154d | |||
| 2c42b1022a | |||
| 7e7340d643 | |||
| a24831530d | |||
| fef174e8c4 | |||
| 3c5200f235 | |||
| 9f01c3893a | |||
| 491e079b50 | |||
| e6e70e4b97 | |||
| 1103b6ce15 | |||
| 5ecdd1222a | |||
| 4a1e72bc7e | |||
| 805f1dc535 | |||
| 9e083d8b7f | |||
| 4bb94db3e0 | |||
| 4ebfdca2fd | |||
| a461106dbf | |||
| d481e62ff7 | |||
| 73e96a3ce5 | |||
| a5f50314e4 | |||
| dcda349878 | |||
| aec14f6518 | |||
| 68b04ea409 | |||
| 44337497d0 | |||
| 5b487ba1ec | |||
| e2665c27d4 | |||
| 833ddaed4d | |||
| c75fb59926 | |||
| 68ffb55f47 | |||
| d89ee64bc7 | |||
| 2c33172ba4 | |||
| caf9d2b96e | |||
| b15f912ad8 | |||
| d0dbed9bc2 | |||
| bf15992a97 | |||
| d9c2f46b79 | |||
| 1666f5b31f | |||
| b9492cb65a | |||
| 2c8869ea0d | |||
| a2ce4dbb45 | |||
| f951b05e0a | |||
| 2b419e6516 | |||
| d2b98af36d | |||
| 5017ea51e0 | |||
| d026376993 | |||
| 7fce1ce6f0 | |||
| e715b129f6 | |||
| b56ce68167 | |||
| f767ccd70e | |||
| 0cdeee50f8 | |||
| a109a84714 | |||
| 4b71859416 | |||
| 4d110e8082 | |||
| 6c8a1ad129 | |||
| 3ea1bfbf55 | |||
| 33c45bcab1 | |||
| efb369eb81 | |||
| a71493bfb1 | |||
| 0213152bfb | |||
| 09b7575df3 | |||
| 21ec836bd8 | |||
| 3dca732da9 | |||
| 138027a8ef | |||
| e641f8e0fa | |||
| 5ab505bc94 | |||
| 1af0282e00 | |||
| 5dd0f243ce | |||
| ea5ab0342d | |||
| c9c764e624 | |||
| 8d1a66e7b8 | |||
| 5cb1c33d1b | |||
| a14065db3f | |||
| 3e84e9bb3f | |||
| 3fe5397675 | |||
| cc762a139c | |||
| a070d866e6 | |||
| c6b91b7666 | |||
| a447149a74 | |||
| 5b52298160 | |||
| a9c6030dde | |||
| 267896430f | |||
| 3067d9ca7f | |||
| e32fdc9d38 | |||
| 48228c0bc6 | |||
| 856736c8c5 | |||
| 458502b12b | |||
| 30246f4292 | |||
| 432acded29 | |||
| abc2d37402 | |||
| 867bf38c5a | |||
| 7bf80d2031 | |||
| 6d8c6cf7e1 | |||
| f3a7ad198d | |||
| 320b9b6dba | |||
| 46b5537a11 | |||
| c18314d8c5 | |||
| c513a6ce4b | |||
| 49a43722eb | |||
| d369015e01 | |||
| a1a2f1230a | |||
| a72224f95a | |||
| 6fa5b9bdc5 | |||
| a85e71dc8d | |||
| efd64c4da4 | |||
| 0dd7c28039 | |||
| 08d8dc1172 | |||
| ee78b0ed62 | |||
| 15bd689a99 | |||
| bf0e770aae | |||
| 6195bece4a | |||
| 39adc423b9 | |||
| 2d4e14e0da | |||
| 6d2d0517cc | |||
| 0e7eb22c26 | |||
| 12f48ea318 | |||
| 2c358ec33a | |||
| 801a846d6e | |||
| a59adcca6b | |||
| 51f9338433 | |||
| 2b58fe40ce | |||
| edb480b21b | |||
| 05a9d45a82 | |||
| cc88f3d688 | |||
| 2cafc7fc12 | |||
| 49b872b405 |
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
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/<file>`), 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/<file> --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
|
||||
|
||||
|
|
|
|||
12
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
TRANSLATIONS.md
Normal file
|
|
@ -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://<user>:<token>@git.comunes.org/comunes/tane.git`
|
||||
where `<token>` 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://<user>:<token>@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/<code>.json`.
|
||||
18
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Coarse location only, for the optional "use my location" sharing area
|
||||
(reduced to a low-precision geohash; never a precise fix). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<!-- Foreground local notifications for incoming private messages (Android 13+
|
||||
asks the user at runtime). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:label="tane"
|
||||
android:label="Tane"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
|
|
|||
52
apps/app_seeds/assets/catalog/seed_saving.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"version": 1,
|
||||
"note": "Curated seed-saving guidance. Values are widely-published horticultural FACTS (isolation distances, pollination habit, population sizes) — not copied text — drawn from the sources below and cross-checked for consistency. Keyed by botanical family (defaults) with per-species/genus overrides that win. Distances in metres are conservative home-garden ranges for keeping a variety reasonably true; plant counts keep enough genetic diversity. `note` is a locale-keyed map, extensible to any language. Advisory only — adapt to local climate. A starter set, not a ceiling.",
|
||||
"sources": [
|
||||
{ "title": "Seed to Seed", "author": "Suzanne Ashworth", "publisher": "Seed Savers Exchange" },
|
||||
{ "title": "Seed Savers Exchange", "url": "https://seedsavers.org" },
|
||||
{ "title": "Organic Seed Alliance", "url": "https://seedalliance.org" }
|
||||
],
|
||||
"families": {
|
||||
"Solanaceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 15, "minPlants": 1, "recommendedPlants": 6, "processing": "wet", "difficulty": "easy" },
|
||||
"Fabaceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 20, "minPlants": 1, "recommendedPlants": 10, "processing": "dry", "difficulty": "easy" },
|
||||
"Cucurbitaceae": { "lifeCycle": "annual", "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 12, "processing": "wet", "difficulty": "medium" },
|
||||
"Brassicaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard" },
|
||||
"Asteraceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 8, "minPlants": 1, "recommendedPlants": 12, "processing": "dry", "difficulty": "easy" },
|
||||
"Apiaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "minPlants": 5, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard" },
|
||||
"Amaranthaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "wind", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard" },
|
||||
"Chenopodiaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "wind", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard" },
|
||||
"Amaryllidaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "medium" },
|
||||
"Alliaceae": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "medium" },
|
||||
"Poaceae": { "lifeCycle": "annual", "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 20, "minPlants": 20, "recommendedPlants": 40, "processing": "dry", "difficulty": "medium" },
|
||||
"Lamiaceae": { "lifeCycle": "perennial", "pollination": "cross", "vector": "insect", "isolationMinM": 45, "isolationMaxM": 150, "minPlants": 5, "recommendedPlants": 20, "processing": "dry", "difficulty": "medium" },
|
||||
"Malvaceae": { "lifeCycle": "annual", "pollination": "mixed", "vector": "insect", "isolationMinM": 150, "isolationMaxM": 500, "minPlants": 5, "recommendedPlants": 12, "processing": "dry", "difficulty": "easy" }
|
||||
},
|
||||
"species": {
|
||||
"Solanum lycopersicum": { "pollination": "self", "isolationMinM": 3, "isolationMaxM": 6, "minPlants": 1, "recommendedPlants": 5, "processing": "wet", "difficulty": "easy", "note": { "es": "Casi siempre se autopoliniza: fácil de mantener pura. Fermenta la semilla con su pulpa 2–3 días, lava y seca.", "en": "Almost always self-pollinates — easy to keep pure. Ferment the seed in its pulp for 2–3 days, then rinse and dry." } },
|
||||
"Capsicum annuum": { "pollination": "mixed", "vector": "insect", "isolationMinM": 8, "isolationMaxM": 150, "minPlants": 2, "recommendedPlants": 6, "processing": "dry", "difficulty": "easy", "note": { "es": "Se autopoliniza pero los insectos cruzan algo: separa los picantes de los dulces.", "en": "Self-pollinates but insects cross it somewhat — keep hot and sweet types apart." } },
|
||||
"Solanum melongena": { "pollination": "self", "isolationMinM": 6, "isolationMaxM": 15, "processing": "wet", "difficulty": "easy" },
|
||||
"Phaseolus vulgaris": { "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 6, "minPlants": 1, "recommendedPlants": 10, "processing": "dry", "difficulty": "easy" },
|
||||
"Pisum sativum": { "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 6, "minPlants": 1, "recommendedPlants": 10, "processing": "dry", "difficulty": "easy" },
|
||||
"Vicia faba": { "pollination": "cross", "vector": "insect", "isolationMinM": 180, "isolationMaxM": 1000, "minPlants": 10, "recommendedPlants": 50, "processing": "dry", "difficulty": "medium", "note": { "es": "¡Ojo! El haba SÍ se cruza por insectos, al revés que otras leguminosas: aíslala o cultiva una sola variedad.", "en": "Careful — unlike most legumes, faba beans DO cross via insects. Isolate them or grow a single variety." } },
|
||||
"Cucumis sativus": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||
"Cucurbita pepo": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "minPlants": 6, "processing": "wet", "difficulty": "medium", "note": { "es": "Se cruza con calabacines, calabazas de verano y algunas de invierno de su especie. Poliniza a mano y cierra la flor para mantenerla pura.", "en": "Crosses with courgettes, summer squash and some winter squash of its species. Hand-pollinate and tape the flower to keep it pure." } },
|
||||
"Cucurbita maxima": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||
"Cucurbita moschata": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||
"Cucumis melo": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||
"Citrullus lanatus": { "pollination": "cross", "vector": "insect", "isolationMinM": 400, "isolationMaxM": 1600, "processing": "wet", "difficulty": "medium" },
|
||||
"Lactuca sativa": { "pollination": "self", "vector": "self", "isolationMinM": 3, "isolationMaxM": 8, "minPlants": 1, "recommendedPlants": 12, "processing": "dry", "difficulty": "easy", "note": { "es": "Se autopoliniza: muy fácil. Deja subir a flor y recoge la semilla con vilano cuando seque.", "en": "Self-pollinating and very easy. Let it bolt and collect the fluffy seed as it dries." } },
|
||||
"Helianthus annuus": { "pollination": "cross", "vector": "insect", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 5, "recommendedPlants": 20, "processing": "dry", "difficulty": "medium" },
|
||||
"Daucus carota": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "minPlants": 5, "recommendedPlants": 40, "processing": "dry", "difficulty": "hard", "note": { "es": "Bienal: da semilla el segundo año. Se cruza con la zanahoria silvestre (encaje de la reina Ana).", "en": "Biennial — seeds in its second year. Crosses with wild carrot (Queen Anne's lace)." } },
|
||||
"Beta vulgaris": { "lifeCycle": "biennial", "pollination": "cross", "vector": "wind", "isolationMinM": 800, "isolationMaxM": 3200, "minPlants": 6, "recommendedPlants": 30, "processing": "dry", "difficulty": "hard", "note": { "es": "Polen por viento a mucha distancia: remolacha y acelga se cruzan entre sí. Cultiva solo una para semilla.", "en": "Wind-borne pollen travels far — beet and chard cross each other. Grow only one for seed." } },
|
||||
"Spinacia oleracea": { "lifeCycle": "annual", "pollination": "cross", "vector": "wind", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 30, "processing": "dry", "difficulty": "medium" },
|
||||
"Allium cepa": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 1600, "processing": "dry", "difficulty": "medium" },
|
||||
"Brassica oleracea": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 800, "isolationMaxM": 1600, "minPlants": 6, "recommendedPlants": 20, "processing": "dry", "difficulty": "hard", "note": { "es": "Coles, brócoli, coliflor, col rizada… todos son la MISMA especie y se cruzan entre sí. Aísla bien y guarda de varias plantas.", "en": "Cabbage, broccoli, cauliflower, kale… all the SAME species and cross freely. Isolate well and save from several plants." } },
|
||||
"Brassica rapa": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 800, "isolationMaxM": 1600, "processing": "dry", "difficulty": "hard" },
|
||||
"Raphanus sativus": { "lifeCycle": "annual", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 800, "processing": "dry", "difficulty": "medium" },
|
||||
"Zea mays": { "lifeCycle": "annual", "pollination": "cross", "vector": "wind", "isolationMinM": 200, "isolationMaxM": 800, "minPlants": 20, "recommendedPlants": 100, "processing": "dry", "difficulty": "medium", "note": { "es": "Polen por viento: se cruza con cualquier otro maíz cercano. Guarda semilla de muchas plantas (100+) para que no pierda vigor.", "en": "Wind-pollinated — crosses with any nearby maize. Save seed from many plants (100+) so it doesn't lose vigour." } },
|
||||
"Ocimum basilicum": { "lifeCycle": "annual", "pollination": "mixed", "vector": "insect", "isolationMinM": 45, "isolationMaxM": 150, "minPlants": 5, "processing": "dry", "difficulty": "easy" },
|
||||
"Coriandrum sativum": { "lifeCycle": "annual", "pollination": "cross", "vector": "insect", "isolationMinM": 45, "isolationMaxM": 400, "processing": "dry", "difficulty": "easy" },
|
||||
"Petroselinum crispum": { "lifeCycle": "biennial", "pollination": "cross", "vector": "insect", "isolationMinM": 300, "isolationMaxM": 800, "processing": "dry", "difficulty": "hard" },
|
||||
"Abelmoschus esculentus": { "pollination": "mixed", "vector": "insect", "isolationMinM": 150, "isolationMaxM": 500, "processing": "dry", "difficulty": "easy" }
|
||||
}
|
||||
}
|
||||
93
apps/app_seeds/assets/fonts/NotoSansArabic-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
apps/app_seeds/assets/fonts/NotoSansArabic.ttf
Normal file
93
apps/app_seeds/assets/fonts/NotoSansJP-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
apps/app_seeds/assets/fonts/NotoSansJP.ttf
Normal file
BIN
apps/app_seeds/assets/ocr_fixtures/1783672939357.jpg
Normal file
|
After Width: | Height: | Size: 399 KiB |
BIN
apps/app_seeds/assets/ocr_fixtures/1783672939375.jpg
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
apps/app_seeds/assets/ocr_fixtures/1783672939389.jpg
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
apps/app_seeds/assets/ocr_fixtures/1783672939410.jpg
Normal file
|
After Width: | Height: | Size: 340 KiB |
23
apps/app_seeds/dart_test.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Global per-test timeout. Without it, a widget test that pumpAndSettle()s a
|
||||
# screen with a never-settling source (a live Drift/Nostr stream, a periodic
|
||||
# Timer, connectivity_plus, a plugin) waits at the 10-minute default and the
|
||||
# whole run LOOKS hung. With this, such a test FAILS in 90s and names itself.
|
||||
#
|
||||
# The everyday, hang-proof gate is the logic layer, not widgets:
|
||||
# dart test packages/commons_core # pure Dart, ~1s
|
||||
# flutter test test/services # logic + in-memory DB, ~10s, no widgets
|
||||
# Run widget tests targeted (flutter test test/ui/<file>), 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"
|
||||
2151
apps/app_seeds/drift_schemas/drift_schema_v10.json
Normal file
2171
apps/app_seeds/drift_schemas/drift_schema_v11.json
Normal file
2269
apps/app_seeds/drift_schemas/drift_schema_v12.json
Normal file
1987
apps/app_seeds/drift_schemas/drift_schema_v9.json
Normal file
|
|
@ -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.
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
|
@ -1 +1 @@
|
|||
Tanemaki
|
||||
Tane
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
|
@ -1 +1 @@
|
|||
Tanemaki
|
||||
Tane
|
||||
|
|
|
|||
|
|
@ -68,5 +68,7 @@
|
|||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Used only to set your approximate sharing area — reduced to a coarse zone, never your exact location.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
191
apps/app_seeds/lib/bootstrap.dart
Normal file
|
|
@ -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<Bootstrap> createState() => _BootstrapState();
|
||||
}
|
||||
|
||||
class _BootstrapState extends State<Bootstrap> {
|
||||
late Future<TaneApp> _app = _boot();
|
||||
|
||||
Future<TaneApp> _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<LocaleStore>().saved();
|
||||
if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale);
|
||||
|
||||
final onboarding = getIt<OnboardingStore>();
|
||||
final connection = getIt.isRegistered<SocialConnection>()
|
||||
? getIt<SocialConnection>()
|
||||
: null;
|
||||
final inbox =
|
||||
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : null;
|
||||
final notifications = getIt.isRegistered<NotificationService>()
|
||||
? getIt<NotificationService>()
|
||||
: 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<SyncService>() ? getIt<SyncService>() : null;
|
||||
final plantares =
|
||||
getIt.isRegistered<PlantareService>() ? getIt<PlantareService>() : 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<VarietyRepository>(),
|
||||
species: getIt<SpeciesRepository>(),
|
||||
onboarding: onboarding,
|
||||
social:
|
||||
getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
|
||||
socialSettings: getIt<SocialSettings>(),
|
||||
connection: connection,
|
||||
location: const GeolocatorCoarseLocation(),
|
||||
outbox: getIt<OfferOutbox>(),
|
||||
messageStore: getIt<MessageStore>(),
|
||||
profileStore: getIt<ProfileStore>(),
|
||||
profileCache: getIt<ProfileCache>(),
|
||||
savedOffers: getIt<SavedOffersStore>(),
|
||||
socialAccounts: getIt<SocialAccountStore>(),
|
||||
inbox: inbox,
|
||||
notifications: notifications,
|
||||
showIntro: !await onboarding.introSeen(),
|
||||
autoBackup: getIt.isRegistered<AutoBackupService>()
|
||||
? getIt<AutoBackupService>()
|
||||
: 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<TaneApp>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic>) {
|
||||
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<String, dynamic> m, String key) => m[key] == true;
|
||||
|
||||
int _int(Map<String, dynamic> m, String key, {int? fallback}) {
|
||||
final value = m[key];
|
||||
if (value is int) return value;
|
||||
|
|
|
|||
|
|
@ -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<Movement> movements;
|
||||
final List<Party> parties;
|
||||
final List<Attachment> attachments;
|
||||
final List<Plantare> plantares;
|
||||
final List<Sale> sales;
|
||||
|
||||
/// speciesId → scientificName, for the species referenced by [varieties].
|
||||
final Map<String, String> speciesNamesById;
|
||||
|
|
|
|||
92
apps/app_seeds/lib/data/export_import/seed_label_codec.dart
Normal file
|
|
@ -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<Object?> 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=<variety>&s=<scientific>&c=<common>&y=<year>&o=<origin>`
|
||||
/// 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 = <String, String>{
|
||||
'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;
|
||||
}
|
||||
}
|
||||
35
apps/app_seeds/lib/data/seed_saving_catalog.dart
Normal file
|
|
@ -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<SeedSavingData> 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<void> 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<SeedSavingSource> get sources => data?.sources ?? const [];
|
||||
}
|
||||
|
|
@ -31,3 +31,16 @@ Future<List<SpeciesSeed>> 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<String, dynamic>)['version'] as int;
|
||||
|
|
|
|||
|
|
@ -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<void> seedBundledIfNeeded({
|
||||
required int version,
|
||||
required Future<String?> Function() readVersion,
|
||||
required Future<void> Function(String version) writeVersion,
|
||||
required Future<List<SpeciesSeed>> Function() loadSeeds,
|
||||
}) async {
|
||||
if (await readVersion() == version.toString()) return;
|
||||
await seedBundled(await loadSeeds());
|
||||
await writeVersion(version.toString());
|
||||
}
|
||||
|
||||
Future<void> seedBundled(List<SpeciesSeed> seeds) async {
|
||||
final stamp = Hlc.zero(nodeId).pack();
|
||||
await _db.transaction(() async {
|
||||
|
|
|
|||
54
apps/app_seeds/lib/db/chat_database.dart
Normal file
|
|
@ -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<Set<Column>> 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());
|
||||
}
|
||||
654
apps/app_seeds/lib/db/chat_database.g.dart
Normal file
|
|
@ -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<int> id = GeneratedColumn<int>(
|
||||
'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<String> accountScope = GeneratedColumn<String>(
|
||||
'account_scope',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _peerPubkeyMeta = const VerificationMeta(
|
||||
'peerPubkey',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> peerPubkey = GeneratedColumn<String>(
|
||||
'peer_pubkey',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _fromPubkeyMeta = const VerificationMeta(
|
||||
'fromPubkey',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> fromPubkey = GeneratedColumn<String>(
|
||||
'from_pubkey',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _bodyMeta = const VerificationMeta('body');
|
||||
@override
|
||||
late final GeneratedColumn<String> body = GeneratedColumn<String>(
|
||||
'body',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _sentAtMeta = const VerificationMeta('sentAt');
|
||||
@override
|
||||
late final GeneratedColumn<int> sentAt = GeneratedColumn<int>(
|
||||
'sent_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> 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<Message> 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<GeneratedColumn> get $primaryKey => {id};
|
||||
@override
|
||||
List<Set<GeneratedColumn>> get uniqueKeys => [
|
||||
{accountScope, peerPubkey, fromPubkey, sentAt, body},
|
||||
];
|
||||
@override
|
||||
Message map(Map<String, dynamic> 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<Message> {
|
||||
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<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<int>(id);
|
||||
map['account_scope'] = Variable<String>(accountScope);
|
||||
map['peer_pubkey'] = Variable<String>(peerPubkey);
|
||||
map['from_pubkey'] = Variable<String>(fromPubkey);
|
||||
map['body'] = Variable<String>(body);
|
||||
map['sent_at'] = Variable<int>(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<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return Message(
|
||||
id: serializer.fromJson<int>(json['id']),
|
||||
accountScope: serializer.fromJson<String>(json['accountScope']),
|
||||
peerPubkey: serializer.fromJson<String>(json['peerPubkey']),
|
||||
fromPubkey: serializer.fromJson<String>(json['fromPubkey']),
|
||||
body: serializer.fromJson<String>(json['body']),
|
||||
sentAt: serializer.fromJson<int>(json['sentAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<int>(id),
|
||||
'accountScope': serializer.toJson<String>(accountScope),
|
||||
'peerPubkey': serializer.toJson<String>(peerPubkey),
|
||||
'fromPubkey': serializer.toJson<String>(fromPubkey),
|
||||
'body': serializer.toJson<String>(body),
|
||||
'sentAt': serializer.toJson<int>(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<Message> {
|
||||
final Value<int> id;
|
||||
final Value<String> accountScope;
|
||||
final Value<String> peerPubkey;
|
||||
final Value<String> fromPubkey;
|
||||
final Value<String> body;
|
||||
final Value<int> 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<Message> custom({
|
||||
Expression<int>? id,
|
||||
Expression<String>? accountScope,
|
||||
Expression<String>? peerPubkey,
|
||||
Expression<String>? fromPubkey,
|
||||
Expression<String>? body,
|
||||
Expression<int>? 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<int>? id,
|
||||
Value<String>? accountScope,
|
||||
Value<String>? peerPubkey,
|
||||
Value<String>? fromPubkey,
|
||||
Value<String>? body,
|
||||
Value<int>? 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<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (id.present) {
|
||||
map['id'] = Variable<int>(id.value);
|
||||
}
|
||||
if (accountScope.present) {
|
||||
map['account_scope'] = Variable<String>(accountScope.value);
|
||||
}
|
||||
if (peerPubkey.present) {
|
||||
map['peer_pubkey'] = Variable<String>(peerPubkey.value);
|
||||
}
|
||||
if (fromPubkey.present) {
|
||||
map['from_pubkey'] = Variable<String>(fromPubkey.value);
|
||||
}
|
||||
if (body.present) {
|
||||
map['body'] = Variable<String>(body.value);
|
||||
}
|
||||
if (sentAt.present) {
|
||||
map['sent_at'] = Variable<int>(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<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@override
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [
|
||||
messages,
|
||||
messagesByConversation,
|
||||
];
|
||||
}
|
||||
|
||||
typedef $$MessagesTableCreateCompanionBuilder =
|
||||
MessagesCompanion Function({
|
||||
Value<int> id,
|
||||
required String accountScope,
|
||||
required String peerPubkey,
|
||||
required String fromPubkey,
|
||||
required String body,
|
||||
required int sentAt,
|
||||
});
|
||||
typedef $$MessagesTableUpdateCompanionBuilder =
|
||||
MessagesCompanion Function({
|
||||
Value<int> id,
|
||||
Value<String> accountScope,
|
||||
Value<String> peerPubkey,
|
||||
Value<String> fromPubkey,
|
||||
Value<String> body,
|
||||
Value<int> sentAt,
|
||||
});
|
||||
|
||||
class $$MessagesTableFilterComposer
|
||||
extends Composer<_$ChatDatabase, $MessagesTable> {
|
||||
$$MessagesTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<int> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get accountScope => $composableBuilder(
|
||||
column: $table.accountScope,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get peerPubkey => $composableBuilder(
|
||||
column: $table.peerPubkey,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get fromPubkey => $composableBuilder(
|
||||
column: $table.fromPubkey,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get body => $composableBuilder(
|
||||
column: $table.body,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> 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<int> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get accountScope => $composableBuilder(
|
||||
column: $table.accountScope,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get peerPubkey => $composableBuilder(
|
||||
column: $table.peerPubkey,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get fromPubkey => $composableBuilder(
|
||||
column: $table.fromPubkey,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get body => $composableBuilder(
|
||||
column: $table.body,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> 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<int> get id =>
|
||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get accountScope => $composableBuilder(
|
||||
column: $table.accountScope,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get peerPubkey => $composableBuilder(
|
||||
column: $table.peerPubkey,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get fromPubkey => $composableBuilder(
|
||||
column: $table.fromPubkey,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get body =>
|
||||
$composableBuilder(column: $table.body, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<int> 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<int> id = const Value.absent(),
|
||||
Value<String> accountScope = const Value.absent(),
|
||||
Value<String> peerPubkey = const Value.absent(),
|
||||
Value<String> fromPubkey = const Value.absent(),
|
||||
Value<String> body = const Value.absent(),
|
||||
Value<int> sentAt = const Value.absent(),
|
||||
}) => MessagesCompanion(
|
||||
id: id,
|
||||
accountScope: accountScope,
|
||||
peerPubkey: peerPubkey,
|
||||
fromPubkey: fromPubkey,
|
||||
body: body,
|
||||
sentAt: sentAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
Value<int> 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);
|
||||
}
|
||||
|
|
@ -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<void> addIfMissing(
|
||||
String column,
|
||||
GeneratedColumn<Object> 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -109,6 +109,17 @@ class Lots extends Table with SyncColumns {
|
|||
/// Distinct from [storageLocation]. Optional.
|
||||
TextColumn get preservationFormat =>
|
||||
textEnum<PreservationFormat>().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<PlantareDirection>()();
|
||||
|
||||
/// 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<PlantareStatus>().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<PlantareRemoteState>().nullable()();
|
||||
|
||||
/// How it's promised back (default `similar`: open-pollinated · non-GMO ·
|
||||
/// organically grown), mirroring the paper form's checkboxes.
|
||||
TextColumn get returnKind =>
|
||||
textEnum<PlantareReturnKind>().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<SaleDirection>()();
|
||||
|
||||
/// 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<ParentType>()();
|
||||
|
|
|
|||
|
|
@ -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<void> 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<AppDatabase>()) 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<void> configureDependencies() async {
|
|||
getIt
|
||||
..registerSingleton<SecureKeyStore>(keyStore)
|
||||
..registerSingleton<AppDatabase>(database)
|
||||
..registerSingleton<ChatDatabase>(chatDatabase)
|
||||
..registerSingleton<SpeciesRepository>(speciesRepository)
|
||||
..registerSingleton<VarietyRepository>(varietyRepository)
|
||||
..registerSingleton<FileService>(fileService)
|
||||
..registerSingleton<LabelTextExtractor>(labelExtractor)
|
||||
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
||||
..registerSingleton<LocaleStore>(LocaleStore(secretStore))
|
||||
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
||||
..registerSingleton<SocialAccountStore>(accounts)
|
||||
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
|
||||
// Per-identity stores are namespaced by the active account's scope.
|
||||
..registerSingleton<MessageStore>(
|
||||
MessageStore(chatDatabase, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ProfileStore>(
|
||||
ProfileStore(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ProfileCache>(
|
||||
ProfileCache(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<SavedOffersStore>(
|
||||
SavedOffersStore(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ExportImportService>(
|
||||
ExportImportService(
|
||||
repository: varietyRepository,
|
||||
|
|
@ -87,7 +201,194 @@ Future<void> configureDependencies() async {
|
|||
)
|
||||
..registerSingleton<RecoverySheetService>(
|
||||
RecoverySheetService(files: fileService),
|
||||
)
|
||||
..registerSingleton<LabelSheetService>(
|
||||
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>(
|
||||
AutoBackupService(
|
||||
exporter: getIt<ExportImportService>(),
|
||||
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>(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<SocialSettings>(),
|
||||
);
|
||||
getIt
|
||||
..registerSingleton<SocialService>(socialService)
|
||||
..registerSingleton<SocialConnection>(connection)
|
||||
// Tracks unread private messages so the UI can badge them.
|
||||
..registerSingleton<UnreadService>(
|
||||
UnreadService(getIt<MessageStore>(), 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>(
|
||||
InboxService(
|
||||
connection: connection,
|
||||
selfPubkey: socialService.publicKeyHex,
|
||||
store: getIt<MessageStore>(),
|
||||
profileCache: getIt<ProfileCache>(),
|
||||
unread: getIt<UnreadService>(),
|
||||
notifications: getIt<NotificationService>(),
|
||||
settings: getIt<SocialSettings>(),
|
||||
),
|
||||
)
|
||||
// Replicates the inventory across this identity's own devices over the
|
||||
// shared connection. Started in `Bootstrap`.
|
||||
..registerSingleton<SyncService>(
|
||||
SyncService(
|
||||
connection: connection,
|
||||
io: getIt<ExportImportService>(),
|
||||
localChanges: database.tableUpdates().map<void>((_) {}),
|
||||
selfDeviceId: deviceId,
|
||||
),
|
||||
)
|
||||
// Drives the bilateral signed Plantaré handshake over the shared
|
||||
// connection, reconciling moves into the local ledger. Started in
|
||||
// `Bootstrap`.
|
||||
..registerSingleton<PlantareService>(
|
||||
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<void> switchSocialAccount(int account) async {
|
||||
final secretStore = FlutterSecretStore();
|
||||
await getIt<SocialAccountStore>().setActive(account);
|
||||
final scope = socialAccountScope(account);
|
||||
final rootSeedHex = await getIt<SecureKeyStore>().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<SyncService>()) {
|
||||
await getIt<SyncService>().stop();
|
||||
await getIt.unregister<SyncService>();
|
||||
}
|
||||
if (getIt.isRegistered<InboxService>()) {
|
||||
await getIt<InboxService>().stop();
|
||||
await getIt.unregister<InboxService>();
|
||||
}
|
||||
if (getIt.isRegistered<PlantareService>()) {
|
||||
await getIt<PlantareService>().stop();
|
||||
await getIt.unregister<PlantareService>();
|
||||
}
|
||||
if (getIt.isRegistered<SocialConnection>()) {
|
||||
await getIt<SocialConnection>().dispose();
|
||||
await getIt.unregister<SocialConnection>();
|
||||
}
|
||||
if (getIt.isRegistered<UnreadService>()) {
|
||||
await getIt.unregister<UnreadService>();
|
||||
}
|
||||
if (getIt.isRegistered<SocialService>()) {
|
||||
await getIt.unregister<SocialService>();
|
||||
}
|
||||
await getIt.unregister<MessageStore>();
|
||||
await getIt.unregister<ProfileStore>();
|
||||
await getIt.unregister<ProfileCache>();
|
||||
await getIt<SavedOffersStore>().close();
|
||||
await getIt.unregister<SavedOffersStore>();
|
||||
|
||||
// Re-register the per-identity stores under the new scope, then the identity.
|
||||
getIt
|
||||
..registerSingleton<MessageStore>(
|
||||
MessageStore(getIt<ChatDatabase>(), accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ProfileStore>(
|
||||
ProfileStore(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ProfileCache>(
|
||||
ProfileCache(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<SavedOffersStore>(
|
||||
SavedOffersStore(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<UnreadService>(
|
||||
UnreadService(getIt<MessageStore>(), secretStore),
|
||||
);
|
||||
|
||||
final social = await SocialService.fromRootSeedHex(
|
||||
rootSeedHex,
|
||||
account: account,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
final connection = SocialConnection(
|
||||
social: social,
|
||||
settings: getIt<SocialSettings>(),
|
||||
);
|
||||
final inbox = InboxService(
|
||||
connection: connection,
|
||||
selfPubkey: social.publicKeyHex,
|
||||
store: getIt<MessageStore>(),
|
||||
profileCache: getIt<ProfileCache>(),
|
||||
unread: getIt<UnreadService>(),
|
||||
notifications: getIt<NotificationService>(),
|
||||
settings: getIt<SocialSettings>(),
|
||||
);
|
||||
final sync = SyncService(
|
||||
connection: connection,
|
||||
io: getIt<ExportImportService>(),
|
||||
localChanges: getIt<AppDatabase>().tableUpdates().map<void>((_) {}),
|
||||
selfDeviceId: deviceId,
|
||||
);
|
||||
final plantares = PlantareService(
|
||||
repo: getIt<VarietyRepository>(),
|
||||
selfPubkey: social.publicKeyHex,
|
||||
selfSecretKey: social.identity.privateKeyHex,
|
||||
connection: connection,
|
||||
);
|
||||
getIt
|
||||
..registerSingleton<SocialService>(social)
|
||||
..registerSingleton<SocialConnection>(connection)
|
||||
..registerSingleton<InboxService>(inbox)
|
||||
..registerSingleton<SyncService>(sync)
|
||||
..registerSingleton<PlantareService>(plantares);
|
||||
// Subscribe the listeners BEFORE the connection starts connecting.
|
||||
inbox.start();
|
||||
sync.start();
|
||||
plantares.start();
|
||||
connection.start();
|
||||
}
|
||||
|
||||
Future<Directory> _backupsDir() async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return Directory(p.join(dir.path, 'backups'));
|
||||
}
|
||||
|
||||
Future<File> _databaseFile() async {
|
||||
|
|
@ -95,6 +396,11 @@ Future<File> _databaseFile() async {
|
|||
return File(p.join(dir.path, 'tane_inventory.sqlite'));
|
||||
}
|
||||
|
||||
Future<File> _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.
|
||||
|
|
|
|||
70
apps/app_seeds/lib/domain/chat_timeline.dart
Normal file
|
|
@ -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<ChatTimelineItem> chatTimeline(List<PrivateMessage> messages) {
|
||||
final out = <ChatTimelineItem>[];
|
||||
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);
|
||||
19
apps/app_seeds/lib/domain/message_rules.dart
Normal file
|
|
@ -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);
|
||||
180
apps/app_seeds/lib/domain/seed_saving.dart
Normal file
|
|
@ -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<String, String> 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<String, SeedSavingGuide> families,
|
||||
required Map<String, SeedSavingGuide> 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<String, SeedSavingGuide> _families;
|
||||
final Map<String, SeedSavingGuide> _taxa;
|
||||
|
||||
/// Where the guidance comes from — shown as a small credit under the section.
|
||||
final List<SeedSavingSource> 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<T extends Enum>(List<T> 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<String, dynamic> 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<String, dynamic>? ?? 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<String, dynamic>;
|
||||
Map<String, SeedSavingGuide> section(String key) {
|
||||
final raw = root[key] as Map<String, dynamic>? ?? const {};
|
||||
return raw.map(
|
||||
(k, v) => MapEntry(k, parseGuide((v as Map).cast<String, dynamic>())),
|
||||
);
|
||||
}
|
||||
|
||||
final sources = [
|
||||
for (final s in (root['sources'] as List? ?? const []).cast<Map>())
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
@ -46,12 +46,12 @@ String? matchSpeciesInLabel(String label, List<SpeciesNameEntry> names) {
|
|||
if (!_containsSequence(haystack, needle)) continue;
|
||||
final chars = needle.fold<int>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
730
apps/app_seeds/lib/i18n/ast.i18n.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
726
apps/app_seeds/lib/i18n/de.i18n.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
726
apps/app_seeds/lib/i18n/fr.i18n.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
46
apps/app_seeds/lib/i18n/ja.i18n.json
Normal file
|
|
@ -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": "種を管理する"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppLocale, Translations> {
|
||||
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<AppLocale, Translations> {
|
|||
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,
|
||||
|
|
|
|||
1991
apps/app_seeds/lib/i18n/strings_ast.g.dart
Normal file
1983
apps/app_seeds/lib/i18n/strings_de.g.dart
Normal file
1983
apps/app_seeds/lib/i18n/strings_fr.g.dart
Normal file
173
apps/app_seeds/lib/i18n/strings_ja.g.dart
Normal file
|
|
@ -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: <root>
|
||||
class TranslationsJa extends Translations with BaseTranslations<AppLocale, Translations> {
|
||||
/// You can call this constructor and build your own translation instance of this locale.
|
||||
/// Constructing via the enum [AppLocale.build] is preferred.
|
||||
TranslationsJa({Map<String, Node>? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata<AppLocale, Translations>? 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 <ja>.
|
||||
@override final TranslationMetadata<AppLocale, Translations> $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<AppLocale, Translations>? 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 <ja>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<OnboardingStore>();
|
||||
// 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<VarietyRepository>(),
|
||||
species: getIt<SpeciesRepository>(),
|
||||
onboarding: onboarding,
|
||||
showIntro: !await onboarding.introSeen(),
|
||||
),
|
||||
RestartWidget(
|
||||
builder: () => TranslationProvider(child: const Bootstrap()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
97
apps/app_seeds/lib/services/auto_backup_service.dart
Normal file
|
|
@ -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<Directory> Function() directory,
|
||||
DateTime Function() now = DateTime.now,
|
||||
}) : _exporter = exporter,
|
||||
_store = store,
|
||||
_directory = directory,
|
||||
_now = now;
|
||||
|
||||
final ExportImportService _exporter;
|
||||
final AutoBackupStore _store;
|
||||
final Future<Directory> 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<DateTime?> 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<bool> 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<bool> 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<void> _rotate(Directory dir) async {
|
||||
final copies =
|
||||
(await dir.list().toList())
|
||||
.whereType<File>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
apps/app_seeds/lib/services/auto_backup_store.dart
Normal file
|
|
@ -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<DateTime?> 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<void> markBackupAt(DateTime when) =>
|
||||
_store.write(_lastKey, when.toIso8601String());
|
||||
}
|
||||
50
apps/app_seeds/lib/services/coarse_location.dart
Normal file
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/app_seeds/lib/services/device_id_store.dart
Normal file
|
|
@ -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<String>? _cached;
|
||||
|
||||
/// This install's device id, generated and persisted on first use.
|
||||
Future<String> deviceId() => _cached ??= _readOrCreate();
|
||||
|
||||
Future<String> _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();
|
||||
}
|
||||
11
apps/app_seeds/lib/services/discovery_area.dart
Normal file
|
|
@ -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);
|
||||
|
|
@ -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<List<int>> buildSnapshot() async =>
|
||||
utf8.encode(_jsonCodec.encode(await _repository.exportForSync()));
|
||||
|
||||
/// Merges a raw interchange snapshot from another device (LWW, idempotent).
|
||||
@override
|
||||
Future<void> applySnapshot(List<int> 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<Uint8List> 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<bool> 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<int> _hexToBytes(String hex) => [
|
||||
|
|
|
|||
160
apps/app_seeds/lib/services/inbox_service.dart
Normal file
|
|
@ -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<void>.broadcast();
|
||||
StreamSubscription<SocialSession?>? _sessionsSub;
|
||||
StreamSubscription<PrivateMessage>? _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<void> 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<void> 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<void> _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<void> _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<void> stop() async {
|
||||
_started = false;
|
||||
await _sessionsSub?.cancel();
|
||||
_sessionsSub = null;
|
||||
await _inboxSub?.cancel();
|
||||
_inboxSub = null;
|
||||
_session = null;
|
||||
if (!_changes.isClosed) await _changes.close();
|
||||
}
|
||||
}
|
||||
12
apps/app_seeds/lib/services/inventory_snapshot_io.dart
Normal file
|
|
@ -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<List<int>> 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<void> applySnapshot(List<int> bytes);
|
||||
}
|
||||
227
apps/app_seeds/lib/services/label_sheet_service.dart
Normal file
|
|
@ -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<Uint8List> buildPdf({
|
||||
required List<LabelSheetLabel> 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 = <pw.Widget>[];
|
||||
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<bool> saveLabels({
|
||||
required List<LabelSheetLabel> 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;
|
||||
}
|
||||
}
|
||||
45
apps/app_seeds/lib/services/locale_store.dart
Normal file
|
|
@ -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<AppLocale?> 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<void> 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<void> useDeviceLocale() async {
|
||||
await _store.write(_key, '');
|
||||
await LocaleSettings.useDeviceLocale();
|
||||
}
|
||||
}
|
||||
122
apps/app_seeds/lib/services/message_store.dart
Normal file
|
|
@ -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<List<PrivateMessage>> 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<bool> 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<List<ChatSummary>> 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 = <String>{};
|
||||
final out = <ChatSummary>[];
|
||||
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),
|
||||
);
|
||||
}
|
||||
103
apps/app_seeds/lib/services/notification_service.dart
Normal file
|
|
@ -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 <name>` 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/<pubkey>')` 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<void> 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<void> 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);
|
||||
}
|
||||
}
|
||||
55
apps/app_seeds/lib/services/offer_mapper.dart
Normal file
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
apps/app_seeds/lib/services/offer_outbox.dart
Normal file
|
|
@ -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<Set<String>> pending() async {
|
||||
final raw = await _store.read(_key);
|
||||
if (raw == null || raw.isEmpty) return <String>{};
|
||||
return raw.split('\n').where((s) => s.isNotEmpty).toSet();
|
||||
}
|
||||
|
||||
Future<void> enqueue(Iterable<String> lotIds) async {
|
||||
final next = await pending()..addAll(lotIds);
|
||||
await _write(next);
|
||||
}
|
||||
|
||||
Future<void> remove(Iterable<String> lotIds) async {
|
||||
final next = await pending()..removeAll(lotIds.toSet());
|
||||
await _write(next);
|
||||
}
|
||||
|
||||
Future<void> clear() => _store.write(_key, '');
|
||||
|
||||
Future<void> _write(Set<String> ids) => _store.write(_key, ids.join('\n'));
|
||||
}
|
||||
56
apps/app_seeds/lib/services/offer_thumbnail.dart
Normal file
|
|
@ -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<int> 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);
|
||||
}
|
||||
|
|
@ -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<bool> introSeen() async => await _store.read(_introSeenKey) == '1';
|
||||
|
||||
/// Records that the intro has been shown; subsequent launches skip it.
|
||||
Future<void> 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<bool> marketRulesAccepted() async =>
|
||||
await _store.read(_marketRulesKey) == '1';
|
||||
|
||||
/// Records the one-time acceptance of the community rules.
|
||||
Future<void> markMarketRulesAccepted() => _store.write(_marketRulesKey, '1');
|
||||
}
|
||||
|
|
|
|||
38
apps/app_seeds/lib/services/pdf_fonts.dart
Normal file
|
|
@ -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<PdfFonts> 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);
|
||||
}
|
||||
281
apps/app_seeds/lib/services/plantare_service.dart
Normal file
|
|
@ -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<void>.broadcast();
|
||||
final _pending = <String, PlantarePledge>{};
|
||||
|
||||
StreamSubscription<SocialSession?>? _sessionsSub;
|
||||
StreamSubscription<PlantareEnvelope>? _incomingSub;
|
||||
PlantareTransport? _transport;
|
||||
bool _started = false;
|
||||
|
||||
/// Fires (no payload) after an incoming move is reconciled — the UI reloads.
|
||||
Stream<void> 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<String, PlantarePledge> 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<String> 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<void> 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<void> 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<void> 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<void> _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<void> _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<void> _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<void> 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,
|
||||
};
|
||||
}
|
||||
44
apps/app_seeds/lib/services/profile_cache.dart
Normal file
|
|
@ -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<String?> name(String pubkeyHex) async {
|
||||
final value = await _store.read('$_prefix$pubkeyHex');
|
||||
return (value == null || value.isEmpty) ? null : value;
|
||||
}
|
||||
|
||||
Future<void> setName(String pubkeyHex, String name) =>
|
||||
_store.write('$_prefix$pubkeyHex', name.trim());
|
||||
|
||||
/// The cached avatar for [pubkeyHex] (a `data:` photo or `tane:seed:<glyph>`
|
||||
/// token), or null if none is known.
|
||||
Future<String?> picture(String pubkeyHex) async {
|
||||
final value = await _store.read('$_picPrefix$pubkeyHex');
|
||||
return (value == null || value.isEmpty) ? null : value;
|
||||
}
|
||||
|
||||
Future<void> 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)}';
|
||||
44
apps/app_seeds/lib/services/profile_store.dart
Normal file
|
|
@ -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<String> name() async => (await _store.read(_nameKey)) ?? '';
|
||||
Future<String> about() async => (await _store.read(_aboutKey)) ?? '';
|
||||
|
||||
/// Your Ğ1 (Duniter) address, if you chose to share one.
|
||||
Future<String> g1() async => (await _store.read(_g1Key)) ?? '';
|
||||
|
||||
/// Your avatar: either a `data:image/jpeg;base64,…` photo thumbnail or a
|
||||
/// `tane:seed:<glyph>` illustration token (see `ui/avatar.dart`). Empty for
|
||||
/// the default coloured-initial disc. Published as the kind:0 `picture`.
|
||||
Future<String> avatar() async => (await _store.read(_avatarKey)) ?? '';
|
||||
|
||||
Future<void> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
123
apps/app_seeds/lib/services/saved_offers_store.dart
Normal file
|
|
@ -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<void>.broadcast();
|
||||
|
||||
/// Emits whenever the saved set changes, so open screens can refresh.
|
||||
Stream<void> 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<Offer>> list() async {
|
||||
final raw = await _store.read(_key);
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
final entries = (jsonDecode(raw) as List)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.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<bool> isSaved(String key) async =>
|
||||
(await _rawList()).any((e) => e['key'] == key);
|
||||
|
||||
/// Saves [offer] (idempotent on its key). Newer snapshot replaces an older one.
|
||||
Future<void> 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<void> 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<bool> 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<List<Map<String, dynamic>>> _rawList() async {
|
||||
final raw = await _store.read(_key);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
return (jsonDecode(raw) as List).cast<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
Future<void> _writeRaw(List<Map<String, dynamic>> list) async {
|
||||
await _store.write(_key, jsonEncode(list));
|
||||
if (!_changes.isClosed) _changes.add(null);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _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<String, dynamic> 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<void> close() async {
|
||||
await _changes.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ShareCatalogRow> 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(
|
||||
|
|
|
|||
60
apps/app_seeds/lib/services/social_account_store.dart
Normal file
|
|
@ -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<int> 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<int> 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<void> 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<int> createNew() async {
|
||||
final next = await maxCreated() + 1;
|
||||
await setActive(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
Future<int> _readInt(String key) async {
|
||||
final raw = await _store.read(key);
|
||||
if (raw == null || raw.isEmpty) return 0;
|
||||
return int.tryParse(raw) ?? 0;
|
||||
}
|
||||
}
|
||||
107
apps/app_seeds/lib/services/social_connection.dart
Normal file
|
|
@ -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<SocialSession> Function(List<String> 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<bool>? online,
|
||||
}) : _settings = settings,
|
||||
_open = open ?? social.openSession,
|
||||
_online = online;
|
||||
|
||||
final SocialSettings _settings;
|
||||
final SessionOpener _open;
|
||||
final Stream<bool>? _online;
|
||||
|
||||
final _sessions = StreamController<SocialSession?>.broadcast();
|
||||
SocialSession? _current;
|
||||
Future<SocialSession?>? _pending;
|
||||
StreamSubscription<bool>? _onlineSub;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Emits the live session on each (re)connect, and null when it drops.
|
||||
Stream<SocialSession?> 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<SocialSession?> session() {
|
||||
if (_current != null) return Future.value(_current);
|
||||
return _pending ??= _connect();
|
||||
}
|
||||
|
||||
Future<SocialSession?> _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<void> dispose() async {
|
||||
_disposed = true;
|
||||
await _onlineSub?.cancel();
|
||||
_onlineSub = null;
|
||||
_drop();
|
||||
if (!_sessions.isClosed) await _sessions.close();
|
||||
}
|
||||
|
||||
static Stream<bool> _connectivityOnline() =>
|
||||
Connectivity().onConnectivityChanged.map((results) =>
|
||||
results.any((r) => r != ConnectivityResult.none));
|
||||
}
|
||||
137
apps/app_seeds/lib/services/social_service.dart
Normal file
|
|
@ -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<String> 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<String> 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<SocialService> fromRootSeedHex(
|
||||
String rootSeedHex, {
|
||||
int account = 0,
|
||||
String deviceId = '',
|
||||
List<String> 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<String?> 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<SocialSession> openSession(List<String> 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<void> 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;
|
||||
}
|
||||