Compare commits

..

73 commits
v0.1.0 ... main

Author SHA1 Message Date
1f3c7975eb feat(site,docs): Tane is in F-Droid — link it next to Play
All checks were successful
ci / analyze (push) Successful in 1m3s
ci / test-commons-core (push) Successful in 36s
ci / test-app-seeds (push) Successful in 6m1s
site / deploy (push) Successful in 34s
The inclusion MR was merged on 2026-07-25 and the app page went live on
2026-07-28 (v0.1.16, no antifeatures), so the landing page can finally
offer both stores.

- site: publish the F-Droid link and give each store link a single-colour
  icon, so the two are told apart by shape (play triangle vs. the
  free-software robot) rather than by brand colour. The badge row is a
  flex row now: left-aligned in the hero, centred under "Get Tane".
- README: an Install section with both stores, and a Status that matches
  reality (Block 1 shipped, Block 2 under way) instead of "early design".
- release.md: record that the app is published, that further releases only
  need a versionCode bump in the fdroiddata recipe, and that publication
  lags the green build by a day or two.
- open-decisions.md: log what unblocked inclusion — reproducible
  developer-signed builds, a Google-free APK, and opt-in networking.
2026-07-28 11:55:54 +02:00
30024ca977 chore(docs): keep internal working notes out of the public repo
Some checks failed
ci / analyze (push) Successful in 2m43s
ci / test-commons-core (push) Successful in 1m21s
ci / test-app-seeds (push) Successful in 12m53s
site / deploy (push) Failing after 1m19s
The NGI funding draft and the Ecorred meeting note are internal
working documents; they were committed by accident. They now live in
the private repo alongside the other internal notes, and docs/notes/
is ignored here so it can't happen again.
2026-07-28 07:55:13 +02:00
dd01603ad0 docs(fdroid): sync metadata mirror to v0.1.16 with the tag's full commit hash
All checks were successful
ci / analyze (push) Successful in 1m13s
ci / test-commons-core (push) Successful in 43s
ci / test-app-seeds (push) Successful in 6m22s
site / deploy (push) Successful in 30s
2026-07-25 18:46:37 +02:00
954dc2caae chore(release): prep v0.1.16 — say plainly which servers Tane talks to
All checks were successful
ci / analyze (push) Successful in 1m7s
ci / test-commons-core (push) Successful in 36s
ci / test-app-seeds (push) Successful in 5m48s
site / deploy (push) Successful in 35s
release / analyze (push) Successful in 1m7s
release / test-commons-core (push) Successful in 44s
release / test-app-seeds (push) Successful in 6m26s
release / play (push) Successful in 13m5s
release / fdroid_reference_armeabi_v7a (push) Successful in 23m4s
release / fdroid_reference_arm64_v8a (push) Successful in 23m23s
release / fdroid_reference_x86_64 (push) Successful in 23m13s
The store listing claimed "No account, no server, no trackers" while the
app was dialling four relays at launch. The app side is fixed; this is
the half a reader sees.

- Store description, both locales: the false line is gone, and a new
  "Sharing needs the internet" block names relay.comunes.org (ours),
  nos.lol, relay.damus.io and relay.primal.net, says when Tane talks to
  them, what they can see (a key and an IP — no name, no account), and
  where to switch any or all of them off, or point Tane at your own.
  Deliberately plain: no protocol names, no jargon. The audience is
  gardeners; the technical detail belongs in the F-Droid thread.
- The same correction on the landing page (feature card, FAQ, status).
- 0.1.16+18, so the F-Droid recipe's versionCodes become 181/182/183.
  F-Droid reads fastlane metadata from the checkout of the newest built
  version, so the description only reaches it via a new release.

The recipe mirror's `commit:` still points at v0.1.15 — it needs a hash
that does not exist yet, and is synced here and in the fdroiddata fork
once v0.1.16 is tagged.
2026-07-25 16:50:40 +02:00
fed0e8200e feat(sharing): make going online opt-in, and show what it unlocks
Tane dialled its four default relays at launch, before anyone had asked
for anything — an F-Droid reviewer spotted it, and they were right. The
seed book needs no network at all, so the app should not have one until
the person joins the sharing side.

- SocialSettings gains a three-state `sharingEnabled`. `null` means
  "never asked", which is what lets `migrateSharingEnabled` keep an
  existing install exactly as it was: anyone past the intro was on a
  build that connected at launch, so they keep messaging, device sync
  and offer alerts. A fresh install starts fully offline.
- bootstrap only starts the shared connection when sharing is on. The
  inbox/sync/plantaré/alert listeners are untouched: they react to a
  session, and none arrives.
- SharingSwitch is the single place that moves the stored choice, the
  live connection and the flag the UI listens to, so they cannot drift.
- Agreeing to the community rules is the opt-in — one consent surface,
  reached from the market or from the drawer's invitation.
- SocialConnection.start is now idempotent and gains stop(), so turning
  sharing off goes offline immediately instead of at the next launch.
- The social drawer entries stay visible but padlocked while sharing is
  off; tapping one explains what wakes up and offers to join. Hiding
  them would have kept the tool a secret. "Coming soon" is gone for
  good — everything it labelled is built.

Covered by tests for the migration in both directions, start/stop
lifecycle, the gate turning sharing on, the invitation, and the drawer
in all three states (no social layer / off / on).
2026-07-25 16:47:56 +02:00
62123582f5 docs(fdroid): sync metadata mirror to v0.1.15 with full commit hash 2026-07-25 16:30:53 +02:00
7ae35920f8 ci(release): gate Play + F-Droid deploy on the test suite
Forgejo workflows are independent — ci.yml never triggers on tag pushes
(its branches filter skips refs/tags/*), so releases shipped with zero
test gating. Duplicate analyze + test-commons-core + test-app-seeds into
release.yml and make play needs them; gate the fdroid chain on the same
tests via explicit result checks while keeping !cancelled() so it stays
decoupled from play's result. Red suite now blocks every deploy.
2026-07-25 16:29:41 +02:00
f04dd3da1a fix(fdroid): drop Google Play Core so the scanner accepts the APK; prep v0.1.15
All checks were successful
ci / analyze (push) Successful in 1m5s
ci / test-commons-core (push) Successful in 37s
ci / test-app-seeds (push) Successful in 5m44s
site / deploy (push) Successful in 32s
release / play (push) Successful in 11m49s
release / fdroid_reference_armeabi_v7a (push) Successful in 22m20s
release / fdroid_reference_arm64_v8a (push) Successful in 23m16s
release / fdroid_reference_x86_64 (push) Successful in 22m11s
F-Droid's APK scanner rejected v0.1.14: 6 com.google.android.play.core.*
(SplitInstall/SplitCompat/tasks) references. They came from Flutter's
io.flutter.embedding.engine.deferredcomponents.PlayStoreDeferredComponentManager
being pinned by our over-broad '-keep class io.flutter.** { *; }' ProGuard
rule — Tane uses no deferred components, so R8 should shrink that manager
away (as it does for immich and other Flutter F-Droid apps, which don't
carry such a keep). Narrow the keep to io.flutter.plugins.**; JNI-critical
embedding classes (FlutterJNI, …) stay via the embedding AAR's own consumer
ProGuard rules — verified the built APK still contains FlutterJNI and no
com.google.android.play.core.* at all. Also exclude the com.google.android.play
group at the dependency level (belt-and-suspenders) + -dontwarn.

Bumps to 0.1.15 (+17); fdroid recipe versionCodes 171/172/173.
2026-07-23 10:47:01 +02:00
facd72db16 ci(release): apksigner --alignment-preserved true (reproducibility, final layer)
All checks were successful
ci / analyze (push) Successful in 1m29s
ci / test-commons-core (push) Successful in 55s
ci / test-app-seeds (push) Successful in 9m51s
Even with the /home/vagrant path fix and v2/v3-only signing, the verify
still failed on a v3 CHUNKED_SHA512 mismatch with an EMPTY file diff. Cause
found by byte-level compare: apksigner (build-tools 35+) re-aligns native
libs to 16KB pages by default (--alignment-preserved defaults to false),
rewriting each .so's ZIP local-header extra field from gradle's 0x0 padding
to the 0xd935 alignment marker. F-Droid's reproducibility check compares
against the raw gradle output (0x0), so our re-aligned reference (0xd935)
differed in the signed bytes though every file was identical. Proven: 273
entries differed only in the extra field, 0 in compressed data. Signing
F-Droid's own unsigned build with --alignment-preserved true yields content
byte-identical to it (0 extra-field diffs, 0 data diffs). So preserve the
build's alignment when signing the reference.
2026-07-22 22:57:51 +02:00
6c9de115e9 ci(release): sign fdroid references v2/v3 only (no v1) for reproducibility
All checks were successful
ci / analyze (push) Successful in 1m14s
ci / test-commons-core (push) Successful in 45s
ci / test-app-seeds (push) Successful in 6m28s
The reproducible-build verify failed even after the /home/vagrant path fix
made every file identical: our reference APK carried a v1 (JAR) signature,
which adds META-INF/{MANIFEST.MF,*.SF,*.RSA} as three extra ZIP ENTRIES.
F-Droid's apksigcopier transplants only the v2/v3 signing block onto its
rebuild, so those 3 v1 entries make the reference's signed content differ
from F-Droid's build — apksigner then reports a v3 CHUNKED_SHA512 digest
mismatch though 'diff -r' shows no file differences. Proven by zipinfo: our
ref had 486 entries vs F-Droid's 483, the delta being exactly the v1 files;
the shared 483 are byte-identical. minSdk is 24 so v1 is never needed.
Sign v2/v3 only so the reference byte-matches F-Droid's rebuild.
2026-07-22 20:37:38 +02:00
8818576ee8 fix(android): declare INTERNET permission in the main manifest (release had none)
Some checks failed
ci / analyze (push) Successful in 1m25s
ci / test-commons-core (push) Successful in 50s
site / deploy (push) Successful in 30s
release / play (push) Successful in 36m40s
ci / test-app-seeds (push) Failing after 17m37s
release / fdroid_reference_armeabi_v7a (push) Successful in 24m4s
release / fdroid_reference_arm64_v8a (push) Successful in 25m5s
release / fdroid_reference_x86_64 (push) Successful in 25m14s
Flutter auto-adds android.permission.INTERNET only to the debug/profile
manifests, so every RELEASE build (Play + F-Droid) shipped with no network
permission at all — the whole social layer (market, messaging, trust, sync)
was dead on Android release, while desktop (no permission model) worked. The
market's 'can't reach the servers' was the visible symptom. Adds it to
android/app/src/main/AndroidManifest.xml.

Release prep v0.1.14: pubspec 0.1.13+15 -> 0.1.14+16; fdroid recipe
versionCodes 161/162/163, commit v0.1.14, CurrentVersionCode 163.
2026-07-22 17:11:12 +02:00
3d042c673e chore(release): prep v0.1.13 — point fdroid recipe at v0.1.13 so references build
Some checks failed
ci / analyze (push) Successful in 1m22s
ci / test-commons-core (push) Successful in 45s
ci / test-app-seeds (push) Successful in 16m43s
site / deploy (push) Successful in 31s
release / play (push) Successful in 16m54s
release / fdroid_reference_arm64_v8a (push) Has been cancelled
release / fdroid_reference_x86_64 (push) Has been cancelled
release / fdroid_reference_armeabi_v7a (push) Has been cancelled
v0.1.11 and v0.1.12 bumped pubspec without advancing the fdroid recipe, so the
fdroid_reference job computed versionCodes 131/141 that the recipe (still
121/122/123) didn't define — 'fdroid build' found no such versionCode and the
job failed. The Play deploys were unaffected.

- pubspec: 0.1.12+14 -> 0.1.13+15.
- fdroid recipe: Builds versionName 0.1.13, versionCodes 151/152/153,
  commit v0.1.13; CurrentVersion 0.1.13, CurrentVersionCode 153.

Play accepts versionCode 15 (new); fdroid_reference now builds 151/152/153.
2026-07-22 16:49:55 +02:00
05e4689339 chore(release): v0.1.12 — fresh-install market connectivity + honest server copy
Some checks failed
ci / analyze (push) Successful in 1m13s
ci / test-commons-core (push) Successful in 38s
ci / test-app-seeds (push) Successful in 7m19s
site / deploy (push) Successful in 34s
release / play (push) Successful in 14m52s
release / fdroid_reference_armeabi_v7a (push) Failing after 11s
release / fdroid_reference_arm64_v8a (push) Has been skipped
release / fdroid_reference_x86_64 (push) Has been skipped
Bumps 0.1.11+13 -> 0.1.12+14. Ships the market fixes: auto-recovery when the
shared relay connection comes up (no more stuck 'can't reach the servers' on a
fresh install), per-relay connect timeout, zone prompt before the connection
error, un-clipped Save button, and honest 'community servers' copy in app + site.

fdroid recipe intentionally not bumped here (as with v0.1.11); F-Droid is tracked
in its own reproducible-build MR.
2026-07-22 16:08:34 +02:00
2c04f4bfb6 fix(site): honest decentralization copy — community servers, not 'no server'
The landing claimed seeds spread 'with no server in the middle' and the About
page said people communicate 'directly with each other', while sharing in fact
travels through community relays. Reworded (en/es/pt, docs sources + generated
about pages via build-legal.sh): what you share travels through community
servers run by people and collectives — many of them, anyone can add one, none
a controllable center.
2026-07-22 16:08:34 +02:00
1a946d5c26 fix(i18n): honest sharing intro — community servers exist, no company runs them
The setup intro claimed sharing happened 'with no company in the middle'
while the sheet itself lists community servers. Reworded in all repo-authored
locales (en/es/fr/de/pt/pt_BR/ast): what you offer travels through community
servers run by people and collectives rather than a company.
2026-07-22 16:08:34 +02:00
13b6b4bfce fix(market): zone prompt before connection error; keep Save above the nav bar
- A new user's first step (set your area) works offline, so that empty state
  now wins over 'can't reach the servers'
- The sharing-setup sheet wraps in SafeArea(top: false) so the Save button
  isn't clipped by the system navigation bar on edge-to-edge devices
2026-07-22 16:08:34 +02:00
8a7dc8d3dc fix(market): recover by itself when the shared connection comes up
Fresh installs could sit on 'can't reach the servers' forever: the offers
cubit captured the transport once at build time, the shared connection only
retried on a connectivity CHANGE, and a silently-filtered relay could stall
the pool for minutes.

- SocialConnection: retry with backoff after a failed attempt while started
  and not knowingly offline (injectable schedule for tests)
- OffersCubit: follow connection.sessions, re-attach the transport and re-run
  the last discovery on (re)connect; announce drops via connectionEpoch
- market _init: record the wanted area on the cubit even while offline
- NostrOfferTransport.discoverPage: sort a copy (channel lists may be
  unmodifiable)
2026-07-22 16:08:34 +02:00
981cf10048 fix(social): cap relay connect with a timeout so one hung relay can't stall the pool 2026-07-22 16:08:34 +02:00
3ec6ed2329 v0.1.11 2026-07-22 16:08:34 +02:00
4963282620 fix(site): only wrap header nav on small screens if it overflows
The forced width:100% on .site-nav below 560px was written back when
the language switcher spelled out every language name and needed the
room. Now that it's a compact pill, the header fits on one line down
to ~360px; let flex-wrap handle the actual overflow case instead of
always forcing a second row.
2026-07-22 16:08:34 +02:00
8aa37bde34 ci(release): retry network-fragile steps (fdroidserver download + fdroid build)
All checks were successful
ci / analyze (push) Successful in 1m22s
ci / test-commons-core (push) Successful in 46s
ci / test-app-seeds (push) Successful in 7m7s
Even after the dnsmasq DNS cache, the Hetzner vSwitch still blips
mid-transfer. Two spots kept killing ~25min builds: the fdroidserver
tarball 'curl -sL | tar' (an empty stream -> 'gzip: unexpected end of
file') and maven/pub fetches mid-Gradle ('Network is unreachable').
Download fdroidserver to a file with curl --retry (+ -f + validate the
gzip before extract), and wrap fdroid build in a 3-attempt loop gated on
the APK actually appearing (fdroid build exits 0 even on failure).
2026-07-22 12:17:11 +02:00
92cc2e6232 ci(release): build fdroid references under /home/vagrant to match F-Droid (reproducibility)
All checks were successful
ci / analyze (push) Successful in 1m12s
ci / test-commons-core (push) Successful in 39s
ci / test-app-seeds (push) Successful in 6m30s
F-Droid's reproducible-build verification failed: the native .so
(Flutter libapp/libdartjni + tesseract4android libjpeg/leptonica/pngx/
libtesseract) differed byte-for-byte. Root cause: F-Droid builds under
/home/vagrant/build/<appid> (its buildserver path) while our reference
built under /tmp/fdroiddata/build/<appid> — the differing absolute build
path leaks into the native libs (debug info / embedded paths). Build the
reference under /home/vagrant so app + srclib paths match F-Droid's.

Also: add a workflow_dispatch ref_tag input to rebuild+re-upload an
existing release's reference APKs (idempotent asset replace) without
cutting a new tag or re-deploying to Play — so reproducibility fixes can
be iterated on the same v* release.
2026-07-22 09:56:59 +02:00
841cb9aaec ci(release): serialize play before the fdroid_reference chain
All checks were successful
ci / analyze (push) Successful in 3m17s
ci / test-commons-core (push) Successful in 1m26s
ci / test-app-seeds (push) Successful in 11m1s
A tag push started play (AAB → Play) and fdroid_reference_armeabi_v7a at
the same instant; two heavy Android builds on the shared runner
(capacity:2) OOM-killed play's Gradle daemon ('daemon disappeared') deep
in bundleRelease. Chain the fdroid references after play with needs:play
+ if:always() so only one Android build runs at a time. if:always() keeps
the fdroid chain independent of play's result (still runs when play fails
or, on workflow_dispatch, is skipped).
2026-07-21 23:57:38 +02:00
538b28e5d2 Lang menu and autodetect
All checks were successful
ci / test-commons-core (push) Successful in 56s
ci / analyze (push) Successful in 1m39s
site / deploy (push) Successful in 44s
ci / test-app-seeds (push) Successful in 7m29s
release / fdroid_reference_armeabi_v7a (push) Successful in 42m36s
release / fdroid_reference_arm64_v8a (push) Successful in 33m58s
release / fdroid_reference_x86_64 (push) Successful in 26m15s
release / play (push) Successful in 12m49s
2026-07-21 19:15:42 +02:00
1b439e9499 chore(release): prep v0.1.10 — harden play vs jcenter/OOM, point recipe at v0.1.10
Some checks failed
ci / test-commons-core (push) Successful in 56s
ci / analyze (push) Successful in 1m47s
site / deploy (push) Successful in 34s
ci / test-app-seeds (push) Has been cancelled
- play job: apply the same two fixes the fdroid recipe uses (strip
  flutter_tesseract_ocr's dead-jcenter buildscript; cap Gradle heap to
  -Xmx4g + no daemon) so a cold-cache production AAB build can't hit
  either blocker. play builds the AAB directly, not via the recipe, so
  it was still exposed.
- pubspec: 0.1.9+11 -> 0.1.10+12.
- fdroid recipe: versionName 0.1.10, versionCodes 121/122/123,
  commit v0.1.10, CurrentVersion(Code) 0.1.10/123. binary: uses %v so
  it resolves to the v0.1.10 release assets the tag build will upload.

The v0.1.10 tag is intentionally NOT cut here: cutting it deploys to
Google Play PRODUCTION (100%) and uploads the 3 signed reference APKs.
2026-07-21 19:12:11 +02:00
e4c62f2b56 fix(fdroid): cap Gradle heap + drop daemons so the build survives host RAM pressure
All checks were successful
ci / test-commons-core (push) Successful in 54s
ci / analyze (push) Successful in 1m51s
site / deploy (push) Successful in 40s
ci / test-app-seeds (push) Successful in 9m28s
The reproducible build OOM-killed the Gradle daemon ('daemon disappeared
unexpectedly') mid flutter-build-apk on the shared runner. The app's
gradle.properties reserves -Xmx8G+4G metaspace (~12G), and the fdroid
build also compiles tesseract4android from source first (its daemon
lingers), so combined peak RAM trips the OS OOM-killer under contention
(the same build succeeded on a quieter host earlier). Scope the fix to
the fdroid build (don't touch the committed 8G the play/local builds
rely on): run tesseract's gradle with --no-daemon so it frees memory
before the app build, and append a lower -Xmx4g + org.gradle.daemon=false
override to gradle.properties in prebuild (last-key-wins; F-Droid runs
the same steps and memory settings don't change output bytes, so it stays
reproducible).
2026-07-21 17:43:37 +02:00
08459ad29d fix(fdroid): strip flutter_tesseract_ocr's stale jcenter buildscript
All checks were successful
ci / test-commons-core (push) Successful in 45s
ci / analyze (push) Successful in 1m30s
site / deploy (push) Successful in 34s
ci / test-app-seeds (push) Successful in 8m8s
The plugin's android/build.gradle pins its own AGP 7.1.2 and resolves it
via jcenter() (dead since 2021). On a cold Gradle cache the AGP-7.1.2
transitives (apkzlib -> fastutil/json-simple/javawriter) only resolve
from jcenter and fail hard, breaking the reproducible fdroid build.
Warm-cache runs masked it (the one green armeabi-v7a run never hit
jcenter). Strip that buildscript block in prebuild so the plugin
inherits the app's AGP 8.11.1 (settings.gradle.kts) and never touches
jcenter or old AGP at all.
2026-07-21 16:50:11 +02:00
64b974a7a6 ci(release): chain fdroid_reference ABIs with needs: (matrix max-parallel not honored by this runner)
All checks were successful
ci / analyze (push) Successful in 2m27s
ci / test-commons-core (push) Successful in 1m16s
ci / test-app-seeds (push) Successful in 12m7s
2026-07-21 13:11:08 +02:00
6a65175154 ci(release): serialize fdroid_reference matrix (max-parallel:1) to avoid OOM contention
All checks were successful
ci / test-commons-core (push) Successful in 45s
ci / analyze (push) Successful in 1m33s
ci / test-app-seeds (push) Successful in 9m9s
2026-07-21 12:31:07 +02:00
fa768ef17f feat(site): add Portuguese to the landing site
All checks were successful
ci / analyze (push) Successful in 2m43s
ci / test-commons-core (push) Successful in 1m32s
site / deploy (push) Successful in 1m23s
ci / test-app-seeds (push) Successful in 12m26s
Mirror the es/ pattern: docs/legal/pt/ + docs/o-que-e-tane.md as the
Portuguese source of truth, build-legal.sh extended to generate the
Hugo pt.* pages from them, config.toml gets a [languages.pt] entry,
i18n/pt.json translates the UI chrome. og-pt.png is a placeholder copy
of og-en.png until a proper Portuguese social card is made by hand
(same as the other og-*.png, per DEPLOY.md). Verified locally with the
Docker build: /pt/, /pt/about/, all four /pt/legal/ pages and the
lang-switch link all resolve correctly.
2026-07-20 23:35:26 +02:00
a47bd6badd docs(claude): point git note at git.comunes.org origin; note pre-split backup
Some checks failed
ci / analyze (push) Successful in 2m1s
ci / test-commons-core (push) Successful in 1m35s
ci / test-app-seeds (push) Has been cancelled
The bare ~/repos/tane.git was renamed to ~/repos/tane-pre-split-2026-07-15.git
(archived pre-filter-repo history) and detached as a remote; origin is now the
forge at git.comunes.org.
2026-07-20 23:16:14 +02:00
d80f6d50fa feat(i18n): complete Portuguese, add Brazilian Portuguese (pt_BR)
Some checks are pending
ci / analyze (push) Waiting to run
ci / test-commons-core (push) Waiting to run
ci / test-app-seeds (push) Waiting to run
Fill the ~74 UI strings pt.i18n.json was missing (saved searches, label
scanning, Plantaré, lot history), then add pt_BR as a proper Brazilian
variant rather than a copy of European Portuguese: tu/teu/tua -> você/
seu/sua with matching verb conjugation, partilhar -> compartilhar, and
Guardar -> Salvar for UI actions (seed-saving keeps "guardar", the
correct term in both variants). Wires AppLocale.ptBr into the language
picker and regenerates slang output.
2026-07-20 23:11:01 +02:00
071be44851 perf(android): R8, bitmap downscaling, edge-to-edge; recover device compat; publish to production
Some checks are pending
ci / analyze (push) Waiting to run
ci / test-commons-core (push) Waiting to run
ci / test-app-seeds (push) Waiting to run
site / deploy (push) Successful in 1m8s
Device compatibility (regression fix):
- The CAMERA permission (from zxing_barcode_scanner / image_picker) implicitly
  required android.hardware.camera, excluding camera-less devices — Play showed
  Automotive -96%, Chromebook -86%, TV -25%. Declare camera & location
  uses-feature required="false" to keep those devices supported.
- Detect a real camera at runtime (PackageManager.FEATURE_CAMERA_ANY via the
  existing MethodChannel), cache it at bootstrap, and hide the QR scan button and
  the camera photo-source option when absent, so no broken actions are offered.

Play "app optimization" recommendations:
- Enable R8 (isMinifyEnabled + isShrinkResources) with keep rules for the
  OCR (tesseract4android), SQLCipher and notifications JNI/reflection code.
- Bitmap downscaling: cacheWidth/cacheHeight (and ResizeImage for avatars) on
  list thumbnails and avatars so photos decode to on-screen size, not full res.
- Edge-to-edge: opt in with transparent system bars in main().

Release flow:
- Tagged builds now publish to the production track at 100% (was internal);
  add a manual deploy_internal lane as a QA safety net.
- Document country/region availability as a Play Console setting (not in-repo).
2026-07-20 22:53:48 +02:00
f17ae7751c perf(market): paginate Nostr offer discovery, infinite scroll, memory cap
Bound market memory and let large areas page instead of loading everything:
- DiscoveryQuery gains an until cursor; OfferTransport gains discoverPage()
  (one-shot, EOSE-bounded, newest-first) alongside the existing live discover()
  stream, which now accepts since so it only carries NEW offers going forward.
- NostrOfferTransport.discoverPage sorts by created_at desc and derives the
  next cursor from the oldest event seen (not the oldest type-matched one, so
  filtering never skips a page).
- OffersCubit: discover() fetches the first page + opens a since=now live sub;
  loadNextPage() pages further back; the in-memory list is capped at 400
  offers (each can carry a ~40KB inline photo thumbnail, so unbounded growth in
  a busy area was a real OOM risk).
- market_screen: infinite scroll via a scroll-position trigger + footer
  spinner, instead of a single unbounded ListView.
- Added discoverPage unit tests (commons_core) and cubit pagination tests
  (first page/cursor, accumulation, cap, cross-page dedup).
2026-07-20 22:53:48 +02:00
3de01bd948 perf(inventory): lazy list, photo thumbnails, indexes, debounced reload
Scale the local inventory to 10k+ varieties:
- Render the list with ListView.builder over a flattened header/item model
  instead of building every tile upfront.
- Store a small regenerable JPEG thumbnail per photo (schema v14) and use it
  for the 48px list avatar; full bytes stay for offer image hosting. Existing
  photos are backfilled lazily at startup. Thumbnail is local-only (excluded
  from CRDT sync and backups by the JSON codec).
- Add indexes on varieties(is_deleted,is_draft), attachments(parent_type,
  parent_id,kind), lots(variety_id) via @TableIndex.
- Debounce watchInventoryView (~250ms) so a burst of table writes triggers one
  reload, not seven.
- cacheWidth/cacheHeight on the list avatar decode.
- Scale test raised 3k -> 10k; migration test v13 -> v14.
2026-07-20 22:53:48 +02:00
2884ddd3c7 ci(release): split fdroid_reference into a 3-way ABI matrix to fit the runner's max job time
All checks were successful
ci / test-commons-core (push) Successful in 1m4s
ci / analyze (push) Successful in 2m5s
ci / test-app-seeds (push) Successful in 8m44s
2026-07-20 22:37:42 +02:00
0763047d02 ci(release): pre-clone app into fdroid build dir so SOURCE_DATE_EPOCH resolves; fail fast unless 3 APKs
All checks were successful
ci / test-commons-core (push) Successful in 59s
ci / analyze (push) Successful in 2m1s
ci / test-app-seeds (push) Successful in 10m58s
2026-07-20 21:39:56 +02:00
06e2ad29c9 chore(release): v0.1.9 (+11) — single-run reproducible developer-signed (SOURCE_DATE_EPOCH comes from the app commit, not fdroiddata)
Some checks failed
ci / test-commons-core (push) Successful in 43s
ci / analyze (push) Successful in 1m24s
site / deploy (push) Successful in 28s
ci / test-app-seeds (push) Successful in 9m35s
release / fdroid_reference (push) Failing after 21s
release / play (push) Successful in 18m50s
2026-07-20 17:24:28 +02:00
bec3493e37 Revert "ci(release): revert to F-Droid-signed recipe; drop pre-merge reference build"
This reverts commit 4d99e1a840.
2026-07-20 17:13:46 +02:00
e054e280dc Revert "docs(fdroid): post-merge runbook for reproducible developer-signed builds"
This reverts commit 85a75ac3b7.
2026-07-20 17:13:46 +02:00
85a75ac3b7 docs(fdroid): post-merge runbook for reproducible developer-signed builds
All checks were successful
ci / test-commons-core (push) Successful in 51s
ci / analyze (push) Successful in 1m35s
site / deploy (push) Successful in 34s
ci / test-app-seeds (push) Successful in 6m40s
2026-07-19 11:36:15 +02:00
4d99e1a840 ci(release): revert to F-Droid-signed recipe; drop pre-merge reference build
All checks were successful
ci / test-commons-core (push) Successful in 44s
ci / analyze (push) Successful in 1m26s
site / deploy (push) Successful in 30s
ci / test-app-seeds (push) Successful in 6m8s
2026-07-19 11:19:46 +02:00
831730e308 ci(release): redirect app clone to internal Forgejo; robust apksigner; manual dispatch (play=tags only)
All checks were successful
ci / test-commons-core (push) Successful in 54s
ci / analyze (push) Successful in 1m48s
ci / test-app-seeds (push) Successful in 6m44s
2026-07-19 10:31:36 +02:00
50edb5ff75 ci(release): vendor fdroid config+srclibs in-repo (no gitlab clone); v0.1.8 (+10)
Some checks failed
ci / test-commons-core (push) Successful in 59s
ci / analyze (push) Successful in 1m53s
site / deploy (push) Successful in 33s
ci / test-app-seeds (push) Successful in 9m52s
release / fdroid_reference (push) Failing after 13s
release / play (push) Successful in 19m22s
2026-07-19 09:54:31 +02:00
9b0ef1fc40 chore(release): v0.1.7 (+9) — reference APKs via fdroid buildserver
Some checks failed
ci / test-commons-core (push) Successful in 44s
ci / analyze (push) Successful in 1m27s
site / deploy (push) Successful in 29s
ci / test-app-seeds (push) Successful in 8m51s
release / fdroid_reference (push) Failing after 1m32s
release / play (push) Successful in 17m48s
2026-07-19 09:32:39 +02:00
b906161d70 ci(release): produce F-Droid reference APKs via fdroid build in buildserver image, then sign 2026-07-19 08:56:54 +02:00
654ed1fe68 ci(release): route dead jcenter deps to mavenCentral; v0.1.6 (+8)
Some checks failed
ci / test-commons-core (push) Successful in 1m3s
ci / analyze (push) Successful in 2m2s
ci / test-app-seeds (push) Successful in 10m51s
release / android (push) Failing after 17m23s
2026-07-19 07:09:07 +02:00
bbd8d3580f chore(release): v0.1.5 (+7) — retag with tesseract build fix
Some checks failed
ci / test-commons-core (push) Successful in 50s
ci / analyze (push) Successful in 1m39s
ci / test-app-seeds (push) Successful in 10m14s
release / android (push) Failing after 20m15s
2026-07-19 06:38:21 +02:00
f968f64de8 ci(release): fix tesseract build — use root gradlew with :tesseract4android task 2026-07-19 06:36:25 +02:00
a575f01207 ci(release): build tesseract4android from source for F-Droid reproducibility; v0.1.4 (+6)
Some checks failed
ci / test-commons-core (push) Successful in 1m27s
ci / analyze (push) Successful in 2m17s
release / android (push) Failing after 4m50s
ci / test-app-seeds (push) Successful in 10m40s
2026-07-19 06:18:04 +02:00
b76083be57 chore(release): v0.1.3 (+5) — reproducible developer-signed F-Droid
All checks were successful
ci / test-commons-core (push) Successful in 1m0s
ci / analyze (push) Successful in 1m55s
site / deploy (push) Successful in 34s
ci / test-app-seeds (push) Successful in 11m39s
release / android (push) Successful in 24m33s
2026-07-19 00:03:53 +02:00
1a826477f2 feat(app): hint isolation distance when sowing a cross-pollinating crop
Some checks failed
ci / test-commons-core (push) Successful in 58s
ci / analyze (push) Successful in 1m49s
site / deploy (push) Successful in 38s
ci / test-app-seeds (push) Has been cancelled
One passive line in the batch story after a 'sown today' tap — 'this
species crosses, grow it ~400 m from others' — sourced from the bundled
seed-saving guidance (family default + species override). Selfers get
nothing: the trap is recording isolation religiously for beans where it
never mattered. No input fields, no schema change.
2026-07-18 12:31:42 +02:00
f1aa8f8e66 feat(app): scan a printed seed label back into its record
Closes the physical↔digital loop the labels opened: the QR on a printed
envelope (tane://seed, already encoded by seed_label_codec) can now be
scanned from the inventory bar. A known label opens its variety; an
unknown one asks before adding anything, then creates the variety (species
linked by exact scientific name when bundled) with a lot carrying the
label's year and origin. Scanner is pure ZXing (zxing_barcode_scanner,
MIT) — no ML Kit, no Play Services, F-Droid-safe, the stack Ğ1nkgo ships.
Mobile-only; other platforms don't show the button (OCR precedent).
The post-scan flow is a plain function (handleScannedPayload) so it's
widget-tested without a camera.
2026-07-18 12:26:27 +02:00
bb5b3d4a43 feat(app): ask 'how did it do?' when a harvest is noted (schema v13)
The note growers most wish they had two seasons later — did it do well,
was it worth keeping — never had a home. New append-only GardenOutcomes
table (lotId, season year, three-face rating, free note); the question
appears ONCE, inline, right after recording a harvest in the batch story,
and is skippable without a trace. The answer shows as one more story
line. Rides backups/sync like every mutable table (JSON codec, LWW
import, HLC clock absorb). Migration v12→v13 guarded + verified from
every historical version.
2026-07-18 12:18:25 +02:00
92fd84590b feat(app): show each batch's story as a composite timeline
The Movement log (history + provenance DAG) was recorded but invisible.
A new history sheet on every lot tile narrates the batch: movements,
germination tests, storage checks and how it entered the collection
(origin attached), most-recent first. Two one-tap chips record 'sown
today' / 'harvested today' — the first UI door to the sown/harvested
movement types. No schema change; one-shot reads only (no live
subscriptions in a transient sheet).
2026-07-18 06:25:55 +02:00
11cbdf3022 docs(site): document the automatic Forgejo Actions deploy
All checks were successful
ci / test-commons-core (push) Successful in 56s
ci / analyze (push) Successful in 1m51s
site / deploy (push) Successful in 36s
ci / test-app-seeds (push) Successful in 7m10s
The manual Ansible playbook was superseded by the site.yml workflow,
which deploys on every push to main touching site/** or docs/**.
Keep Ansible documented only as the manual fallback.
2026-07-18 00:30:05 +02:00
0aa8dd91ab feat(site): show discreet # permalinks on landing section titles
All checks were successful
ci / test-commons-core (push) Successful in 49s
ci / analyze (push) Successful in 1m34s
site / deploy (push) Successful in 36s
ci / test-app-seeds (push) Successful in 6m19s
The landing sections had ids but no visible anchor, unlike About/Legal.
Add a hover-revealed # link on each section title (Status, Features,
Screenshots, Values, FAQ, Collaborate, Get it) and widen the reveal rule
beyond .doc so it applies to the home too.
2026-07-17 17:36:32 +02:00
8bec7dcde2 feat(android): split release APK per ABI for F-Droid
All checks were successful
release / android (push) Successful in 13m47s
Adds a versionCodeOverride to build.gradle.kts (F-Droid requires distinct
versionCodes per split APK; Google Play's App Bundle is unaffected since
this only touches the application-variant/APK output). Bumps to 0.1.2+4.

Verified locally: flutter build apk --release --split-per-abi produces
app-{armeabi-v7a,arm64-v8a,x86_64}-release.apk with versionCode 31/32/33.
2026-07-17 17:36:18 +02:00
bfd29f19b9 feat(site): add section and heading anchors for deep-linking
All checks were successful
ci / test-commons-core (push) Successful in 44s
ci / analyze (push) Successful in 1m25s
site / deploy (push) Successful in 32s
ci / test-app-seeds (push) Successful in 5m51s
Landing sections now carry ids (status, features, faq, collab, …) so any
section is directly linkable (e.g. /#faq). About and Legal headings get a
discreet, hover-revealed permalink via a goldmark heading render hook.
A scroll-margin-top offset keeps anchored targets clear of the sticky header.
2026-07-17 17:28:39 +02:00
b0325fd40d feat(site): mention saved-search alerts on the landing page
All checks were successful
ci / test-commons-core (push) Successful in 47s
ci / analyze (push) Successful in 1m31s
site / deploy (push) Successful in 33s
ci / test-app-seeds (push) Successful in 6m8s
Add the saved-searches-with-alerts market feature to the "share and
stay connected" list, EN and ES.
2026-07-17 13:34:46 +02:00
87e87abc7c chore(i18n): regenerate strings.g.dart (slang)
All checks were successful
ci / test-commons-core (push) Successful in 43s
ci / analyze (push) Successful in 1m23s
ci / test-app-seeds (push) Successful in 5m48s
2026-07-17 13:18:29 +02:00
93028c4933 feat(market): saved searches with in-app alerts (Wallapop-style)
Save the current market search (query + facets) and get notified when a
matching offer appears in your zone while the app is open, with a catch-up
scan on start. Adds a save affordance in the search bar, a saved-searches
list (apply/delete) with a per-search new-match badge, an AppBar entry
badged with the unseen total, and notification-tap routing to the list.
Alerts evaluate against the user's current area, dedup via seen keys, and
skip own/blocked/hidden/non-active offers. en+es strings.
2026-07-17 13:18:29 +02:00
c0cd299408 feat(social): add SavedSearchAlertService + notification alerts channel
One discovery subscription over the shared connection for the user's
current zone; matches each incoming offer against saved searches and
fires a text-free OS alert on the new 'alerts' channel for fresh matches.
Skips own/blocked/hidden/non-active offers and dedups via seen keys. The
replay-on-connect doubles as the catch-up scan. Notification payloads are
now prefixed so taps route to chat or the saved search.
2026-07-17 13:18:29 +02:00
530bf96358 feat(social): add SavedSearchesStore with alert bookkeeping
Keystore-backed, account-scoped store mirroring SavedOffersStore. Tracks
per-search seen keys (dedup, capped 500) and unread match keys (badge
source), with markMatched/markViewed/newMatchCount and a changes stream.
2026-07-17 13:18:29 +02:00
5b1066a224 feat(social): add SavedSearch model with shared offer matcher
Extract the market's query/type/category/organic filter chain into
SavedSearch.matchesFilters so live results and saved-search alerts can
never disagree. OffersState.visibleOffers now delegates to it.
2026-07-17 13:18:29 +02:00
dd9c97a57a feat(site): human-language 'what you can do' features section
All checks were successful
ci / test-commons-core (push) Successful in 46s
ci / analyze (push) Successful in 1m23s
site / deploy (push) Successful in 31s
ci / test-app-seeds (push) Successful in 5m52s
Add a features section to the landing home (EN/ES) that describes what
Tane does by intent, not mechanism: inventory, care & calendar, labels &
backup, and the neighbourly sharing layer (offers, chat, trust, plantaré,
sync). Follows the existing pillars/values pattern (front-matter data +
template + CSS, RTL-safe). Reframe the beta status copy so the sharing
layer reads as 'arriving, in beta' rather than 'being built'.
2026-07-17 12:52:47 +02:00
75098b8029 docs(about): age range 10-99, link 'translate' to Weblate
All checks were successful
ci / test-commons-core (push) Successful in 43s
ci / analyze (push) Successful in 1m26s
site / deploy (push) Successful in 27s
ci / test-app-seeds (push) Successful in 5m47s
2026-07-17 12:23:45 +02:00
e321447f9d chore(i18n): regenerate strings.g.dart (slang)
All checks were successful
ci / analyze (push) Successful in 2m15s
ci / test-commons-core (push) Successful in 1m6s
ci / test-app-seeds (push) Successful in 8m25s
2026-07-17 09:25:20 +02:00
d27dc1c553 chore(release): v0.1.1 (+3) — GMS-free build
Some checks failed
ci / test-commons-core (push) Successful in 43s
ci / analyze (push) Successful in 1m23s
site / deploy (push) Successful in 25s
ci / test-app-seeds (push) Has been cancelled
release / android (push) Failing after 10m57s
Bumps to 0.1.1+3 (versionCode 3). Adds CHANGELOG [0.1.1], fastlane
changelogs (en-US, es-ES) for versionCode 3, and a 0.1.1 build entry in
the F-Droid recipe (commit v0.1.1, CurrentVersion/Code 3).
2026-07-17 09:19:16 +02:00
d0fabb80a9 fix(android): drop geolocator for a native LocationManager channel
Removes the geolocator dependency, which transitively embedded
com.google.android.gms.* (play-services-location) — proprietary classes
that F-Droid's scanner rejects. The optional coarse-location button now
talks to Android's own LocationManager over a small platform channel
(org.comunes.tane/coarse_location): permission prompt, single coarse fix
with a 12s timeout, last-known fallback, null on any failure. Behavior
is unchanged and the CoarseLocationProvider interface is preserved, so
the UI, i18n and widget tests need no changes.

Also sets dependenciesInfo.includeInApk/Bundle = false so the AGP
dependency-metadata block (also flagged by F-Droid) is not embedded.
Neither change affects the Google Play build.
2026-07-17 09:19:05 +02:00
7f1118a243 docs(fdroid): fix recipe for F-Droid CI (build chown, checkupdates, schema)
All checks were successful
ci / test-commons-core (push) Successful in 43s
ci / analyze (push) Successful in 1m24s
site / deploy (push) Successful in 25s
ci / test-app-seeds (push) Successful in 5m43s
- chown /opt/flutter to vagrant (build runs as non-root; fixes cache write)
- AutoUpdateMode: Version (drop invalid 'v%v' per schema)
- UpdateCheckMode: Tags v[\d.]+ + UpdateCheckData reading version from
  apps/app_seeds/pubspec.yaml (Flutter versionCode isn't a literal in gradle)
Validated locally: schema, lint, checkupdates (exit 0), and flutter build as vagrant.
2026-07-17 08:26:27 +02:00
ab66f2fdf1 docs(fdroid): fit categories (Inventory/Market) + versionCode 2
All checks were successful
ci / test-commons-core (push) Successful in 45s
ci / analyze (push) Successful in 1m28s
site / deploy (push) Successful in 29s
ci / test-app-seeds (push) Successful in 5m51s
Categories now Inventory + Market & Price + Science & Education (F-Droid has no
'Tools'; these match the app). Build entry and CurrentVersionCode bumped to 2 to
match the shipped 0.1.0 release. Description/screenshots come from the fastlane
metadata tree, not this recipe.
2026-07-17 07:31:47 +02:00
4fc343e57d Update gitignore 2026-07-16 22:21:27 +02:00
153 changed files with 24082 additions and 2561 deletions

View file

@ -1,17 +1,32 @@
# Release automation for git.comunes.org (Forgejo Actions).
#
# Password-free: pushing a tag `v*` builds a signed AAB/APK and uploads it to
# Google Play's internal track via fastlane. All credentials come from repo
# secrets (Settings > Actions > Secrets), never typed:
# Pushing a tag `v*` runs two independent jobs:
# play - build the signed AAB and ship it to Google Play (production, 100%).
# fdroid_reference- build the per-ABI reference APKs the SAME way F-Droid will
# (fdroid build inside the fdroidserver buildserver image),
# sign them with the tane-upload key, and attach them to the
# Forgejo release. F-Droid re-runs `fdroid build` on the same
# commit+recipe+image, gets byte-identical output, verifies our
# signature (AllowedAPKSigningKeys) and publishes OUR APK — so
# F-Droid and Play share the same signing key.
#
# All credentials come from repo secrets (Settings > Actions > Secrets):
# TANE_KEYSTORE_BASE64 base64 of the dedicated tane-upload.jks
# TANE_KEYSTORE_PASSWORD store password
# TANE_KEY_ALIAS tane-upload
# TANE_KEY_PASSWORD key password
# SUPPLY_JSON_KEY_DATA Google Play service-account JSON (reused from Ğ1nkgo)
# SUPPLY_JSON_KEY_DATA Google Play service-account JSON
#
# Build + deploy live in ONE job so the AAB never has to cross a job boundary.
# NOTE: `runs-on` must match a label your Forgejo runner registered with; adjust
# if the Comunes runner uses something other than `docker`.
# NOTE: `runs-on` must match a label your Forgejo runner registered with.
# fdroid_reference is 3 separate jobs chained with `needs:` (armeabi-v7a ->
# arm64-v8a -> x86_64), NOT a matrix: this runner's act_runner does not honor
# `strategy.max-parallel`, so a matrix always ran all 3 concurrently — 2-3
# simultaneous cold NDK/CMake/Gradle builds contend for RAM and get OOM-killed
# (silent "Gradle build daemon disappeared", no error message). `needs:` is
# core Actions syntax with no such gap, so it actually serializes them. Each
# needs its own full cold NDK+tesseract-native+flutter compile (~30-35min
# alone), which is why they're chained rather than run in one job (that alone
# was too slow even for a 90m timeout).
name: release
@ -19,9 +34,89 @@ on:
push:
tags:
- 'v*'
# Manual trigger to test the fdroid_reference job on a branch without cutting a
# tag or deploying to Play (the play job below is guarded to tags only).
# Pass ref_tag (e.g. v0.1.10) to rebuild the reference APKs for an EXISTING
# release and re-upload them (idempotently), without cutting a new tag — used
# to iterate on build-reproducibility fixes without re-deploying to Play.
workflow_dispatch:
inputs:
ref_tag:
description: 'Existing tag to rebuild+re-upload reference APKs for (e.g. v0.1.10). Empty = build-only, no upload.'
required: false
default: ''
jobs:
android:
# Test gate. Forgejo/Actions workflows are independent — there is no cross-workflow
# `needs:`, and ci.yml does NOT trigger on tag pushes (its `branches` filter never
# matches refs/tags/*). So the CI test jobs are duplicated here (verbatim from
# ci.yml) to gate the deploy: nothing ships unless analyze + both test suites pass.
# Keep these in sync with ci.yml by hand.
analyze:
runs-on: docker
container:
image: ghcr.io/cirruslabs/flutter:3.41.9
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
git config --global --add safe.directory '*'
git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD
- run: flutter pub get
- run: flutter analyze
test-commons-core:
runs-on: docker
container:
image: ghcr.io/cirruslabs/flutter:3.41.9
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
git config --global --add safe.directory '*'
git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD
- run: flutter pub get
- name: dart test
working-directory: packages/commons_core
run: dart test
test-app-seeds:
runs-on: docker
container:
image: ghcr.io/cirruslabs/flutter:3.41.9
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
git config --global --add safe.directory '*'
git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD
# SQLCipher so the "no plaintext at rest" security test actually runs.
- run: apt-get update -qq && apt-get install -y -qq libsqlcipher-dev
- run: flutter pub get
- name: Generate code + test
working-directory: apps/app_seeds
run: |
dart run slang
dart run build_runner build --delete-conflicting-outputs
flutter test --coverage
play:
# Gate on the tests: with no status-check function in this `if`, the implicit
# success() on `needs` still applies — play runs only on a tag AND green tests.
needs: [analyze, test-commons-core, test-app-seeds]
if: ${{ startsWith(github.ref, 'refs/tags/') }}
runs-on: docker
container:
image: ghcr.io/cirruslabs/flutter:3.41.9
@ -42,6 +137,13 @@ jobs:
working-directory: apps/app_seeds
run: |
flutter pub get
# Same two hardenings the fdroid recipe applies (this job builds the AAB
# directly, not via the recipe, so a cold cache is still exposed):
# 1) strip flutter_tesseract_ocr's dead-jcenter buildscript (AGP 7.1.2) so
# it inherits the app's AGP instead of hitting jcenter.bintray.com;
# 2) cap the Gradle heap so the build fits the shared CI host's RAM.
find "${PUB_CACHE:-$HOME/.pub-cache}" -path '*flutter_tesseract_ocr-*/android/build.gradle' -exec sed -i '/^buildscript {/,/^}/d' {} +
printf 'org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g\norg.gradle.daemon=false\n' >> android/gradle.properties
dart run slang
dart run build_runner build --delete-conflicting-outputs
@ -61,11 +163,9 @@ jobs:
keyPassword=$TANE_KEY_PASSWORD
EOF
- name: Build signed AAB + APK
- name: Build signed AAB
working-directory: apps/app_seeds
run: |
flutter build appbundle --release
flutter build apk --release
run: flutter build appbundle --release
- name: Install fastlane
working-directory: apps/app_seeds
@ -75,7 +175,7 @@ jobs:
gem install bundler --no-document
bundle install
- name: Publish to Google Play (internal track)
- name: Publish to Google Play (production track, 100%)
working-directory: apps/app_seeds
env:
SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }}
@ -85,3 +185,423 @@ jobs:
if: always()
working-directory: apps/app_seeds
run: rm -f android/key.properties "$GITHUB_WORKSPACE/tane-upload.jks"
fdroid_reference_armeabi_v7a:
# Serialize after play so two heavy Android builds never run concurrently on
# the shared host (capacity:2) — that contention OOM-killed play's Gradle
# daemon. Gated on the test jobs, but kept INDEPENDENT of play's result:
# `!cancelled()` is a status function, which drops the implicit success() on
# `needs`, so play failing/skipping (e.g. on workflow_dispatch, where play is
# tag-only) does not block the fdroid chain — while the explicit result checks
# still hard-gate on green tests. Nothing gets built/uploaded on a red suite.
needs: [analyze, test-commons-core, test-app-seeds, play]
if: ${{ !cancelled()
&& needs.analyze.result == 'success'
&& needs.test-commons-core.result == 'success'
&& needs.test-app-seeds.result == 'success' }}
runs-on: docker
container:
image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie
steps:
- name: Build the armeabi-v7a reference APK with fdroid, sign, and attach it
env:
TOKEN: ${{ github.token }}
TANE_KEYSTORE_BASE64: ${{ secrets.TANE_KEYSTORE_BASE64 }}
TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }}
TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }}
TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }}
REF_TAG: ${{ github.event.inputs.ref_tag }}
ABI: armeabi-v7a
OFFSET: 1
run: |
set -eu
# 1) Reproduce F-Droid's build environment (ANDROID_HOME, PATH, NDK...).
sh /opt/buildserver/setup-env-vars /opt/android-sdk
. /etc/profile.d/bsenv.sh
# 2) fdroidserver from master, exactly like the fdroiddata CI.
fds=/opt/fdroidserver-src
mkdir -p "$fds"
# Download to a file with retries: a mid-transfer vSwitch blip used to
# pipe an empty stream straight into tar ("gzip: unexpected end of
# file"). -f fails on HTTP errors; --retry-all-errors covers resets.
for a in 1 2 3 4 5; do
curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors \
https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \
-o /tmp/fds.tgz && tar -tzf /tmp/fds.tgz >/dev/null 2>&1 && break
echo "fdroidserver download attempt $a failed; retrying in 5s" >&2; sleep 5
done
tar -xz -C "$fds" --strip-components=1 -f /tmp/fds.tgz
export PATH="$fds:$PATH"
export PYTHONPATH="$fds:$fds/examples"
git config --global --add safe.directory '*'
# The job container reaches github.com but not the host's public git.comunes.org
# (NAT hairpin); redirect the app clone to the internal Forgejo host so
# `fdroid build` can fetch the source (and SOURCE_DATE_EPOCH from its commit).
git config --global url."http://x-access-token:${TOKEN}@forgejo:3000/".insteadOf "https://git.comunes.org/"
# 3) Fetch our in-repo recipe (the single source of truth) at this commit,
# plus the pubspec versionCode base for this ABI's versionCode.
# Which app commit/tag to build: on a tag push, the pushed commit; on a
# manual dispatch with ref_tag set, that tag (to rebuild+re-upload an
# existing release's references without cutting a new tag / Play deploy).
BUILD_REF="${REF_TAG:-$GITHUB_SHA}"
work=/tmp/tane-src
mkdir -p "$work" && cd "$work"
git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "$BUILD_REF"
git checkout -q FETCH_HEAD
base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml)
[ -n "$base" ]
vc=$((base * 10 + OFFSET))
# 4) Assemble a minimal fdroiddata (config.yml + srclibs + our recipe) from the
# in-repo files. No external host needed — the job container reaches
# git.comunes.org (via forgejo:3000) but not gitlab.com.
# Match F-Droid's buildserver path (~/build/<appid>) so the native .so
# embed identical build paths — required for the byte-for-byte repro check.
fdd=/home/vagrant
mkdir -p "$fdd/metadata" "$fdd/srclibs"
cp "$work/fdroid-ci/config.yml" "$fdd/config.yml"
cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/"
cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml"
cd "$fdd"
# Build THIS commit; drop binary: (we PRODUCE the reference here, not verify it).
sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml
sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml
# Pre-clone the app into fdroid's build dir: fdroidserver computes
# SOURCE_DATE_EPOCH from this checkout BEFORE it clones anything — on a
# fresh dir it falls back to `git log` of the fdroiddata tree, which our
# assembled skeleton doesn't have, and crashes on None. With the clone in
# place the primary path pins SOURCE_DATE_EPOCH to the app commit
# timestamp (deterministic; the public URL is redirected to forgejo:3000
# by the insteadOf rule above).
mkdir -p build
git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane
git -C build/org.comunes.tane checkout -q "${BUILD_REF}"
# 5) Build ONLY this ABI's split with F-Droid's own toolchain. Splitting
# per-ABI into its own job/matrix-entry (rather than all 3 sequentially in
# one job) is what keeps each build inside the runner's max-job-runtime —
# each needs a full cold NDK+tesseract-native+flutter compile.
# Retry the build: a transient network blip mid-Gradle (maven/pub) used
# to kill it. `fdroid build` exits 0 even on failure, so gate the retry
# on the APK actually appearing. Up to 3 attempts fit the 90m timeout.
for a in 1 2 3; do
fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" || true
if [ -f "unsigned/org.comunes.tane_${vc}.apk" ]; then break; fi
echo "fdroid build attempt $a produced no APK; retrying" >&2
done
# `fdroid build` exits 0 even when builds fail — demand the APK.
f="unsigned/org.comunes.tane_${vc}.apk"
if [ ! -f "$f" ]; then
echo "expected $f, not found" >&2
tail -n 80 logs/*.log 2>/dev/null || true
exit 1
fi
# 6) Sign the unsigned APK with tane-upload and attach it to the Forgejo release.
apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1)
if [ -z "$apksigner" ]; then
yes | sdkmanager "build-tools;34.0.0" >/dev/null 2>&1 || true
apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1)
fi
echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks
out="/tmp/app-${ABI}-release.apk"
# v2/v3 only — NO v1 (JAR) signature. minSdk is 24, so v1 is never
# needed, and v1 adds META-INF/*.SF/*.RSA/MANIFEST.MF as zip ENTRIES
# that F-Droid's rebuild doesn't have. F-Droid's apksigcopier transplants
# only the v2/v3 signing block, so those extra v1 entries make the
# reference's signed content differ from F-Droid's rebuild (CHUNKED_SHA512
# mismatch) even when every file is identical. Dropping v1 makes the
# reference byte-match F-Droid's build so the signature copy verifies.
"$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \
--ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \
--v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \
--alignment-preserved true \
--out "$out" "$f"
# Prove the signer cert matches AllowedAPKSigningKeys.
"$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true
rm -f /tmp/ks.jks
# Upload target: the pushed tag, or (on a manual dispatch) the ref_tag
# input. A plain dispatch with no ref_tag just builds+signs, no upload.
api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}"
UPLOAD_TAG=""
if [ "${GITHUB_REF_TYPE:-}" = tag ]; then
UPLOAD_TAG="${GITHUB_REF_NAME}"
elif [ -n "${REF_TAG:-}" ]; then
UPLOAD_TAG="${REF_TAG}"
fi
if [ -n "$UPLOAD_TAG" ]; then
curl -sf -X POST "$api/releases" \
-H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true
rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# Idempotent re-upload: delete any existing asset of this name first
# (the assets POST rejects a duplicate name).
curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets" \
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='app-${ABI}-release.apk']" 2>/dev/null \
| while read -r aid; do [ -n "$aid" ] && curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets/${aid}" >/dev/null || true; done
curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${out};type=application/vnd.android.package-archive"
echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})"
else
echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)"
fi
fdroid_reference_arm64_v8a:
needs: fdroid_reference_armeabi_v7a
runs-on: docker
container:
image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie
steps:
- name: Build the arm64-v8a reference APK with fdroid, sign, and attach it
env:
TOKEN: ${{ github.token }}
TANE_KEYSTORE_BASE64: ${{ secrets.TANE_KEYSTORE_BASE64 }}
TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }}
TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }}
TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }}
REF_TAG: ${{ github.event.inputs.ref_tag }}
ABI: arm64-v8a
OFFSET: 2
run: |
set -eu
sh /opt/buildserver/setup-env-vars /opt/android-sdk
. /etc/profile.d/bsenv.sh
fds=/opt/fdroidserver-src
mkdir -p "$fds"
# Download to a file with retries: a mid-transfer vSwitch blip used to
# pipe an empty stream straight into tar ("gzip: unexpected end of
# file"). -f fails on HTTP errors; --retry-all-errors covers resets.
for a in 1 2 3 4 5; do
curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors \
https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \
-o /tmp/fds.tgz && tar -tzf /tmp/fds.tgz >/dev/null 2>&1 && break
echo "fdroidserver download attempt $a failed; retrying in 5s" >&2; sleep 5
done
tar -xz -C "$fds" --strip-components=1 -f /tmp/fds.tgz
export PATH="$fds:$PATH"
export PYTHONPATH="$fds:$fds/examples"
git config --global --add safe.directory '*'
git config --global url."http://x-access-token:${TOKEN}@forgejo:3000/".insteadOf "https://git.comunes.org/"
# Which app commit/tag to build: on a tag push, the pushed commit; on a
# manual dispatch with ref_tag set, that tag (to rebuild+re-upload an
# existing release's references without cutting a new tag / Play deploy).
BUILD_REF="${REF_TAG:-$GITHUB_SHA}"
work=/tmp/tane-src
mkdir -p "$work" && cd "$work"
git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "$BUILD_REF"
git checkout -q FETCH_HEAD
base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml)
[ -n "$base" ]
vc=$((base * 10 + OFFSET))
# Match F-Droid's buildserver path (~/build/<appid>) so the native .so
# embed identical build paths — required for the byte-for-byte repro check.
fdd=/home/vagrant
mkdir -p "$fdd/metadata" "$fdd/srclibs"
cp "$work/fdroid-ci/config.yml" "$fdd/config.yml"
cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/"
cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml"
cd "$fdd"
sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml
sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml
mkdir -p build
git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane
git -C build/org.comunes.tane checkout -q "${BUILD_REF}"
# Retry the build: a transient network blip mid-Gradle (maven/pub) used
# to kill it. `fdroid build` exits 0 even on failure, so gate the retry
# on the APK actually appearing. Up to 3 attempts fit the 90m timeout.
for a in 1 2 3; do
fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" || true
if [ -f "unsigned/org.comunes.tane_${vc}.apk" ]; then break; fi
echo "fdroid build attempt $a produced no APK; retrying" >&2
done
f="unsigned/org.comunes.tane_${vc}.apk"
if [ ! -f "$f" ]; then
echo "expected $f, not found" >&2
tail -n 80 logs/*.log 2>/dev/null || true
exit 1
fi
apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1)
if [ -z "$apksigner" ]; then
yes | sdkmanager "build-tools;34.0.0" >/dev/null 2>&1 || true
apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1)
fi
echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks
out="/tmp/app-${ABI}-release.apk"
# v2/v3 only — NO v1 (JAR) signature. minSdk is 24, so v1 is never
# needed, and v1 adds META-INF/*.SF/*.RSA/MANIFEST.MF as zip ENTRIES
# that F-Droid's rebuild doesn't have. F-Droid's apksigcopier transplants
# only the v2/v3 signing block, so those extra v1 entries make the
# reference's signed content differ from F-Droid's rebuild (CHUNKED_SHA512
# mismatch) even when every file is identical. Dropping v1 makes the
# reference byte-match F-Droid's build so the signature copy verifies.
"$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \
--ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \
--v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \
--alignment-preserved true \
--out "$out" "$f"
"$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true
rm -f /tmp/ks.jks
# Upload target: the pushed tag, or (on a manual dispatch) the ref_tag
# input. A plain dispatch with no ref_tag just builds+signs, no upload.
api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}"
UPLOAD_TAG=""
if [ "${GITHUB_REF_TYPE:-}" = tag ]; then
UPLOAD_TAG="${GITHUB_REF_NAME}"
elif [ -n "${REF_TAG:-}" ]; then
UPLOAD_TAG="${REF_TAG}"
fi
if [ -n "$UPLOAD_TAG" ]; then
curl -sf -X POST "$api/releases" \
-H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true
rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# Idempotent re-upload: delete any existing asset of this name first
# (the assets POST rejects a duplicate name).
curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets" \
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='app-${ABI}-release.apk']" 2>/dev/null \
| while read -r aid; do [ -n "$aid" ] && curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets/${aid}" >/dev/null || true; done
curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${out};type=application/vnd.android.package-archive"
echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})"
else
echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)"
fi
fdroid_reference_x86_64:
needs: fdroid_reference_arm64_v8a
runs-on: docker
container:
image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie
steps:
- name: Build the x86_64 reference APK with fdroid, sign, and attach it
env:
TOKEN: ${{ github.token }}
TANE_KEYSTORE_BASE64: ${{ secrets.TANE_KEYSTORE_BASE64 }}
TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }}
TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }}
TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }}
REF_TAG: ${{ github.event.inputs.ref_tag }}
ABI: x86_64
OFFSET: 3
run: |
set -eu
sh /opt/buildserver/setup-env-vars /opt/android-sdk
. /etc/profile.d/bsenv.sh
fds=/opt/fdroidserver-src
mkdir -p "$fds"
# Download to a file with retries: a mid-transfer vSwitch blip used to
# pipe an empty stream straight into tar ("gzip: unexpected end of
# file"). -f fails on HTTP errors; --retry-all-errors covers resets.
for a in 1 2 3 4 5; do
curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors \
https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \
-o /tmp/fds.tgz && tar -tzf /tmp/fds.tgz >/dev/null 2>&1 && break
echo "fdroidserver download attempt $a failed; retrying in 5s" >&2; sleep 5
done
tar -xz -C "$fds" --strip-components=1 -f /tmp/fds.tgz
export PATH="$fds:$PATH"
export PYTHONPATH="$fds:$fds/examples"
git config --global --add safe.directory '*'
git config --global url."http://x-access-token:${TOKEN}@forgejo:3000/".insteadOf "https://git.comunes.org/"
# Which app commit/tag to build: on a tag push, the pushed commit; on a
# manual dispatch with ref_tag set, that tag (to rebuild+re-upload an
# existing release's references without cutting a new tag / Play deploy).
BUILD_REF="${REF_TAG:-$GITHUB_SHA}"
work=/tmp/tane-src
mkdir -p "$work" && cd "$work"
git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "$BUILD_REF"
git checkout -q FETCH_HEAD
base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml)
[ -n "$base" ]
vc=$((base * 10 + OFFSET))
# Match F-Droid's buildserver path (~/build/<appid>) so the native .so
# embed identical build paths — required for the byte-for-byte repro check.
fdd=/home/vagrant
mkdir -p "$fdd/metadata" "$fdd/srclibs"
cp "$work/fdroid-ci/config.yml" "$fdd/config.yml"
cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/"
cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml"
cd "$fdd"
sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml
sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml
mkdir -p build
git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane
git -C build/org.comunes.tane checkout -q "${BUILD_REF}"
# Retry the build: a transient network blip mid-Gradle (maven/pub) used
# to kill it. `fdroid build` exits 0 even on failure, so gate the retry
# on the APK actually appearing. Up to 3 attempts fit the 90m timeout.
for a in 1 2 3; do
fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" || true
if [ -f "unsigned/org.comunes.tane_${vc}.apk" ]; then break; fi
echo "fdroid build attempt $a produced no APK; retrying" >&2
done
f="unsigned/org.comunes.tane_${vc}.apk"
if [ ! -f "$f" ]; then
echo "expected $f, not found" >&2
tail -n 80 logs/*.log 2>/dev/null || true
exit 1
fi
apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1)
if [ -z "$apksigner" ]; then
yes | sdkmanager "build-tools;34.0.0" >/dev/null 2>&1 || true
apksigner=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | sort -V | tail -1)
fi
echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks
out="/tmp/app-${ABI}-release.apk"
# v2/v3 only — NO v1 (JAR) signature. minSdk is 24, so v1 is never
# needed, and v1 adds META-INF/*.SF/*.RSA/MANIFEST.MF as zip ENTRIES
# that F-Droid's rebuild doesn't have. F-Droid's apksigcopier transplants
# only the v2/v3 signing block, so those extra v1 entries make the
# reference's signed content differ from F-Droid's rebuild (CHUNKED_SHA512
# mismatch) even when every file is identical. Dropping v1 makes the
# reference byte-match F-Droid's build so the signature copy verifies.
"$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \
--ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \
--v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \
--alignment-preserved true \
--out "$out" "$f"
"$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true
rm -f /tmp/ks.jks
# Upload target: the pushed tag, or (on a manual dispatch) the ref_tag
# input. A plain dispatch with no ref_tag just builds+signs, no upload.
api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}"
UPLOAD_TAG=""
if [ "${GITHUB_REF_TYPE:-}" = tag ]; then
UPLOAD_TAG="${GITHUB_REF_NAME}"
elif [ -n "${REF_TAG:-}" ]; then
UPLOAD_TAG="${REF_TAG}"
fi
if [ -n "$UPLOAD_TAG" ]; then
curl -sf -X POST "$api/releases" \
-H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true
rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# Idempotent re-upload: delete any existing asset of this name first
# (the assets POST rejects a duplicate name).
curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets" \
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='app-${ABI}-release.apk']" 2>/dev/null \
| while read -r aid; do [ -n "$aid" ] && curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "$api/releases/${rid}/assets/${aid}" >/dev/null || true; done
curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${out};type=application/vnd.android.package-archive"
echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})"
else
echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)"
fi

5
.gitignore vendored
View file

@ -11,3 +11,8 @@ node_modules/
# OS
.DS_Store
Thumbs.db
apps/app_seeds/fastlane/README.md
apps/app_seeds/TODO.org
# Internal working notes — live in the private repo (tane-private)
docs/notes/

View file

@ -3,7 +3,25 @@
All notable changes to Tane are recorded here. Human-readable, newest
first. Dates are ISO (YYYY-MM-DD).
## [Unreleased] — Block 1 beta prep
## [0.1.2] — 2026-07-17
### Changed
- Release builds now produce one split APK per CPU architecture (smaller
downloads) instead of a single universal APK, in addition to the App
Bundle Google Play already used. No user-visible change.
## [0.1.1] — 2026-07-17
### Changed
- **Fully Google-free build.** The optional "use my approximate location"
button (market setup) now talks to Android's own location service instead
of a plugin that embedded Google Play Services code. Same behavior — the
fix is still reduced on-device to a coarse area code — and the APK now
contains no proprietary classes, paving the way for F-Droid distribution.
- Release builds no longer embed Google's encrypted dependency-metadata
block in the signature.
## [0.1.0] — 2026-07-16 — Block 1 beta
Block 1 is the offline, encrypted seed inventory — useful with zero network.

View file

@ -74,4 +74,4 @@ Social layer uses: Nostr (offers NIP-99, DMs NIP-17, via the pure-Dart `nostr` p
- Live decision log: [`docs/design/open-decisions.md`](docs/design/open-decisions.md) — check before deciding anything.
- First code steps: [`docs/design/first-sprint.md`](docs/design/first-sprint.md).
- Prior art / valuable mockups: [`docs/mockups/`](docs/mockups/) (inventory, item, search, profile, chat — the UI spec).
- Git: bare at `~/repos/tane.git`; working clone here. Commit messages in English.
- Git: `origin` is the forge at `git.comunes.org/comunes/tane.git` (authoritative; push here). Working clone here. A pre-split backup of the old (pre-`filter-repo`) history is archived at `~/repos/tane-pre-split-2026-07-15.git` — not a remote, do not push to it. Commit messages in English.

View file

@ -9,9 +9,16 @@ The name honors the old Japanese mutual-aid traditions around rice — *yui* (sh
- Web: https://tane.comunes.org
- Package id: `org.comunes.tane`
## Install
Android, same signature in both stores — you can switch between them without reinstalling:
- **F-Droid**: https://f-droid.org/packages/org.comunes.tane/ (reproducible, developer-signed build)
- **Google Play**: https://play.google.com/store/apps/details?id=org.comunes.tane
## Status
Early design. See [`PLAN.md`](PLAN.md) for the full analysis and roadmap (in Spanish). The valuable prior work lives in [`docs/mockups/`](docs/mockups/).
Block 1 (inventory) is shipped and in beta; Block 2 (the social layer) is under way. See [`PLAN.md`](PLAN.md) for the full analysis and roadmap (in Spanish) and [`docs/design/open-decisions.md`](docs/design/open-decisions.md) for the live decision log. The valuable prior work lives in [`docs/mockups/`](docs/mockups/).
## Principles

View file

@ -56,6 +56,13 @@ android {
}
}
dependenciesInfo {
// No Google-encrypted dependency-metadata block in the APK/AAB
// signature: F-Droid rejects it, and Play does not require it.
includeInApk = false
includeInBundle = false
}
buildTypes {
release {
// Sign with the real release keystore when key.properties is present,
@ -66,6 +73,17 @@ android {
} else {
signingConfigs.getByName("debug")
}
// R8: shrink + optimize + obfuscate the Java/Kotlin bytecode and strip
// unused resources — Play's "app optimization" recommendation (smaller
// download, less memory). Native .so libs are untouched, so this does
// not change device/ABI compatibility. Keep rules for reflection/JNI
// heavy deps (OCR, SQLCipher, notifications) live in proguard-rules.pro.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}
@ -78,3 +96,32 @@ dependencies {
// Backport of Java 8+ APIs, required by flutter_local_notifications.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
// F-Droid: Flutter's embedding transitively pulls Google Play Core (deferred
// components / split install — com.google.android.play.core.*), which F-Droid's
// scanner rejects as a proprietary Google dependency. Tane does not use deferred
// components, so drop the whole com.google.android.play group; those split-install
// code paths are never reached. R8 keeps the referenced classes otherwise, so the
// exclusion (not shrinking) is what removes them; -dontwarn in proguard-rules.pro
// silences the now-dangling compile-time references.
configurations.all {
exclude(group = "com.google.android.play")
}
// F-Droid builds one split APK per ABI (smaller downloads than a universal
// APK) and needs each split to carry a distinct versionCode so its repo can
// tell them apart; `flutter build apk --split-per-abi` alone gives every
// split the same versionCode. This override only touches the APK
// (applicationVariants) output, not the App Bundle Google Play consumes, so
// Play's per-device delivery is unaffected.
val abiVersionCodes = mapOf("armeabi-v7a" to 1, "arm64-v8a" to 2, "x86_64" to 3)
android.applicationVariants.all {
val variant = this
variant.outputs.all {
val output = this as com.android.build.gradle.internal.api.ApkVariantOutputImpl
val abi = output.getFilter(com.android.build.OutputFile.ABI)
abiVersionCodes[abi]?.let { abiCode ->
output.versionCodeOverride = variant.versionCode * 10 + abiCode
}
}
}

View file

@ -0,0 +1,52 @@
# R8/ProGuard keep rules for Tane (org.comunes.tane).
#
# R8 is enabled for release builds (see build.gradle.kts). It shrinks/optimizes
# the Java/Kotlin bytecode and can break code reached only via reflection or JNI
# the native plugins below load their Java classes from C, so R8 can't see the
# references and would strip/rename them. Keep them explicitly. Start
# conservative; trim only after a release smoke test proves a class is unused.
# --- Flutter engine & embedding ---
# Do NOT blanket-keep io.flutter.** : that pins io.flutter.embedding.engine.
# deferredcomponents.PlayStoreDeferredComponentManager, which references the
# proprietary com.google.android.play.core.* (SplitInstall/SplitCompat) API and
# makes F-Droid's scanner reject the APK. Tane doesn't use deferred components,
# so let R8 shrink that unused manager away (removing the Play Core references).
# JNI-critical embedding classes (FlutterJNI, etc.) are kept by the Flutter
# embedding AAR's own bundled consumer ProGuard rules, so this stays safe.
-keep class io.flutter.plugins.** { *; }
-dontwarn io.flutter.embedding.**
# --- On-device OCR: tesseract4android (JNI) ---
# libtesseract/libleptonica call back into these Java classes by name.
-keep class com.googlecode.tesseract.android.** { *; }
-keep class com.googlecode.leptonica.android.** { *; }
-keep class io.paratoner.flutter_tesseract_ocr.** { *; }
# --- QR scanning: zxing_barcode_scanner (pure ZXing, platform view) ---
-keep class com.shirisharyal.zxing_barcode_scanner.** { *; }
# --- Encrypted DB: SQLCipher / sqlite3 native loader ---
# sqlite3 is reached over FFI (dlopen), but keep the loader plugin classes.
-keep class eu.simonbinder.sqlite3_flutter_libs.** { *; }
-keep class net.zetetic.** { *; }
-dontwarn net.zetetic.**
# --- Local notifications ---
# Published keep rules for flutter_local_notifications' Gson-serialized models.
-keep class com.dexterous.** { *; }
-keep class com.google.gson.** { *; }
-keep class * extends com.google.gson.TypeAdapter
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn com.google.errorprone.annotations.**
# --- Core library desugaring ---
-dontwarn java.lang.invoke.**
-dontwarn build.IgnoreJava8API
# --- F-Droid: Google Play Core excluded (see build.gradle.kts) ---
# The Flutter embedding references Play Core split-install classes for deferred
# components, which Tane doesn't use; the com.google.android.play group is
# excluded from the build, so tell R8 not to warn about the absent references.
-dontwarn com.google.android.play.**

View file

@ -1,10 +1,29 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Network access for the social layer (relays: offers, messaging, trust,
device sync). Flutter auto-adds this only to the debug/profile manifests,
so release builds shipped it MISSING — every store build had no network
at all (the market's "can't reach the servers" was the visible symptom;
messaging and sync were equally dead). Must live in the main manifest. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 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" />
<!-- The CAMERA permission (pulled in by zxing_barcode_scanner for QR scanning
and used by image_picker) makes Android implicitly require the camera
hardware features, which would exclude camera-less devices — Chromebooks,
Android Automotive, many TVs — from Play. The app degrades gracefully
without a camera (gallery import still works; the scan button hides), so
declare these optional to keep those devices supported. Same for the
location features implied by ACCESS_COARSE_LOCATION. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.location" android:required="false" />
<uses-feature android:name="android.hardware.location.network" android:required="false" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<application
android:label="Tane"
android:name="${applicationName}"

View file

@ -1,5 +1,182 @@
package org.comunes.tane
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import android.os.Handler
import android.os.Looper
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
/**
* Hosts the "coarse location" platform channel backed by Android's own
* LocationManager. Deliberately no Google Play Services: the APK must stay
* free of proprietary classes (F-Droid). Mirrors the old plugin's behavior:
* ask permission if needed, try a single coarse fix with a timeout, fall
* back to the last known position, and reply null on any failure never
* throw across the channel.
*/
class MainActivity : FlutterActivity() {
private var pendingPermissionResult: MethodChannel.Result? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"getCoarseLatLon" -> getCoarseLatLon(result)
"hasCamera" -> result.success(hasCameraHardware())
else -> result.notImplemented()
}
}
}
private fun getCoarseLatLon(result: MethodChannel.Result) {
if (hasLocationPermission()) {
fetchCoarseLocation(result)
return
}
if (pendingPermissionResult != null) {
// A permission prompt is already on screen; don't stack requests.
result.success(null)
return
}
pendingPermissionResult = result
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION),
PERMISSION_REQUEST_CODE,
)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != PERMISSION_REQUEST_CODE) return
val result = pendingPermissionResult ?: return
pendingPermissionResult = null
if (hasLocationPermission()) {
fetchCoarseLocation(result)
} else {
result.success(null)
}
}
/**
* Whether this device has any camera. Camera-less devices (Chromebooks,
* Android Automotive, many TVs) are supported see AndroidManifest's
* uses-feature required="false" so the QR scan and camera-capture UI
* hide themselves when this is false instead of offering broken actions.
*/
private fun hasCameraHardware(): Boolean =
packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_COARSE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
private fun fetchCoarseLocation(result: MethodChannel.Result) {
val reply = SingleReply(result)
try {
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
val provider = pickProvider(lm)
if (provider == null) {
reply.send(lastKnownLocation(lm))
return
}
val handler = Handler(Looper.getMainLooper())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancel = CancellationSignal()
handler.postDelayed({
if (!reply.sent) {
cancel.cancel()
reply.send(lastKnownLocation(lm))
}
}, FIX_TIMEOUT_MS)
lm.getCurrentLocation(provider, cancel, mainExecutor) { location ->
reply.send(location ?: lastKnownLocation(lm))
}
} else {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
reply.send(location)
}
@Deprecated("Deprecated in Java")
override fun onStatusChanged(
provider: String?,
status: Int,
extras: android.os.Bundle?,
) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
handler.postDelayed({
if (!reply.sent) {
try {
lm.removeUpdates(listener)
} catch (_: Exception) {
}
reply.send(lastKnownLocation(lm))
}
}, FIX_TIMEOUT_MS)
@Suppress("DEPRECATION")
lm.requestSingleUpdate(provider, listener, Looper.getMainLooper())
}
} catch (_: Exception) {
// SecurityException, disabled providers, anything: degrade quietly.
reply.send(null)
}
}
/** Coarse first: the network provider is enough for a ~2 km geohash. */
private fun pickProvider(lm: LocationManager): String? = when {
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
LocationManager.NETWORK_PROVIDER
lm.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
LocationManager.GPS_PROVIDER
else -> null
}
private fun lastKnownLocation(lm: LocationManager): Location? = try {
lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
?: lm.getLastKnownLocation(LocationManager.GPS_PROVIDER)
?: lm.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER)
} catch (_: Exception) {
null
}
/** Answers a MethodChannel.Result exactly once (fix vs. timeout race). */
private class SingleReply(private val result: MethodChannel.Result) {
var sent = false
private set
fun send(location: Location?) {
if (sent) return
sent = true
result.success(
location?.let { mapOf("lat" to it.latitude, "lon" to it.longitude) },
)
}
}
private companion object {
const val CHANNEL = "org.comunes.tane/coarse_location"
const val PERMISSION_REQUEST_CODE = 4711
const val FIX_TIMEOUT_MS = 12_000L
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -16,13 +16,24 @@ default_platform(:android)
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab".freeze
platform :android do
desc "Upload the signed AAB + store listing to Google Play (internal track)"
desc "Upload the signed AAB + store listing to Google Play (production, 100%)"
lane :deploy_play do
supply(
track: "production",
aab: AAB_PATH,
release_status: "completed", # publish to 100% of users (subject to review)
skip_upload_apk: true, # we ship the AAB, not a raw APK
skip_upload_changelogs: false,
)
end
desc "Upload the signed AAB to the internal test track (manual QA safety net)"
lane :deploy_internal do
supply(
track: "internal",
aab: AAB_PATH,
release_status: "completed", # internal testing has no review delay
skip_upload_apk: true, # we ship the AAB, not a raw APK
skip_upload_apk: true,
skip_upload_changelogs: false,
)
end

View file

@ -0,0 +1 @@
Tane now stays completely offline until you join the sharing part — it no longer contacts any server on its own. The store description finally says which community servers it talks to, what they can see, and how to change them or switch them all off.

View file

@ -0,0 +1 @@
The app is now fully Google-free: the optional "use my location" button uses Android's own location service, with no Google Play Services code in the app. Paves the way for F-Droid distribution.

View file

@ -0,0 +1 @@
Smaller downloads: release builds now ship one APK per phone architecture instead of one universal file. No other change.

View file

@ -13,8 +13,11 @@ MANAGE YOUR BANK
• Search and filter; snap a photo now and name it later.
YOURS, AND ONLY YOURS
• Works fully offline. No account, no server, no trackers.
• Your seed book works with no internet at all. What you write stays on your
phone; nothing goes out unless you put some of your seeds up for sharing, or
write to someone.
• Everything is encrypted at rest on your device.
• No account, no sign-up, no ads, no trackers.
• Save an encrypted backup and keep a printed recovery sheet — restore your
whole bank on a new device.
@ -22,6 +25,26 @@ 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.
SHARING NEEDS THE INTERNET
Keeping your seed book needs nothing but your phone. Sharing does need a
connection: to get an offer or a message across to someone, Tane leaves it on
community servers — machines run by people, not by a company. Tane comes with
four: relay.comunes.org, which Asociación Comunes runs (the same people who make
Tane), plus three public ones that others already use — nos.lol, relay.damus.io
and relay.primal.net.
Tane only talks to them once you join the sharing part and agree to the
community rules. Before that it doesn't connect to anything, and messaging stays
switched off. Once you join, those servers see the key that stands for you and
the address your phone connects from — no name, no email, no account. Your seed
book itself is never sent anywhere.
You stay in charge of that list of servers. In the sharing setup you can switch
off any server on it, switch off every one of them — Tane then goes back to
being a seed book that never connects — or add the address of a different
server. Anyone can run a server of their own, and Tane works just as well with
it.
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.

View file

@ -0,0 +1 @@
Tane ya no se conecta a ningún servidor por su cuenta: se queda completamente sin conexión hasta que entras en la parte de compartir. Y la descripción de la tienda por fin dice con qué servidores comunitarios habla, qué pueden ver y cómo cambiarlos o desactivarlos todos.

View file

@ -0,0 +1 @@
La aplicación ya es totalmente libre de Google: el botón opcional «usar mi ubicación» usa el servicio de ubicación propio de Android, sin código de Google Play Services en la app. Allana el camino para su distribución en F-Droid.

View file

@ -0,0 +1 @@
Descargas más pequeñas: las versiones ahora reparten un APK por arquitectura de teléfono en vez de uno universal. Sin ningún otro cambio.

View file

@ -13,8 +13,11 @@ GESTIONA TU BANCO
• Busca y filtra; haz una foto ahora y ponle nombre después.
TUYO, Y SOLO TUYO
• Funciona totalmente sin conexión. Sin cuenta, sin servidor, sin rastreadores.
• Tu cuaderno de semillas funciona sin nada de internet. Lo que escribes se
queda en el móvil; no sale nada salvo que pongas a compartir parte de tus
semillas o le escribas a alguien.
• Todo va cifrado en tu dispositivo.
• Sin cuenta, sin registro, sin anuncios, sin rastreadores.
• Guarda una copia cifrada y ten una hoja de recuperación impresa: recupera
todo tu banco en un dispositivo nuevo.
@ -22,6 +25,25 @@ 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.
COMPARTIR SÍ NECESITA INTERNET
Para llevar tu cuaderno de semillas no hace falta más que el móvil. Compartir sí
necesita conexión: para que una oferta o un mensaje le lleguen a otra persona,
Tane los deja en servidores comunitarios, máquinas que lleva gente, no una
empresa. Tane viene con cuatro: relay.comunes.org, que lleva la Asociación
Comunes (la misma gente que hace Tane), y tres públicos que ya usa otra gente:
nos.lol, relay.damus.io y relay.primal.net.
Tane solo habla con ellos cuando entras en la parte de compartir y aceptas las
normas de la comunidad. Antes de eso no se conecta a nada, y la mensajería está
apagada. Cuando entras, esos servidores ven la clave que te representa y la
dirección desde la que se conecta tu móvil: ni nombre, ni correo, ni cuenta. Tu
cuaderno de semillas no se envía nunca a ningún sitio.
Tú mandas sobre esa lista de servidores. En la configuración de compartir puedes
desactivar el servidor que quieras, desactivarlos todos —y Tane vuelve a ser un
cuaderno que no se conecta nunca— o añadir la dirección de otro servidor.
Cualquiera puede montar su propio servidor, y Tane funciona igual de bien con él.
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.

View file

@ -18,10 +18,12 @@ import 'services/onboarding_store.dart';
import 'services/profile_cache.dart';
import 'services/profile_store.dart';
import 'services/saved_offers_store.dart';
import 'services/saved_searches_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/sharing_switch.dart';
import 'state/inventory_cubit.dart';
import 'state/variety_detail_cubit.dart';
import 'ui/about_screen.dart';
@ -36,6 +38,7 @@ import 'ui/inventory_list_screen.dart';
import 'ui/legal_screen.dart';
import 'ui/plantares_screen.dart';
import 'ui/sales_screen.dart';
import 'ui/saved_searches_screen.dart';
import 'ui/market_offer_detail_screen.dart';
import 'ui/market_screen.dart';
import 'ui/offline_banner.dart';
@ -62,11 +65,13 @@ class TaneApp extends StatelessWidget {
this.profileStore,
this.profileCache,
this.savedOffers,
this.savedSearches,
this.socialAccounts,
this.inbox,
this.notifications,
this.showIntro = false,
this.autoBackup,
SharingSwitch? sharing,
super.key,
}) : _router = _buildRouter(
repository,
@ -81,13 +86,27 @@ class TaneApp extends StatelessWidget {
profileStore,
profileCache,
savedOffers,
savedSearches,
socialAccounts,
inbox,
// `bootstrap` passes the real switch, holding the person's stored
// answer. A widget test that doesn't care gets one already on, so
// screens behave as they did before sharing became opt-in.
sharing ??
(socialSettings == null
? null
: SharingSwitch(
settings: socialSettings,
connection: connection,
enabled: true,
)),
) {
// 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');
// A tapped saved-search alert opens the saved-searches list.
notifications?.onTapSearch = (_) => _router.push('/saved-searches');
}
final VarietyRepository repository;
@ -120,6 +139,9 @@ class TaneApp extends StatelessWidget {
/// Optional store of the user's saved ("favorite") market offers.
final SavedOffersStore? savedOffers;
/// Optional store of the user's saved market searches (with alerts).
final SavedSearchesStore? savedSearches;
/// Optional store of the active social identity, for the profile switcher.
final SocialAccountStore? socialAccounts;
@ -149,16 +171,20 @@ class TaneApp extends StatelessWidget {
ProfileStore? profileStore,
ProfileCache? profileCache,
SavedOffersStore? savedOffers,
SavedSearchesStore? savedSearches,
SocialAccountStore? socialAccounts,
InboxService? inbox,
SharingSwitch? sharing,
) {
return GoRouter(
initialLocation: showIntro ? '/intro' : '/',
routes: [
GoRoute(
path: '/',
// A null switch means there is no social layer at all: the market card
// and the social drawer entries aren't drawn.
builder: (context, state) =>
HomeScreen(marketEnabled: social != null),
HomeScreen(sharing: sharing, onboarding: onboarding),
),
if (social != null && socialSettings != null && connection != null)
GoRoute(
@ -170,6 +196,8 @@ class TaneApp extends StatelessWidget {
location: location,
outbox: outbox,
onboarding: onboarding,
savedSearches: savedSearches,
sharing: sharing,
),
),
if (social != null && connection != null)
@ -196,6 +224,12 @@ class TaneApp extends StatelessWidget {
connection: connection,
),
),
if (social != null && savedSearches != null)
GoRoute(
path: '/saved-searches',
builder: (context, state) =>
SavedSearchesScreen(store: savedSearches),
),
if (messageStore != null)
GoRoute(
path: '/messages',

View file

@ -9,6 +9,7 @@ import 'data/variety_repository.dart';
import 'di/injector.dart';
import 'i18n/strings.g.dart';
import 'services/auto_backup_service.dart';
import 'services/camera_availability.dart';
import 'services/coarse_location.dart';
import 'services/inbox_service.dart';
import 'services/locale_store.dart';
@ -20,6 +21,9 @@ import 'services/plantare_service.dart';
import 'services/profile_cache.dart';
import 'services/profile_store.dart';
import 'services/saved_offers_store.dart';
import 'services/saved_search_alert_service.dart';
import 'services/saved_searches_store.dart';
import 'services/sharing_switch.dart';
import 'services/social_account_store.dart';
import 'services/social_connection.dart';
import 'services/social_service.dart';
@ -48,6 +52,11 @@ class _BootstrapState extends State<Bootstrap> {
Future<TaneApp> _boot() async {
await configureDependencies();
// Detect camera presence once, before the home screen builds, so camera-less
// devices (Chromebooks, Automotive, some TVs) hide the QR scan / camera UI
// instead of offering broken actions. Non-Android platforms keep the default.
await initCameraAvailability();
// 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.
@ -70,13 +79,27 @@ class _BootstrapState extends State<Bootstrap> {
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.
final savedSearchAlerts = getIt.isRegistered<SavedSearchAlertService>()
? getIt<SavedSearchAlertService>()
: null;
// Sharing is opt-in: the app must not touch the network until the person
// has joined the sharing side. An install from before this setting keeps
// whatever it had (see `migrateSharingEnabled`) so nobody silently loses
// messaging on upgrade.
final introSeen = await onboarding.introSeen();
final sharingOn = await getIt<SocialSettings>().migrateSharingEnabled(
introSeen: introSeen,
);
// Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the
// shared connection starts connecting, so the first session is caught; then
// bring the connection up. The listeners are harmless while sharing is off:
// they only ever react to a session, and none arrives.
inbox?.start();
sync?.start();
plantares?.start();
connection?.start();
savedSearchAlerts?.start();
if (sharingOn) connection?.start();
return TaneApp(
repository: getIt<VarietyRepository>(),
@ -86,16 +109,22 @@ class _BootstrapState extends State<Bootstrap> {
getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
socialSettings: getIt<SocialSettings>(),
connection: connection,
location: const GeolocatorCoarseLocation(),
location: const NativeCoarseLocation(),
outbox: getIt<OfferOutbox>(),
messageStore: getIt<MessageStore>(),
profileStore: getIt<ProfileStore>(),
profileCache: getIt<ProfileCache>(),
savedOffers: getIt<SavedOffersStore>(),
savedSearches: getIt<SavedSearchesStore>(),
socialAccounts: getIt<SocialAccountStore>(),
inbox: inbox,
notifications: notifications,
showIntro: !await onboarding.introSeen(),
showIntro: !introSeen,
sharing: SharingSwitch(
settings: getIt<SocialSettings>(),
connection: connection,
enabled: sharingOn,
),
autoBackup: getIt.isRegistered<AutoBackupService>()
? getIt<AutoBackupService>()
: null,

View file

@ -153,6 +153,23 @@ class InventoryJsonCodec {
'notes': c.notes,
},
],
'gardenOutcomes': [
for (final o in snapshot.gardenOutcomes)
{
..._syncMeta(
id: o.id,
createdAt: o.createdAt,
updatedAt: o.updatedAt,
lastAuthor: o.lastAuthor,
schemaRowVersion: o.schemaRowVersion,
isDeleted: o.isDeleted,
),
'lotId': o.lotId,
'year': o.year,
'rating': o.rating?.name,
'notes': o.notes,
},
],
'movements': [
for (final m in snapshot.movements)
{
@ -417,6 +434,20 @@ class InventoryJsonCodec {
notes: m['notes'] as String?,
);
}),
gardenOutcomes: _rows(root, 'gardenOutcomes', (m) {
return GardenOutcome(
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),
lotId: _string(m, 'lotId'),
year: m['year'] as int?,
rating: _enumOrNull(GardenOutcomeRating.values, m['rating']),
notes: m['notes'] as String?,
);
}),
movements: _rows(root, 'movements', (m) {
final type = _enumOrNull(MovementType.values, m['type']);
if (type == null) return null; // no safe default drop row

View file

@ -27,6 +27,7 @@ class InventorySnapshot {
this.externalLinks = const [],
this.germinationTests = const [],
this.conditionChecks = const [],
this.gardenOutcomes = const [],
this.movements = const [],
this.parties = const [],
this.attachments = const [],
@ -41,6 +42,7 @@ class InventorySnapshot {
final List<ExternalLink> externalLinks;
final List<GerminationTest> germinationTests;
final List<ConditionCheck> conditionChecks;
final List<GardenOutcome> gardenOutcomes;
final List<Movement> movements;
final List<Party> parties;
final List<Attachment> attachments;

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:async/async.dart';
import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart';
@ -326,6 +328,88 @@ class ConditionEntry extends Equatable {
];
}
/// What kind of event a [LotHistoryEntry] narrates.
enum LotHistoryKind {
created,
movement,
germinationTest,
conditionCheck,
gardenOutcome,
}
/// One line of a lot's composite history — the batch's own story, merged from
/// what is already recorded (the lot's creation with its origin, movements,
/// germination tests and condition checks). Read-only; built by
/// [VarietyRepository.lotHistory].
class LotHistoryEntry extends Equatable {
const LotHistoryEntry({
required this.kind,
this.when,
this.movementType,
this.notes,
this.quantity,
this.counterpartyName,
this.originName,
this.originPlace,
this.germinationRate,
this.containerCount,
this.desiccantState,
this.rating,
this.year,
this.hasParentLink = false,
});
final LotHistoryKind kind;
/// When it happened (ms since epoch): the event's own date when recorded,
/// falling back to the row's `createdAt`. Null only when neither exists.
final int? when;
/// Set for [LotHistoryKind.movement] entries.
final MovementType? movementType;
final String? notes;
final Quantity? quantity;
/// Display name of the movement's counterparty, when one was recorded.
final String? counterpartyName;
/// Provenance carried by the creation entry (who grew/gave it, where from).
final String? originName;
final String? originPlace;
/// Set for [LotHistoryKind.germinationTest] entries (0..1), when computable.
final double? germinationRate;
/// Set for [LotHistoryKind.conditionCheck] entries.
final int? containerCount;
final DesiccantState? desiccantState;
/// Set for [LotHistoryKind.gardenOutcome] entries: how that season went.
final GardenOutcomeRating? rating;
final int? year;
/// True when the movement links back to a source batch (provenance DAG).
final bool hasParentLink;
@override
List<Object?> get props => [
kind,
when,
movementType,
notes,
quantity,
counterpartyName,
originName,
originPlace,
germinationRate,
containerCount,
desiccantState,
rating,
year,
hasParentLink,
];
}
/// One held batch of a variety, for the detail view. [germinationTests] and
/// [conditionChecks] are ordered most-recent first, so `.first` is the latest.
class VarietyLot extends Equatable {
@ -570,13 +654,27 @@ class VarietyRepository {
required this.idGen,
required this.nodeId,
int Function()? nowMillis,
Uint8List? Function(Uint8List)? thumbnailBuilder,
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
_thumbnailBuilder = thumbnailBuilder,
_clock = Hlc.zero(nodeId);
final AppDatabase _db;
final IdGen idGen;
final String nodeId;
final int Function() _now;
/// Builds the small list-avatar thumbnail from a full photo. Injected (from
/// `services/offer_thumbnail.dart` in production) so the data layer stays free
/// of the image codec and unit tests run without decoding. Null no
/// thumbnail is stored and the list falls back to the full photo.
final Uint8List? Function(Uint8List)? _thumbnailBuilder;
/// The thumbnail for [photoBytes], or null when no builder is wired or the
/// bytes aren't decodable. Wrapped in a Value for direct use in a companion.
Value<Uint8List?> _thumbValue(Uint8List photoBytes) =>
Value(_thumbnailBuilder?.call(photoBytes));
Hlc _clock;
/// Emits the non-deleted inventory, ordered by category then label, each with
@ -614,7 +712,11 @@ class VarietyRepository {
_db.select(_db.species).watch().map((_) {}),
_db.select(_db.lots).watch().map((_) {}),
]);
return triggers.asyncMap(
// Coalesce bursts: a single quick-add or handover touches several of these
// tables at once, and each would otherwise re-run the full (7-query) load.
// Debouncing collapses the burst into one reload decisive with a large
// inventory.
return _debounce(triggers, const Duration(milliseconds: 250)).asyncMap(
(_) async => (items: await _loadInventory(), drafts: await _loadDrafts()),
);
}
@ -847,7 +949,10 @@ class VarietyRepository {
return rows.map((l) => l.varietyId).toSet();
}
/// Loads the first photo BLOB for each of [varietyIds] (one query).
/// Loads the first photo's small [thumbnail] for each of [varietyIds] (one
/// query) for the inventory-list avatar. Falls back to the full-resolution
/// [bytes] only when a thumbnail hasn't been generated yet (older rows,
/// pending lazy backfill), so the list stays correct meanwhile.
Future<Map<String, Uint8List>> _firstPhotosFor(
List<String> varietyIds,
) async {
@ -868,17 +973,69 @@ class VarietyRepository {
.get();
final byVariety = <String, Uint8List>{};
for (final row in rows) {
final bytes = row.bytes;
if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes);
final image = row.thumbnail ?? row.bytes;
if (image != null) byVariety.putIfAbsent(row.parentId, () => image);
}
return byVariety;
}
/// Generates the missing list thumbnails for photos that predate the
/// thumbnail column (or arrived via sync/backup restore, which never carry
/// one). Processes in small batches so decoding doesn't block the UI; safe to
/// call at startup as fire-and-forget. No-op when no thumbnail builder is
/// wired. Returns how many thumbnails were written.
Future<int> backfillThumbnails({int batchSize = 20}) async {
final build = _thumbnailBuilder;
if (build == null) return 0;
var written = 0;
while (true) {
final batch =
await (_db.select(_db.attachments)
..where(
(a) =>
a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false) &
a.thumbnail.isNull() &
a.bytes.isNotNull(),
)
..limit(batchSize))
.get();
if (batch.isEmpty) break;
for (final row in batch) {
final thumb = build(row.bytes!);
// No decodable image store the full bytes as the "thumbnail" so this
// row isn't re-scanned forever. It's rare (corrupt photo) and still
// bounded by the avatar's cacheWidth at render time.
await (_db.update(_db.attachments)..where((a) => a.id.equals(row.id)))
.write(AttachmentsCompanion(thumbnail: Value(thumb ?? row.bytes)));
written++;
}
if (batch.length < batchSize) break;
}
return written;
}
/// The cover photo (lowest `sortOrder`) for a single variety, or null when it
/// has none. Used by the publish step to host an offer's image; reuses the
/// same "first photo" rule as the inventory avatar.
Future<Uint8List?> coverPhotoFor(String varietyId) async =>
(await _firstPhotosFor([varietyId]))[varietyId];
/// has none. Used by the publish step to host an offer's image, so it returns
/// the FULL-resolution [bytes] (not the list thumbnail).
Future<Uint8List?> coverPhotoFor(String varietyId) async {
final rows =
await (_db.select(_db.attachments)
..where(
(a) =>
a.parentId.equals(varietyId) &
a.parentType.equalsValue(ParentType.variety) &
a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false),
)
..orderBy([
(a) => OrderingTerm(expression: a.sortOrder),
(a) => OrderingTerm(expression: a.createdAt),
])
..limit(1))
.get();
return rows.isEmpty ? null : rows.first.bytes;
}
/// Maps each of [speciesIds] to its scientific name (one query).
Future<Map<String, String>> _scientificNamesFor(
@ -958,6 +1115,7 @@ class VarietyRepository {
parentId: varietyId,
kind: AttachmentKind.photo,
bytes: Value(photoBytes),
thumbnail: _thumbValue(photoBytes),
mimeType: const Value('image/jpeg'),
),
);
@ -998,6 +1156,7 @@ class VarietyRepository {
parentId: varietyId,
kind: AttachmentKind.photo,
bytes: Value(photoBytes),
thumbnail: _thumbValue(photoBytes),
mimeType: const Value('image/jpeg'),
),
);
@ -1713,6 +1872,7 @@ class VarietyRepository {
parentId: varietyId,
kind: AttachmentKind.photo,
bytes: Value(bytes),
thumbnail: _thumbValue(bytes),
mimeType: const Value('image/jpeg'),
),
);
@ -1870,6 +2030,184 @@ class VarietyRepository {
return id;
}
/// Records a one-tap cultivation event on a lot the grower sowed from it or
/// harvested into it. Only [MovementType.sown] and [MovementType.harvested]
/// are cultivation events; exchanges go through [recordHandover]. Returns the
/// new movement id. [occurredOn] defaults to now.
Future<String> recordCultivation({
required String lotId,
required MovementType type,
int? occurredOn,
String? notes,
}) async {
if (type != MovementType.sown && type != MovementType.harvested) {
throw ArgumentError.value(
type,
'type',
'only sown/harvested are cultivation events',
);
}
final (created, _) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.movements)
.insert(
MovementsCompanion.insert(
id: id,
createdAt: created,
lastAuthor: nodeId,
lotId: lotId,
type: type,
occurredOn: Value(occurredOn ?? created),
notes: Value(_hasText(notes) ? notes!.trim() : null),
),
);
return id;
}
/// The id of the non-deleted variety whose label matches [label]
/// (case-insensitive, trimmed), or null. This is how a scanned envelope
/// label finds its record: the label IS the identity a keeper printed.
Future<String?> findVarietyIdByLabel(String label) async {
final row =
await (_db.select(_db.varieties)
..where(
(v) =>
v.isDeleted.equals(false) &
v.label.lower().equals(label.trim().toLowerCase()),
)
..limit(1))
.getSingleOrNull();
return row?.id;
}
/// Records how a season went for a lot the optional, skippable answer to
/// "how did it do?" asked when a harvest is noted. Returns the new row id.
Future<String> addGardenOutcome({
required String lotId,
int? year,
GardenOutcomeRating? rating,
String? notes,
}) async {
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.gardenOutcomes)
.insert(
GardenOutcomesCompanion.insert(
id: id,
lotId: lotId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
year: Value(year),
rating: Value(rating),
notes: Value(_hasText(notes) ? notes!.trim() : null),
),
);
return id;
}
/// The lot's composite history, most-recent first: every movement,
/// germination test and condition check already recorded, closed by the
/// lot's own creation entry (carrying its origin, so "received from…" shows
/// even when no movement was ever logged). One-shot Future a transient
/// sheet must never hold a live subscription.
Future<List<LotHistoryEntry>> lotHistory(String lotId) async {
final lot = await (_db.select(
_db.lots,
)..where((l) => l.id.equals(lotId))).getSingleOrNull();
if (lot == null) return const [];
final movements = await (_db.select(
_db.movements,
)..where((m) => m.lotId.equals(lotId))).get();
final tests = await (_db.select(
_db.germinationTests,
)..where((g) => g.lotId.equals(lotId) & g.isDeleted.equals(false))).get();
final checks = await (_db.select(
_db.conditionChecks,
)..where((c) => c.lotId.equals(lotId) & c.isDeleted.equals(false))).get();
final outcomes = await (_db.select(
_db.gardenOutcomes,
)..where((o) => o.lotId.equals(lotId) & o.isDeleted.equals(false))).get();
// Resolve counterparty display names in one go (usually zero or one).
final partyIds = movements
.map((m) => m.counterpartyId)
.whereType<String>()
.toSet();
final partyNames = <String, String>{};
if (partyIds.isNotEmpty) {
final parties = await (_db.select(
_db.parties,
)..where((p) => p.id.isIn(partyIds))).get();
for (final p in parties) {
partyNames[p.id] = p.displayName;
}
}
final entries = <LotHistoryEntry>[
for (final m in movements)
LotHistoryEntry(
kind: LotHistoryKind.movement,
when: m.occurredOn ?? m.createdAt,
movementType: m.type,
notes: m.notes,
quantity: m.quantityKind == null && m.quantityLabel == null
? null
: Quantity(
kind: _parseKind(m.quantityKind),
count: m.quantityPrecise,
label: m.quantityLabel,
),
counterpartyName: m.counterpartyId == null
? null
: partyNames[m.counterpartyId],
hasParentLink: m.parentMovementId != null,
),
for (final g in tests)
LotHistoryEntry(
kind: LotHistoryKind.germinationTest,
when: g.testedOn ?? g.createdAt,
notes: g.notes,
germinationRate: GerminationEntry(
id: g.id,
sampleSize: g.sampleSize,
germinatedCount: g.germinatedCount,
).rate,
),
for (final c in checks)
LotHistoryEntry(
kind: LotHistoryKind.conditionCheck,
when: c.checkedOn ?? c.createdAt,
notes: c.notes,
containerCount: c.containerCount,
desiccantState: c.desiccantState,
),
for (final o in outcomes)
LotHistoryEntry(
kind: LotHistoryKind.gardenOutcome,
when: o.createdAt,
notes: o.notes,
rating: o.rating,
year: o.year,
),
]..sort((a, b) => (b.when ?? 0).compareTo(a.when ?? 0));
return [
...entries,
// Creation closes the story (oldest), whatever its stamp: it reads as
// "this batch entered your collection", origin attached.
LotHistoryEntry(
kind: LotHistoryKind.created,
when: lot.createdAt,
originName: lot.originName,
originPlace: lot.originPlace,
),
];
}
// --- Plantares: reproduction commitments (data-model §2.7) ----------------
/// Records a Plantare a promise to reproduce seed and return some (or a
@ -2259,6 +2597,9 @@ class VarietyRepository {
conditionChecks: await (_db.select(
_db.conditionChecks,
)..where((c) => c.isDeleted.equals(false))).get(),
gardenOutcomes: await (_db.select(
_db.gardenOutcomes,
)..where((o) => o.isDeleted.equals(false))).get(),
movements: await _db.select(_db.movements).get(),
parties: await (_db.select(
_db.parties,
@ -2292,6 +2633,7 @@ class VarietyRepository {
externalLinks: await _db.select(_db.externalLinks).get(),
germinationTests: await _db.select(_db.germinationTests).get(),
conditionChecks: await _db.select(_db.conditionChecks).get(),
gardenOutcomes: await _db.select(_db.gardenOutcomes).get(),
movements: await _db.select(_db.movements).get(),
parties: await _db.select(_db.parties).get(),
plantares: await _db.select(_db.plantares).get(),
@ -2372,6 +2714,14 @@ class VarietyRepository {
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.gardenOutcomes,
idColumn: _db.gardenOutcomes.id,
rows: snapshot.gardenOutcomes,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.parties,
idColumn: _db.parties.id,
@ -2414,6 +2764,7 @@ class VarietyRepository {
for (final e in snapshot.externalLinks) e.updatedAt,
for (final g in snapshot.germinationTests) g.updatedAt,
for (final c in snapshot.conditionChecks) c.updatedAt,
for (final o in snapshot.gardenOutcomes) o.updatedAt,
for (final p in snapshot.parties) p.updatedAt,
for (final a in snapshot.attachments) a.updatedAt,
for (final pl in snapshot.plantares) pl.updatedAt,
@ -2733,3 +3084,46 @@ class VarietyRepository {
return maxPacked == null ? null : Hlc.parse(maxPacked);
}
}
/// Emits the latest event from [source] only once [duration] has elapsed with
/// no newer event collapsing a burst of rapid change-triggers into a single
/// downstream reload. Trailing-edge; single-subscription (matches the merged
/// Drift trigger stream it wraps).
Stream<T> _debounce<T>(Stream<T> source, Duration duration) {
late StreamController<T> controller;
StreamSubscription<T>? sub;
Timer? timer;
T? pending;
var hasPending = false;
void flush() {
if (hasPending) {
hasPending = false;
controller.add(pending as T);
}
}
controller = StreamController<T>(
onListen: () {
sub = source.listen(
(value) {
pending = value;
hasPending = true;
timer?.cancel();
timer = Timer(duration, flush);
},
onError: controller.addError,
onDone: () {
timer?.cancel();
flush();
controller.close();
},
);
},
onCancel: () async {
timer?.cancel();
await sub?.cancel();
},
);
return controller.stream;
}

View file

@ -18,6 +18,7 @@ part 'database.g.dart';
Lots,
GerminationTests,
ConditionChecks,
GardenOutcomes,
Movements,
Parties,
Attachments,
@ -31,7 +32,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 = 12;
static const int currentSchemaVersion = 14;
@override
int get schemaVersion => currentSchemaVersion;
@ -187,9 +188,48 @@ class AppDatabase extends _$AppDatabase {
await addIfMissing('return_kind', plantares.returnKind);
await addIfMissing('work_hours', plantares.workHours);
}
// v13: GardenOutcomes the per-season "how did it do in my garden"
// answer (rating + note) captured when a harvest is recorded. Guarded
// (see the v7 note above).
if (from < 13) {
if (!await _hasTable('garden_outcomes')) {
await m.createTable(gardenOutcomes);
}
}
// v14: scalability for large inventories. A local, regenerable [thumbnail]
// BLOB on attachments (so the list never decodes full-resolution photos)
// plus indexes on the columns the inventory list filters/joins on. All
// additive and guarded, so a half-migrated dev database re-runs cleanly
// (see the v7 note above). Existing thumbnails are backfilled lazily in
// the background, not here (image decoding in a migration is slow/fragile).
if (from < 14) {
if (!await _hasColumn('attachments', 'thumbnail')) {
await m.addColumn(attachments, attachments.thumbnail);
}
// Declared as `@TableIndex` on the tables, so a fresh install gets them
// via createAll(); existing databases get them here. Guarded against a
// half-migrated dev database (see the v7 note above).
for (final index in [
idxVarietiesDeletedDraft,
idxAttachmentsParent,
idxLotsVariety,
]) {
if (!await _hasIndex(index.entityName)) await m.create(index);
}
}
},
);
/// Whether an index named [name] already exists keeps the additive v14
/// index creation idempotent against partially-migrated databases.
Future<bool> _hasIndex(String name) async {
final rows = await customSelect(
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?",
variables: [Variable.withString(name)],
).get();
return rows.isNotEmpty;
}
/// Whether a table named [table] already exists. Keeps the additive v8
/// table creation idempotent against partially-migrated databases.
Future<bool> _hasTable(String table) async {

File diff suppressed because it is too large Load diff

View file

@ -67,6 +67,12 @@ enum PreservationFormat {
/// - `fresh` just renewed (lila/violet).
enum DesiccantState { none, add, replace, dry, fresh }
/// How a sowing from this batch turned out in the grower's own garden — the
/// note people most wish they had two seasons later. Deliberately coarse
/// (three faces, not a breeder's scorecard): the free-text note carries any
/// nuance.
enum GardenOutcomeRating { good, mixed, poor }
/// Append-only event kinds on a Lot (data-model §2.4).
enum MovementType {
received,

View file

@ -5,6 +5,10 @@ import 'sync_columns.dart';
/// The identity/accession one row per distinct thing in the inventory.
/// Only [label] is mandatory (progressive disclosure).
///
/// The index covers the inventory list's hot filter — non-deleted, non-draft
/// rows so it stays fast with a large catalogue.
@TableIndex(name: 'idx_varieties_deleted_draft', columns: {#isDeleted, #isDraft})
class Varieties extends Table with SyncColumns {
TextColumn get label => text()();
TextColumn get speciesId => text().nullable()(); // Species
@ -77,6 +81,10 @@ class SpeciesCommonNames extends Table with SyncColumns {
/// A homogeneous batch held for a Variety its own year and its own unit.
/// Quantity (commons_core value type) is flattened into columns here.
///
/// Indexed by [varietyId] the inventory list joins lots per variety (types,
/// shared status, viability), so this avoids a full scan per reload.
@TableIndex(name: 'idx_lots_variety', columns: {#varietyId})
class Lots extends Table with SyncColumns {
TextColumn get varietyId => text()();
TextColumn get type =>
@ -142,6 +150,18 @@ class ConditionChecks extends Table with SyncColumns {
TextColumn get notes => text().nullable()();
}
/// "How did it do in MY garden" one optional, skippable answer per season
/// (v13). Captured at the natural moment (recording a harvest), shown as one
/// more line of the lot's story. Deliberately minimal — a coarse rating plus a
/// free note; the note carries any nuance. Mirrors [GerminationTests]: a dated
/// log under a Lot.
class GardenOutcomes extends Table with SyncColumns {
TextColumn get lotId => text()();
IntColumn get year => integer().nullable()(); // season, e.g. 2026
TextColumn get rating => textEnum<GardenOutcomeRating>().nullable()();
TextColumn get notes => text().nullable()();
}
/// The append-only event log on a Lot history + provenance DAG.
class Movements extends Table with AppendOnlyColumns {
TextColumn get lotId => text()();
@ -169,12 +189,26 @@ class Parties extends Table with SyncColumns {
/// rest by SQLCipher) via [bytes]; external files use [uri]. Storing bytes here
/// keeps the "no plaintext at rest" rule for photos in Block 1; an external
/// encrypted file store is a later optimization.
///
/// Indexed by (parentType, parentId, kind) the list's "first photo per
/// variety" lookup filters on exactly these.
@TableIndex(
name: 'idx_attachments_parent',
columns: {#parentType, #parentId, #kind},
)
class Attachments extends Table with SyncColumns {
TextColumn get parentType => textEnum<ParentType>()();
TextColumn get parentId => text()();
TextColumn get kind => textEnum<AttachmentKind>()();
TextColumn get uri => text().nullable()();
BlobColumn get bytes => blob().nullable()();
/// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so
/// the inventory list never has to decode the full-resolution photo for a
/// 48px avatar. Purely derived, local and regenerable it is EXCLUDED from
/// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a
/// restored backup regenerates it lazily via `backfillThumbnails`.
BlobColumn get thumbnail => blob().nullable()();
TextColumn get mimeType => text().nullable()();
/// Display order among sibling attachments (lower first). The lowest-ordered

View file

@ -28,6 +28,7 @@ import '../services/file_service.dart';
import '../services/inbox_service.dart';
import '../services/locale_store.dart';
import '../services/notification_service.dart';
import '../services/offer_thumbnail.dart';
import '../services/ocr/label_text_extractor.dart';
import '../services/ocr/ocr_language.dart';
import '../services/ocr/tesseract_label_extractor.dart';
@ -41,6 +42,8 @@ import '../services/plantare_service.dart';
import '../services/profile_cache.dart';
import '../services/profile_store.dart';
import '../services/saved_offers_store.dart';
import '../services/saved_search_alert_service.dart';
import '../services/saved_searches_store.dart';
import '../services/social_account_store.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
@ -150,7 +153,11 @@ Future<void> configureDependencies() async {
database,
idGen: IdGen(),
nodeId: nodeId,
thumbnailBuilder: inventoryThumbnailBytes,
);
// Backfill list thumbnails for photos that predate the thumbnail column (or
// arrived via a restored backup). Fire-and-forget so startup isn't blocked.
unawaited(varietyRepository.backfillThumbnails());
const fileService = FilePickerFileService();
@ -189,6 +196,9 @@ Future<void> configureDependencies() async {
..registerSingleton<SavedOffersStore>(
SavedOffersStore(secretStore, accountScope: scope),
)
..registerSingleton<SavedSearchesStore>(
SavedSearchesStore(secretStore, accountScope: scope),
)
..registerSingleton<ExportImportService>(
ExportImportService(
repository: varietyRepository,
@ -273,6 +283,18 @@ Future<void> configureDependencies() async {
selfSecretKey: socialService.identity.privateKeyHex,
connection: connection,
),
)
// Watches the market for offers matching the user's saved searches and
// fires an OS alert on a fresh match. Started in `Bootstrap`.
..registerSingleton<SavedSearchAlertService>(
SavedSearchAlertService(
connection: connection,
selfPubkey: socialService.publicKeyHex,
store: getIt<SavedSearchesStore>(),
settings: getIt<SocialSettings>(),
notifications: getIt<NotificationService>(),
alertTitle: (s) => t.savedSearches.alert(label: s.label),
),
);
}
@ -309,6 +331,10 @@ Future<void> switchSocialAccount(int account) async {
await getIt<PlantareService>().stop();
await getIt.unregister<PlantareService>();
}
if (getIt.isRegistered<SavedSearchAlertService>()) {
await getIt<SavedSearchAlertService>().stop();
await getIt.unregister<SavedSearchAlertService>();
}
if (getIt.isRegistered<SocialConnection>()) {
await getIt<SocialConnection>().dispose();
await getIt.unregister<SocialConnection>();
@ -324,6 +350,8 @@ Future<void> switchSocialAccount(int account) async {
await getIt.unregister<ProfileCache>();
await getIt<SavedOffersStore>().close();
await getIt.unregister<SavedOffersStore>();
await getIt<SavedSearchesStore>().close();
await getIt.unregister<SavedSearchesStore>();
// Re-register the per-identity stores under the new scope, then the identity.
getIt
@ -339,6 +367,9 @@ Future<void> switchSocialAccount(int account) async {
..registerSingleton<SavedOffersStore>(
SavedOffersStore(secretStore, accountScope: scope),
)
..registerSingleton<SavedSearchesStore>(
SavedSearchesStore(secretStore, accountScope: scope),
)
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore),
);
@ -373,16 +404,26 @@ Future<void> switchSocialAccount(int account) async {
selfSecretKey: social.identity.privateKeyHex,
connection: connection,
);
final savedSearchAlerts = SavedSearchAlertService(
connection: connection,
selfPubkey: social.publicKeyHex,
store: getIt<SavedSearchesStore>(),
settings: getIt<SocialSettings>(),
notifications: getIt<NotificationService>(),
alertTitle: (s) => t.savedSearches.alert(label: s.label),
);
getIt
..registerSingleton<SocialService>(social)
..registerSingleton<SocialConnection>(connection)
..registerSingleton<InboxService>(inbox)
..registerSingleton<SyncService>(sync)
..registerSingleton<PlantareService>(plantares);
..registerSingleton<PlantareService>(plantares)
..registerSingleton<SavedSearchAlertService>(savedSearchAlerts);
// Subscribe the listeners BEFORE the connection starts connecting.
inbox.start();
sync.start();
plantares.start();
savedSearchAlerts.start();
connection.start();
}

View file

@ -58,8 +58,7 @@
"cancel": "Encaboxar",
"delete": "Desaniciar",
"edit": "Editar",
"type": "Triba",
"comingSoon": "Aína"
"type": "Triba"
},
"home": {
"tagline": "Comparte y cultiva simiente llocal",
@ -94,6 +93,7 @@
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"langPtBr": "Português (Brasil)",
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
@ -146,9 +146,9 @@
"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",
"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"
@ -515,7 +515,7 @@
"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.",
"setupIntro": "Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu'otres persones cercanes puedan atopalo.",
"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",

View file

@ -59,7 +59,6 @@
"delete": "Löschen",
"edit": "Bearbeiten",
"type": "Typ",
"comingSoon": "Bald",
"offline": "Offline - Teilen ist unterbrochen"
},
"home": {
@ -95,6 +94,7 @@
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"langPtBr": "Português (Brasil)",
"langAst": "Asturianu",
"about": "Über",
"aboutText": "Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.",
@ -147,9 +147,9 @@
"license": "Lizenz",
"licenseValue": "AGPL-3.0",
"website": "Webseite",
"sourceCode": "Quellcode",
"translate": "Beim Übersetzen helfen",
"translateSubtitle": "Hilf, Tane in deine Sprache zu bringen",
"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"
@ -518,7 +518,7 @@
"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.",
"setupIntro": "Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.",
"areaLabel": "Dein Gebiet",
"areaHelp": "Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.",
"areaSet": "Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -59,7 +59,6 @@
"delete": "Supprimer",
"edit": "Modifier",
"type": "Type",
"comingSoon": "À venir",
"offline": "Vous êtes hors ligne — le partage est en pause"
},
"home": {
@ -95,6 +94,7 @@
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"langPtBr": "Português (Brasil)",
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
@ -147,9 +147,9 @@
"license": "Licence",
"licenseValue": "AGPL-3.0",
"website": "Site web",
"sourceCode": "Code source",
"translate": "Aider à traduire",
"translateSubtitle": "Aidez à traduire Tane dans votre langue",
"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"
@ -518,7 +518,7 @@
"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.",
"setupIntro": "Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d'autres près de chez vous puissent le trouver.",
"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",

View file

@ -1,46 +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": "種を管理する"
}
"app": {
"title": "Tane"
},
"common": {
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"type": "種類",
"offline": "オフラインです — 共有を一時停止しています"
},
"menu": {
"tagline": "あなたの種子バンク",
"inventory": "在庫",
"market": "マーケット",
"profile": "プロフィール",
"chat": "チャット",
"wishlist": "お気に入り",
"following": "フォロー中",
"calendar": "カレンダー",
"settings": "設定"
},
"settings": {
"language": "言語",
"systemLanguage": "システムの言語",
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"langPtBr": "Português (Brasil)",
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
"langJa": "日本語",
"about": "このアプリについて",
"aboutText": "在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。",
"aboutOpen": "Tane について"
},
"home": {
"tagline": "地域の種を分かち合い、育てよう",
"openMarket": "マーケット",
"openMarketSubtitle": "近くの種を見つけて分かち合う",
"yourInventory": "あなたの在庫",
"yourInventorySubtitle": "種を管理する"
}
}

View file

@ -12,6 +12,19 @@
"remove": "Remover dos favoritos",
"unavailable": "Já não está disponível"
},
"savedSearches": {
"title": "Pesquisas guardadas",
"empty": "Ainda não há pesquisas guardadas. Guarda uma pesquisa no mercado e avisamos-te quando aparecer algo parecido na tua zona.",
"save": "Guardar esta pesquisa",
"nameLabel": "Dá um nome a esta pesquisa",
"namePlaceholder": "ex.: Tomates perto de mim",
"saved": "Pesquisa guardada — vamos avisar-te sobre novas correspondências perto de ti",
"openTooltip": "Pesquisas guardadas",
"delete": "Eliminar",
"deleteConfirm": "Eliminar esta pesquisa guardada?",
"newMatchesBadge": "{n} novas",
"alert": "Sementes perto de ti: {label}"
},
"seedSaving": {
"title": "Guardar a sua semente",
"subtitle": "O que é preciso para manter a variedade fiel",
@ -59,7 +72,6 @@
"delete": "Eliminar",
"edit": "Editar",
"type": "Tipo",
"comingSoon": "Em breve",
"offline": "Sem ligação — a partilha está em pausa"
},
"home": {
@ -95,6 +107,7 @@
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"langPtBr": "Português (Brasil)",
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
@ -147,9 +160,9 @@
"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",
"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"
@ -226,6 +239,10 @@
"addLink": "Adicionar ligação",
"linkUrl": "URL",
"linkTitle": "Título (opcional)",
"reference": "Saber mais",
"refGbif": "GBIF",
"refWikipedia": "Wikipédia",
"refWikispecies": "Wikispecies",
"notes": "Notas",
"addLot": "Adicionar lote",
"editLot": "Editar lote",
@ -351,6 +368,15 @@
"saved": "Etiquetas guardadas",
"cancelled": "Cancelado"
},
"scan": {
"action": "Digitalizar um rótulo de semente",
"title": "Digitalizar um rótulo",
"notALabel": "Esse código não é um rótulo de semente",
"addTitle": "Não está na tua coleção",
"addBody": "Adicionar “{label}” às tuas sementes?",
"add": "Adicionar",
"added": "Adicionado à tua coleção"
},
"cropCalendar": {
"add": "Calendário de cultivo",
"title": "Calendário de cultivo",
@ -514,7 +540,7 @@
"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.",
"setupIntro": "Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — o que ofereces viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de ti o possam encontrar.",
"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",
@ -645,7 +671,30 @@
"dueByHint": "Um lembrete gentil, nunca imposto",
"pickDate": "Escolher data",
"clearDate": "Limpar data",
"sectionTitle": "Compromissos"
"sectionTitle": "Compromissos",
"propose": "Propor um Plantaré assinado",
"proposeHelp": "Ambos mantêm a mesma promessa, assinada pelos dois — prova de que esta semente mudou de mãos e será cultivada e devolvida.",
"proposeTo": "Com {name}",
"sent": "Proposta enviada — a aguardar que assinem",
"seedLabel": "Que semente?",
"seedHint": "A variedade a que se refere esta promessa",
"returnKindLabel": "O que volta?",
"returnSimilar": "Uma quantidade semelhante de semente",
"returnSimilarNote": "polinização aberta · não transgénico · cultivado organicamente",
"returnWork": "Algumas horas de trabalho",
"returnOther": "Outra coisa",
"workHoursLabel": "Quantas horas?",
"proposalsSection": "À espera da tua resposta",
"incomingFrom": "{name} propõe um Plantaré",
"accept": "Aceitar e assinar",
"declineAction": "Recusar",
"declineReasonHint": "Motivo (opcional)",
"acceptedToast": "Assinado — agora ambos o mantêm",
"declinedToast": "Recusado",
"badgeAwaiting": "A aguardar assinatura",
"badgeSigned": "Assinado por ambos",
"badgeDeclined": "Recusado",
"offline": "Estás offline — será enviado quando reconectares"
},
"handover": {
"title": "Dei ou recebi sementes",
@ -660,6 +709,37 @@
"promiseGave": "Vão devolver-me semente",
"promiseReceived": "Vou devolver semente"
},
"history": {
"title": "História deste lote",
"tooltip": "História",
"sowToday": "Semeado hoje",
"harvestToday": "Colhido hoje",
"sownRecorded": "Sementeira registada",
"harvestRecorded": "Colheita registada",
"movementReceived": "Recebido",
"movementGiven": "Oferecido",
"movementSown": "Semeado",
"movementHarvested": "Colhido",
"movementGerminationTest": "Teste de germinação",
"movementSplit": "Dividido em lotes",
"movementDiscarded": "Descartado",
"created": "Adicionado à tua coleção",
"from": "De {origin}",
"germinationResult": "Teste de germinação — {percent}%",
"linkedEarlier": "Vem de um lote anterior",
"outcomeQuestion": "Como correu?",
"outcomeGood": "Bem",
"outcomeMixed": "Mais ou menos",
"outcomePoor": "Mal",
"outcomeNoteHint": "Uma nota para o teu eu futuro (opcional)",
"outcomeSaved": "Registado",
"ratedGood": "Correu bem",
"ratedMixed": "Correu mais ou menos",
"ratedPoor": "Correu mal",
"outcomeTitle": "Nota da estação",
"outcomeYear": "Estação {year}",
"isolationHint": "Esta espécie cruza-se com vizinhas próximas — cultiva-a a cerca de {meters} m de distância de outras para a manter pura"
},
"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.",

View file

@ -0,0 +1,809 @@
{
"avatar": {
"title": "A sua foto ou avatar",
"fromPhoto": "Tirar ou escolher uma foto",
"illustration": "Ou escolhe um desenho",
"remove": "Remover"
},
"favorites": {
"title": "Favoritos",
"empty": "Ainda não tem favoritos. Salve ofertas que goste do mercado.",
"save": "Salvar nos favoritos",
"remove": "Remover dos favoritos",
"unavailable": "Já não está disponível"
},
"savedSearches": {
"title": "Pesquisas salvas",
"empty": "Ainda não há pesquisas salvas. Salve uma pesquisa no mercado e te avisamos quando aparecer algo parecido na sua zona.",
"save": "Salvar esta pesquisa",
"nameLabel": "Dá um nome a esta pesquisa",
"namePlaceholder": "ex.: Tomates perto de mim",
"saved": "Pesquisa salva — vamos te avisar sobre novas correspondências perto de você",
"openTooltip": "Pesquisas salvas",
"delete": "Eliminar",
"deleteConfirm": "Eliminar esta pesquisa salva?",
"newMatchesBadge": "{n} novas",
"alert": "Sementes perto de você: {label}"
},
"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 você anotou nas suas variedades.",
"nothing": "Nada anotado para {month}."
},
"app": {
"title": "Tane"
},
"bootstrap": {
"failed": "O Tane não conseguiu iniciar",
"retry": "Tentar de novo"
},
"common": {
"save": "Salvar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"type": "Tipo",
"offline": "Sem ligação — a compartilha está em pausa"
},
"home": {
"tagline": "Compartilha e cultiva sementes locais",
"openMarket": "Mercado",
"openMarketSubtitle": "Descobre e compartilha sementes por perto",
"yourInventory": "O seu inventário",
"yourInventorySubtitle": "Gere as suas sementes"
},
"photo": {
"camera": "Tirar uma foto",
"gallery": "Escolher da galeria",
"setAsCover": "Usar como capa",
"isCover": "Foto de capa",
"deleteConfirm": "Eliminar esta foto?"
},
"menu": {
"tagline": "o seu banco de sementes",
"inventory": "Inventário",
"market": "Mercado",
"profile": "O seu perfil",
"chat": "Conversas",
"wishlist": "Favoritos",
"following": "A seguir",
"plantares": "Plantares",
"sales": "Vendas",
"calendar": "Calendário",
"settings": "Definições"
},
"settings": {
"language": "Idioma",
"systemLanguage": "Idioma do sistema",
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"langPtBr": "Português (Brasil)",
"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 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 é salva automaticamente a cada {days} dias",
"exportJson": "Salvar 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",
"importJsonSubtitle": "Recupera uma cópia salva — nada fica duplicado",
"exportCsv": "Exportar para uma folha de cálculo",
"exportCsvSubtitle": "Uma lista simples para Excel ou LibreOffice — sem fotos",
"importCsv": "Importar uma lista",
"importCsvSubtitle": "Acrescenta entradas a partir de uma folha de cálculo",
"importConfirmTitle": "Restaurar uma cópia?",
"importConfirmBody": "As entradas fundem-se com o seu inventário; quando a mesma entrada existe dos dois lados, vence a versão mais recente. Nada fica duplicado.",
"importCsvConfirmTitle": "Importar uma lista?",
"importCsvConfirmBody": "Cada linha é acrescentada como uma entrada nova. Não funde nem substitui, por isso importar o mesmo arquivo duas vezes acrescenta-o duas vezes.",
"importAction": "Importar",
"exportSaved": "Cópia salva",
"cancelled": "Cancelado",
"importDone": "Importado: {added} novas, {updated} atualizadas",
"importCsvDone": "{count} entradas acrescentadas",
"importFailed": "Este arquivo não pôde ser lido como uma cópia do Tane",
"failed": "Algo correu mal",
"recoveryTitle": "O seu código de recuperação",
"recoverySubtitle": "Imprime-o e guarda-o bem: abre as suas cópias noutro aparelho",
"recoveryIntro": "Este código abre as suas cópias salvas e recupera o seu banco em qualquer aparelho. Guarde duas cópias em papel em lugares seguros, como a sua melhor semente. Quem o tiver pode ler as suas cópias, por isso não o compartilhe com ninguém.",
"recoveryCopy": "Copiar",
"recoverySave": "Salvar a folha",
"recoverySheetTitle": "Tane — a sua folha de recuperação",
"recoveryPromptTitle": "Escreve o seu código de recuperação",
"recoveryPromptBody": "Esta cópia foi salva com outro código. Escreve o código da sua folha de recuperação para a abrir.",
"recoveryWrongCode": "Esse código não abre esta cópia"
},
"about": {
"title": "Acerca de",
"kanji": "種",
"tagline": "Uma aplicação local e descentralizada para gerir e compartilhar sementes e plântulas tradicionais.",
"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 compartilhá-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 compartilhado) 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 seu idioma",
"openSourceLicenses": "Licenças de código aberto",
"openSourceLicensesSubtitle": "Bibliotecas de terceiros e as suas licenças",
"copyright": "© {years} Associação Comunes, sob AGPLv3"
},
"intro": {
"skip": "Saltar",
"next": "Seguinte",
"start": "Começar",
"menuEntry": "Como funciona o Tane",
"slides": {
"welcome": {
"title": "A semente que te trouxe até aqui",
"body": "Cada semente tradicional é uma carta escrita por milhares de gerações, passada de mão em mão. Somos o que somos graças a essa compartilha."
},
"inventory": {
"title": "O seu banco de sementes, no bolso",
"body": "Anote o que tem, de que ano, quanto e de onde veio — com o nome que você usa. Uma foto e um nome chegam para começar."
},
"privacy": {
"title": "Seu, e só seu",
"body": "Sem conta, sem internet, sem rastreadores. Os seus dados vivem cifrados no seu aparelho, e só o que você escolher é compartilhado."
},
"share": {
"title": "Compartilhar, como sempre se fez",
"body": "Ofereça o que sobra — dar, trocar ou vender — e deixe que alguém perto de você o encontre. Só se mostra uma distância aproximada, nunca a sua morada; o acordo fecha-se em pessoa."
},
"plantare": {
"title": "Semear é multiplicar",
"body": "Com um Plantare, quem recebe semente promete devolver uma parte mais tarde. E como devolvê-la implica cultivá-la, cada empréstimo multiplica o comum."
}
}
},
"inventory": {
"title": "Inventário",
"searchHint": "Procurar sementes",
"empty": "Ainda não há sementes. Toca em + para adicionar a primeira.",
"noMatches": "Nenhuma semente corresponde aos seus filtros.",
"clearFilters": "Limpar filtros",
"uncategorized": "Sem categoria",
"needsReproductionFilter": "Para reproduzir",
"loadError": "Não foi possível abrir o seu banco de sementes. Talvez estivesse ocupado — tenta de novo.",
"retry": "Tentar de novo"
},
"draft": {
"capture": "Capturar fotos",
"captured": "{n} capturadas para catalogar",
"triageTitle": "Para catalogar",
"triageCount": "{n} para catalogar",
"untitled": "Sem nome",
"nameField": "Dá um nome a esta semente",
"nameHint": "O que é?",
"suggestFromPhoto": "Sugerir nome a partir da foto",
"discard": "Descartar"
},
"quickAdd": {
"title": "Adicionar uma semente",
"labelField": "Nome",
"labelRequired": "Dá-lhe um nome",
"addPhoto": "Adicionar foto",
"quantity": "Quanto?",
"more": "Adicionar mais…",
"save": "Salvar",
"saveAndAddAnother": "Salvar e adicionar outra",
"addedCount": "{n} adicionadas",
"cancel": "Cancelar"
},
"detail": {
"notFound": "Esta semente já não está aqui.",
"lots": "Lotes",
"noLots": "Ainda não há lotes.",
"names": "Também conhecida como",
"addName": "Adicionar nome",
"links": "Ligações",
"addLink": "Adicionar ligação",
"linkUrl": "URL",
"linkTitle": "Título (opcional)",
"reference": "Saber mais",
"refGbif": "GBIF",
"refWikipedia": "Wikipédia",
"refWikispecies": "Wikispecies",
"notes": "Notas",
"addLot": "Adicionar lote",
"editLot": "Editar lote",
"deleteConfirm": "Eliminar esta semente?",
"year": "Ano {year}",
"noYear": "Ano desconhecido"
},
"germination": {
"title": "Germinação",
"add": "Adicionar teste",
"sampleSize": "Tamanho da amostra",
"germinated": "Germinadas",
"none": "Ainda não há testes de germinação.",
"result": "{percent}%"
},
"viability": {
"expiringSoon": "Usa ou reproduz nesta época",
"expiringSoonYears": "Usa ou reproduz nesta época · dura ~{years} anos",
"expired": "Passou a viabilidade típica — reproduzir",
"expiredYears": "Passou a viabilidade típica (~{years} anos) — reproduzir"
},
"editVariety": {
"title": "Editar semente",
"name": "Nome",
"category": "Categoria",
"notes": "Notas",
"species": "Espécie (do catálogo)",
"speciesHint": "Procura uma espécie…",
"speciesSuggested": "Sugerida pelo nome",
"organic": "Biológica",
"organicHint": "Cultivada em modo biológico"
},
"addLot": {
"title": "Adicionar lote",
"year": "Data de colheita",
"quantity": "Quanto?",
"amount": "Quantidade"
},
"harvest": {
"pickTitle": "Escolhe mês / ano",
"anyMonth": "Qualquer mês",
"noDate": "Definir data de colheita",
"monthNames": [
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro"
]
},
"lotType": {
"seed": "Sementes",
"plant": "Planta",
"seedling": "Plântula",
"tree": "Árvore / arbusto",
"bulb": "Bolbo / tubérculo",
"cutting": "Estaca"
},
"presentation": {
"title": "Apresentação",
"none": "Sem indicar",
"pot": "Vaso",
"tray": "Tabuleiro",
"plug": "Alvéolo",
"bareRoot": "Raiz nua",
"rootBall": "Torrão"
},
"provenance": {
"section": "De onde vem",
"seedsFrom": "Sementes de",
"seedsFromHint": "Quem as cultivou ou ofereceu",
"place": "Lugar",
"placeHint": "De onde vêm (com a região)",
"addSeedsFrom": "Sementes de",
"addPlace": "Lugar"
},
"abundance": {
"add": "Quanta tenho",
"title": "Quanta tenho",
"none": "Sem indicar",
"plentyToShare": "De sobra para compartilhar",
"enoughToShare": "Bastante, para compartilhar com moderação",
"enoughForMe": "Suficiente para mim",
"runningLow": "Resta pouca"
},
"share": {
"add": "Compartilhas esta?",
"title": "Compartilhas esta?",
"nudge": "Tem de sobra: podia compartilhar um pouco.",
"price": "Preço",
"priceHint": "Deixe vazio para combinar depois",
"private": "Só para mim",
"gift": "Para dar",
"exchange": "Para trocar",
"sell": "À venda",
"filterChip": "Compartilho",
"printCatalog": "Imprimir o que compartilho",
"catalogTitle": "O que compartilho",
"catalogSaved": "Catálogo salvo",
"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": "Salvar etiquetas",
"saved": "Etiquetas salvas",
"cancelled": "Cancelado"
},
"scan": {
"action": "Digitalizar um rótulo de semente",
"title": "Digitalizar um rótulo",
"notALabel": "Esse código não é um rótulo de semente",
"addTitle": "Não está na sua coleção",
"addBody": "Adicionar “{label}” às suas sementes?",
"add": "Adicionar",
"added": "Adicionado à sua coleção"
},
"cropCalendar": {
"add": "Calendário de cultivo",
"title": "Calendário de cultivo",
"sow": "Sementeira",
"transplant": "Transplante",
"flowering": "Floração",
"fruiting": "Frutificação",
"seedHarvest": "Colheita de semente",
"editorHint": "Anote os meses típicos desta variedade na sua zona — são suas notas.",
"unset": "—"
},
"needsReproduction": {
"label": "Reproduzir nesta época",
"hint": "Cultiva-a antes que a semente acabe",
"badge": "Por reproduzir"
},
"preservation": {
"add": "Como está guardada",
"title": "Como está guardada",
"none": "Sem indicar",
"jarWithDesiccant": "Frasco com agente secante",
"glassJar": "Frasco de vidro",
"paperEnvelope": "Envelope de papel",
"paperBag": "Saco de papel",
"plasticBag": "Saco de plástico"
},
"conditionCheck": {
"advanced": "Armazenamento e detalhes de banco de sementes",
"title": "Verificações de armazenamento",
"add": "Adicionar verificação",
"containers": "Frascos / recipientes",
"desiccant": "Agente secante",
"none": "Ainda não há verificações de armazenamento.",
"summary": "{count} frasco(s) · {state}"
},
"desiccant": {
"none": "Nenhum",
"add": "Adicionar",
"replace": "Substituir",
"dry": "Azul — seco",
"fresh": "Acabado de renovar"
},
"unit": {
"aFew": "algumas",
"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"
}
},
"market": {
"title": "Sementes perto de você",
"subtitle": "O que outras pessoas compartilham por perto",
"notSetUp": "Ainda não configurou a compartilha",
"notSetUpBody": "Ativa a compartilha para ver e dar sementes a pessoas por perto. Mantém-se aproximado — a sua zona, nunca a sua morada exata.",
"setUp": "Configurar a compartilha",
"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 sua zona",
"setAreaBody": "Diz ao mercado a sua zona aproximada para ver sementes por perto.",
"searching": "A procurar pela sua zona…",
"empty": "Ainda não há sementes compartilhadas perto de você",
"searchHint": "Procurar nestas sementes",
"noMatches": "Nenhuma semente compartilhada corresponde à procura",
"near": "Perto de você",
"contact": "Mensagem",
"mine": "Você",
"configTitle": "Configuração de compartilha",
"setupIntro": "Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — o que você oferece viaja por servidores comunitários, mantidos por pessoas e coletivos, não por uma empresa, para que outras pessoas perto de você possam encontrar.",
"areaLabel": "A sua zona",
"areaHelp": "Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.",
"areaSet": "A sua zona está definida — aproximada, nunca o seu ponto exato",
"areaNotSet": "Zona por definir — usa a sua 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": "Escolha que servidores usar. Deixe assim se não souber.",
"serversAdvanced": "Adicionar outro servidor",
"serverAddress": "Endereço do servidor",
"serverInvalid": "Introduz um endereço válido (wss://…)",
"save": "Salvar",
"saved": "Salvo",
"wanted": "Procuro",
"shareMine": "Compartilhar as minhas sementes",
"sharedCount": "Compartilhadas {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 sua localização — verifica se a localização está ativa e a permissão concedida",
"queued": "Salvo — vamos compartilhá-las quando você tiver conexão",
"shareFailed": "Não foi possível contactar os servidores — as suas sementes não foram compartilhadas. Tenta de novo daqui a pouco.",
"rangeLabel": "Até onde procurar",
"rangeNear": "Muito perto",
"rangeArea": "Pela minha zona",
"rangeRegion": "A minha região",
"sharedBy": "Compartilhado por",
"noProfile": "Esta pessoa ainda não compartilhou o seu perfil",
"copyId": "Copiar código",
"idCopied": "Código copiado",
"photo": "Foto"
},
"profile": {
"title": "O seu perfil",
"name": "Nome",
"nameHint": "Como os outros te veem",
"about": "Sobre você",
"aboutHint": "Uma linha — o que cultivas, onde",
"g1": "Endereço Ğ1 (opcional)",
"g1Hint": "Para que te paguem em Ğ1 — separado da sua chave",
"yourId": "A sua identidade",
"idHelp": "Compartilha-a para que te reconheçam",
"copy": "Copiar",
"copied": "Copiado",
"save": "Salvar",
"saved": "Perfil salvo",
"identities": "As suas identidades",
"identitiesHelp": "Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da sua ú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 contatos são guardados separadamente para cada identidade. Pode voltar quando quiser.",
"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 compartilha para enviar mensagens",
"payG1": "Pagar em Ğ1",
"g1Copied": "Endereço Ğ1 copiado — cola-o na sua carteira",
"today": "Hoje",
"yesterday": "Ontem",
"sendError": "Não foi possível enviar — verifica a sua 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 seu círculo"
},
"yourPeople": {
"title": "A sua gente",
"help": "Pessoas que conheces e avalizas, e pessoas que te avalizam.",
"youVouchFor": "Você avaliza",
"vouchesForYou": "Avalizam-te",
"youVouchForEmpty": "Ainda não avaliza ninguém. Quando conhecer alguém, abra a conversa com essa pessoa e toque em \"Conheço esta pessoa\".",
"vouchesForYouEmpty": "Ainda ninguém te avaliza",
"revoke": "Deixar de avalizar",
"revokeConfirm": "Deixar de avalizar esta pessoa?",
"offline": "Sem conexão — tente novamente quando estiver online"
},
"ratings": {
"rate": "Avaliar esta pessoa",
"edit": "Editar a sua avaliação",
"commentHint": "Como correu? (opcional)",
"fromYourCircle": "{n} de pessoas que conheces",
"retract": "Remover a sua avaliação",
"saved": "Avaliação salva"
},
"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 compartilhar ou receber semente com o compromisso de reproduzi-la e devolver algo, anote 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": "Salvar",
"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",
"propose": "Propor um Plantaré assinado",
"proposeHelp": "Ambos mantêm a mesma promessa, assinada pelos dois — prova de que esta semente mudou de mãos e será cultivada e devolvida.",
"proposeTo": "Com {name}",
"sent": "Proposta enviada — a aguardar que assinem",
"seedLabel": "Que semente?",
"seedHint": "A variedade a que se refere esta promessa",
"returnKindLabel": "O que volta?",
"returnSimilar": "Uma quantidade semelhante de semente",
"returnSimilarNote": "polinização aberta · não transgénico · cultivado organicamente",
"returnWork": "Algumas horas de trabalho",
"returnOther": "Outra coisa",
"workHoursLabel": "Quantas horas?",
"proposalsSection": "À espera da sua resposta",
"incomingFrom": "{name} propõe um Plantaré",
"accept": "Aceitar e assinar",
"declineAction": "Recusar",
"declineReasonHint": "Motivo (opcional)",
"acceptedToast": "Assinado — agora ambos o mantêm",
"declinedToast": "Recusado",
"badgeAwaiting": "A aguardar assinatura",
"badgeSigned": "Assinado por ambos",
"badgeDeclined": "Recusado",
"offline": "Você está offline — será enviado quando reconectar"
},
"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"
},
"history": {
"title": "História deste lote",
"tooltip": "História",
"sowToday": "Semeado hoje",
"harvestToday": "Colhido hoje",
"sownRecorded": "Sementeira registada",
"harvestRecorded": "Colheita registada",
"movementReceived": "Recebido",
"movementGiven": "Oferecido",
"movementSown": "Semeado",
"movementHarvested": "Colhido",
"movementGerminationTest": "Teste de germinação",
"movementSplit": "Dividido em lotes",
"movementDiscarded": "Descartado",
"created": "Adicionado à sua coleção",
"from": "De {origin}",
"germinationResult": "Teste de germinação — {percent}%",
"linkedEarlier": "Vem de um lote anterior",
"outcomeQuestion": "Como correu?",
"outcomeGood": "Bem",
"outcomeMixed": "Mais ou menos",
"outcomePoor": "Mal",
"outcomeNoteHint": "Uma nota para o seu eu futuro (opcional)",
"outcomeSaved": "Registado",
"ratedGood": "Correu bem",
"ratedMixed": "Correu mais ou menos",
"ratedPoor": "Correu mal",
"outcomeTitle": "Nota da estação",
"outcomeYear": "Estação {year}",
"isolationHint": "Esta espécie cruza-se com vizinhas próximas — cultiva-a a cerca de {meters} m de distância de outras para a manter pura"
},
"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": "Salvar",
"delete": "Remover",
"removeConfirm": "Remover esta venda?"
},
"legal": {
"title": "Privacidade e regras",
"subtitle": "A sua privacidade, as regras do mercado e a legalidade das sementes",
"privacyTitle": "A sua privacidade",
"privacyBody": "O Tane funciona sem conta, e tudo o que você registra fica no seu dispositivo, cifrado. Não há publicidade, nem rastreadores, nem servidor do Tane.\n\nNada é compartilhado a não ser que você queira: quando publica uma oferta ou o seu perfil, ou envia uma mensagem, viaja por servidores comunitários. As ofertas levam apenas uma zona aproximada — nunca a sua morada.\n\nO que publica é público, e podem ficar cópias mesmo depois de você retirar — por isso compartilhe 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 é responsável pelo que oferece e envia.\n\nNo mercado: seja honesto com suas sementes, ofereça apenas o que pode compartilhar, trate bem as pessoas e não faça spam. Pode bloquear qualquer pessoa e denunciar ofertas ou pessoas que quebrem as regras — as denúncias são tratadas nos servidores comunitários.",
"seedsTitle": "Sobre compartilhar 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 entrar no mercado",
"intro": "O mercado é um espaço compartilhado entre vizinhas e vizinhos. Ao continuar, aceitas umas poucas regras simples:",
"ruleHonest": "Seja honesto com as sementes que oferece",
"ruleLegal": "Compartilhe apenas o que pode compartilhar onde vive",
"ruleRespect": "Trata bem as pessoas — sem spam nem abusos",
"publicNote": "O que você publicar aqui é público, e podem ficar cópias mesmo que você retire 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 sua ligação",
"alsoBlock": "Bloquear também esta pessoa"
},
"block": {
"action": "Bloquear esta pessoa",
"confirmTitle": "Bloquear esta pessoa?",
"confirmBody": "Deixa de ver as suas ofertas e mensagens. Pode desbloqueá-la mais tarde em Pessoas bloqueadas, na configuração de compartilhamento.",
"confirm": "Bloquear",
"blockedToast": "Pessoa bloqueada — as suas ofertas e mensagens ficam ocultas",
"manageTitle": "Pessoas bloqueadas",
"manageEmpty": "Você não bloqueou ninguém",
"unblock": "Desbloquear"
}
}

View file

@ -3,10 +3,10 @@
/// Source: lib/i18n
/// To regenerate, run: `dart run slang`
///
/// Locales: 7
/// Strings: 3472 (496 per locale)
/// Locales: 8
/// Strings: 4299 (537 per locale)
///
/// Built on 2026-07-15 at 17:09 UTC
/// Built on 2026-07-25 at 14:38 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
@ -24,6 +24,7 @@ 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;
import 'strings_pt_BR.g.dart' as l_pt_BR;
part 'strings_en.g.dart';
/// Supported locales.
@ -39,7 +40,8 @@ enum AppLocale with BaseAppLocale<AppLocale, Translations> {
es(languageCode: 'es'),
fr(languageCode: 'fr'),
ja(languageCode: 'ja'),
pt(languageCode: 'pt');
pt(languageCode: 'pt'),
ptBr(languageCode: 'pt', countryCode: 'BR');
const AppLocale({
required this.languageCode,
@ -113,6 +115,12 @@ enum AppLocale with BaseAppLocale<AppLocale, Translations> {
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.ptBr:
return l_pt_BR.TranslationsPtBr(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
}

View file

@ -199,7 +199,6 @@ class _Translations$common$ast extends Translations$common$en {
@override String get delete => 'Desaniciar';
@override String get edit => 'Editar';
@override String get type => 'Triba';
@override String get comingSoon => 'Aína';
}
// Path: home
@ -262,6 +261,7 @@ class _Translations$settings$ast extends Translations$settings$en {
@override String get langEs => 'Español';
@override String get langEn => 'English';
@override String get langPt => 'Português';
@override String get langPtBr => 'Português (Brasil)';
@override String get langAst => 'Asturianu';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@ -765,7 +765,7 @@ class _Translations$market$ast extends Translations$market$en {
@override String get contact => 'Mensaxe';
@override String get mine => 'Tu';
@override String get configTitle => 'Configuración de compartir';
@override String get setupIntro => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu\'otres persones atopen lo qu\'ufiertes, ensin nenguna empresa en mediu.';
@override String get setupIntro => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu\'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu\'otres persones cercanes puedan atopalo.';
@override String get areaLabel => 'La to zona';
@override String get areaHelp => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.';
@override String get areaSet => 'La to zona ta puesta — averada, enxamás el to puntu esactu';
@ -1464,7 +1464,6 @@ extension on TranslationsAst {
'common.delete' => 'Desaniciar',
'common.edit' => 'Editar',
'common.type' => 'Triba',
'common.comingSoon' => 'Aína',
'home.tagline' => 'Comparte y cultiva simiente llocal',
'home.openMarket' => 'Mercáu',
'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca',
@ -1491,6 +1490,7 @@ extension on TranslationsAst {
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.langPtBr' => 'Português (Brasil)',
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
@ -1800,7 +1800,7 @@ extension on TranslationsAst {
'market.contact' => 'Mensaxe',
'market.mine' => 'Tu',
'market.configTitle' => 'Configuración de compartir',
'market.setupIntro' => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu\'otres persones atopen lo qu\'ufiertes, ensin nenguna empresa en mediu.',
'market.setupIntro' => 'Compartir con xente cercano ye opcional. Namás indica la to zona averada — lo qu\'ufiertes viaxa per servidores comunitarios, calteníos por persones y coleutivos, non por una empresa, pa qu\'otres persones cercanes puedan atopalo.',
'market.areaLabel' => 'La to zona',
'market.areaHelp' => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.',
'market.areaSet' => 'La to zona ta puesta — averada, enxamás el to puntu esactu',

View file

@ -199,7 +199,6 @@ class _Translations$common$de extends Translations$common$en {
@override String get delete => 'Löschen';
@override String get edit => 'Bearbeiten';
@override String get type => 'Typ';
@override String get comingSoon => 'Bald';
@override String get offline => 'Offline - Teilen ist unterbrochen';
}
@ -263,6 +262,7 @@ class _Translations$settings$de extends Translations$settings$en {
@override String get langEs => 'Español';
@override String get langEn => 'English';
@override String get langPt => 'Português';
@override String get langPtBr => 'Português (Brasil)';
@override String get langAst => 'Asturianu';
@override String get about => 'Über';
@override String get aboutText => 'Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.';
@ -768,7 +768,7 @@ class _Translations$market$de extends Translations$market$en {
@override String get contact => 'Nachricht';
@override String get mine => 'Du';
@override String get configTitle => 'Teilen-Einrichtung';
@override String get setupIntro => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.';
@override String get setupIntro => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.';
@override String get areaLabel => 'Dein Gebiet';
@override String get areaHelp => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.';
@override String get areaSet => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt';
@ -1460,7 +1460,6 @@ extension on TranslationsDe {
'common.delete' => 'Löschen',
'common.edit' => 'Bearbeiten',
'common.type' => 'Typ',
'common.comingSoon' => 'Bald',
'common.offline' => 'Offline - Teilen ist unterbrochen',
'home.tagline' => 'Teile und baue lokale Samen an',
'home.openMarket' => 'Markt',
@ -1488,6 +1487,7 @@ extension on TranslationsDe {
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.langPtBr' => 'Português (Brasil)',
'settings.langAst' => 'Asturianu',
'settings.about' => 'Über',
'settings.aboutText' => 'Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.',
@ -1799,7 +1799,7 @@ extension on TranslationsDe {
'market.contact' => 'Nachricht',
'market.mine' => 'Du',
'market.configTitle' => 'Teilen-Einrichtung',
'market.setupIntro' => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - du bist bereits mit freigegebenen Gemeinschaftsservern verbunden, damit Leute das finden können, das du anbietest, ohne ein Unternehmen dazwischen.',
'market.setupIntro' => 'Teilen mit Menschen in der Nähe ist optional. Gib einfach dein ungefähres Gebiet an - was du anbietest, läuft über Gemeinschaftsserver, die von Menschen und Kollektiven betrieben werden, nicht von einem Unternehmen, damit andere in deiner Nähe es finden können.',
'market.areaLabel' => 'Dein Gebiet',
'market.areaHelp' => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.',
'market.areaSet' => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt',

View file

@ -42,6 +42,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
// Translations
late final Translations$avatar$en avatar = Translations$avatar$en.internal(_root);
late final Translations$favorites$en favorites = Translations$favorites$en.internal(_root);
late final Translations$savedSearches$en savedSearches = Translations$savedSearches$en.internal(_root);
late final Translations$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root);
late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root);
late final Translations$app$en app = Translations$app$en.internal(_root);
@ -69,6 +70,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$abundance$en abundance = Translations$abundance$en.internal(_root);
late final Translations$share$en share = Translations$share$en.internal(_root);
late final Translations$printLabels$en printLabels = Translations$printLabels$en.internal(_root);
late final Translations$scan$en scan = Translations$scan$en.internal(_root);
late final Translations$cropCalendar$en cropCalendar = Translations$cropCalendar$en.internal(_root);
late final Translations$needsReproduction$en needsReproduction = Translations$needsReproduction$en.internal(_root);
late final Translations$preservation$en preservation = Translations$preservation$en.internal(_root);
@ -85,11 +87,13 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root);
late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root);
late final Translations$handover$en handover = Translations$handover$en.internal(_root);
late final Translations$history$en history = Translations$history$en.internal(_root);
late final Translations$sale$en sale = Translations$sale$en.internal(_root);
late final Translations$legal$en legal = Translations$legal$en.internal(_root);
late final Translations$marketGate$en marketGate = Translations$marketGate$en.internal(_root);
late final Translations$report$en report = Translations$report$en.internal(_root);
late final Translations$block$en block = Translations$block$en.internal(_root);
late final Translations$sharingInvite$en sharingInvite = Translations$sharingInvite$en.internal(_root);
}
// Path: avatar
@ -137,6 +141,48 @@ class Translations$favorites$en {
String get unavailable => 'No longer available';
}
// Path: savedSearches
class Translations$savedSearches$en {
Translations$savedSearches$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Saved searches'
String get title => 'Saved searches';
/// en: 'No saved searches yet. Save a search from the market and we'll tell you when something like it appears in your zone.'
String get empty => 'No saved searches yet. Save a search from the market and we\'ll tell you when something like it appears in your zone.';
/// en: 'Save this search'
String get save => 'Save this search';
/// en: 'Name this search'
String get nameLabel => 'Name this search';
/// en: 'e.g. Tomatoes near me'
String get namePlaceholder => 'e.g. Tomatoes near me';
/// en: 'Search saved — we'll alert you about new matches nearby'
String get saved => 'Search saved — we\'ll alert you about new matches nearby';
/// en: 'Saved searches'
String get openTooltip => 'Saved searches';
/// en: 'Delete'
String get delete => 'Delete';
/// en: 'Delete this saved search?'
String get deleteConfirm => 'Delete this saved search?';
/// en: '{n} new'
String newMatchesBadge({required Object n}) => '${n} new';
/// en: 'New seeds near you: {label}'
String alert({required Object label}) => 'New seeds near you: ${label}';
}
// Path: seedSaving
class Translations$seedSaving$en {
Translations$seedSaving$en.internal(this._root);
@ -295,9 +341,6 @@ class Translations$common$en {
/// en: 'Type'
String get type => 'Type';
/// en: 'Coming soon'
String get comingSoon => 'Coming soon';
/// en: 'You're offline sharing is paused'
String get offline => 'You\'re offline — sharing is paused';
}
@ -415,6 +458,9 @@ class Translations$settings$en {
/// en: 'Português'
String get langPt => 'Português';
/// en: 'Português (Brasil)'
String get langPtBr => 'Português (Brasil)';
/// en: 'Asturianu'
String get langAst => 'Asturianu';
@ -1156,6 +1202,36 @@ class Translations$printLabels$en {
String get cancelled => 'Cancelled';
}
// Path: scan
class Translations$scan$en {
Translations$scan$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Scan a seed label'
String get action => 'Scan a seed label';
/// en: 'Scan a label'
String get title => 'Scan a label';
/// en: 'That code is not a seed label'
String get notALabel => 'That code is not a seed label';
/// en: 'Not in your collection'
String get addTitle => 'Not in your collection';
/// en: 'Add “{label}” to your seeds?'
String addBody({required Object label}) => 'Add “${label}” to your seeds?';
/// en: 'Add it'
String get add => 'Add it';
/// en: 'Added to your collection'
String get added => 'Added to your collection';
}
// Path: cropCalendar
class Translations$cropCalendar$en {
Translations$cropCalendar$en.internal(this._root);
@ -1405,8 +1481,8 @@ class Translations$market$en {
/// en: 'Sharing setup'
String get configTitle => 'Sharing setup';
/// en: 'Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.'
String get setupIntro => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.';
/// en: 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.'
String get setupIntro => 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.';
/// en: 'Your area'
String get areaLabel => 'Your area';
@ -1500,6 +1576,12 @@ class Translations$market$en {
/// en: 'Photo'
String get photo => 'Photo';
/// en: 'Sharing is on'
String get sharingOnLabel => 'Sharing is on';
/// en: 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.'
String get sharingOnHelp => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.';
}
// Path: profile
@ -1937,6 +2019,102 @@ class Translations$handover$en {
String get promiseReceived => 'I\'ll return seed';
}
// Path: history
class Translations$history$en {
Translations$history$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Story of this batch'
String get title => 'Story of this batch';
/// en: 'Story'
String get tooltip => 'Story';
/// en: 'Sown today'
String get sowToday => 'Sown today';
/// en: 'Harvested today'
String get harvestToday => 'Harvested today';
/// en: 'Sowing noted'
String get sownRecorded => 'Sowing noted';
/// en: 'Harvest noted'
String get harvestRecorded => 'Harvest noted';
/// en: 'Received'
String get movementReceived => 'Received';
/// en: 'Given away'
String get movementGiven => 'Given away';
/// en: 'Sown'
String get movementSown => 'Sown';
/// en: 'Harvested'
String get movementHarvested => 'Harvested';
/// en: 'Germination test'
String get movementGerminationTest => 'Germination test';
/// en: 'Split into batches'
String get movementSplit => 'Split into batches';
/// en: 'Discarded'
String get movementDiscarded => 'Discarded';
/// en: 'Added to your collection'
String get created => 'Added to your collection';
/// en: 'From {origin}'
String from({required Object origin}) => 'From ${origin}';
/// en: 'Germination test — {percent}%'
String germinationResult({required Object percent}) => 'Germination test — ${percent}%';
/// en: 'Comes from an earlier batch'
String get linkedEarlier => 'Comes from an earlier batch';
/// en: 'How did it do?'
String get outcomeQuestion => 'How did it do?';
/// en: 'Well'
String get outcomeGood => 'Well';
/// en: 'So-so'
String get outcomeMixed => 'So-so';
/// en: 'Poorly'
String get outcomePoor => 'Poorly';
/// en: 'A note for your future self (optional)'
String get outcomeNoteHint => 'A note for your future self (optional)';
/// en: 'Noted'
String get outcomeSaved => 'Noted';
/// en: 'It did well'
String get ratedGood => 'It did well';
/// en: 'It did so-so'
String get ratedMixed => 'It did so-so';
/// en: 'It did poorly'
String get ratedPoor => 'It did poorly';
/// en: 'Season note'
String get outcomeTitle => 'Season note';
/// en: 'Season {year}'
String outcomeYear({required Object year}) => 'Season ${year}';
/// en: 'This species crosses with its neighbours — grow it about {meters} m away from others to keep it true'
String isolationHint({required Object meters}) => 'This species crosses with its neighbours — grow it about ${meters} m away from others to keep it true';
}
// Path: sale
class Translations$sale$en {
Translations$sale$en.internal(this._root);
@ -2067,6 +2245,9 @@ class Translations$marketGate$en {
/// en: 'Not now'
String get decline => 'Not now';
/// en: 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn't connect to anything.'
String get networkNote => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.';
}
// Path: report
@ -2150,6 +2331,36 @@ class Translations$block$en {
String get unblock => 'Unblock';
}
// Path: sharingInvite
class Translations$sharingInvite$en {
Translations$sharingInvite$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'This wakes up when you start sharing'
String get title => 'This wakes up when you start sharing';
/// en: 'Write to whoever has seeds near you'
String get perkChat => 'Write to whoever has seeds near you';
/// en: 'Keep the offers you like'
String get perkFavorites => 'Keep the offers you like';
/// en: 'Your circle of people you trust'
String get perkPeople => 'Your circle of people you trust';
/// en: 'For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.'
String get networkNote => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.';
/// en: 'Start sharing'
String get start => 'Start sharing';
/// en: 'Not now'
String get notNow => 'Not now';
}
// Path: intro.slides
class Translations$intro$slides$en {
Translations$intro$slides$en.internal(this._root);
@ -2616,6 +2827,17 @@ extension on Translations {
'favorites.save' => 'Save to favorites',
'favorites.remove' => 'Remove from favorites',
'favorites.unavailable' => 'No longer available',
'savedSearches.title' => 'Saved searches',
'savedSearches.empty' => 'No saved searches yet. Save a search from the market and we\'ll tell you when something like it appears in your zone.',
'savedSearches.save' => 'Save this search',
'savedSearches.nameLabel' => 'Name this search',
'savedSearches.namePlaceholder' => 'e.g. Tomatoes near me',
'savedSearches.saved' => 'Search saved — we\'ll alert you about new matches nearby',
'savedSearches.openTooltip' => 'Saved searches',
'savedSearches.delete' => 'Delete',
'savedSearches.deleteConfirm' => 'Delete this saved search?',
'savedSearches.newMatchesBadge' => ({required Object n}) => '${n} new',
'savedSearches.alert' => ({required Object label}) => 'New seeds near you: ${label}',
'seedSaving.title' => 'Saving its seed',
'seedSaving.subtitle' => 'What it takes to keep the variety true',
'seedSaving.lifeCycle' => 'Cycle',
@ -2654,7 +2876,6 @@ extension on Translations {
'common.delete' => 'Delete',
'common.edit' => 'Edit',
'common.type' => 'Type',
'common.comingSoon' => 'Coming soon',
'common.offline' => 'You\'re offline — sharing is paused',
'home.tagline' => 'Share and grow local seeds',
'home.openMarket' => 'Market',
@ -2682,6 +2903,7 @@ extension on Translations {
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.langPtBr' => 'Português (Brasil)',
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
@ -2891,6 +3113,13 @@ extension on Translations {
'printLabels.save' => 'Save labels',
'printLabels.saved' => 'Labels saved',
'printLabels.cancelled' => 'Cancelled',
'scan.action' => 'Scan a seed label',
'scan.title' => 'Scan a label',
'scan.notALabel' => 'That code is not a seed label',
'scan.addTitle' => 'Not in your collection',
'scan.addBody' => ({required Object label}) => 'Add “${label}” to your seeds?',
'scan.add' => 'Add it',
'scan.added' => 'Added to your collection',
'cropCalendar.add' => 'Crop calendar',
'cropCalendar.title' => 'Crop calendar',
'cropCalendar.sow' => 'Sow',
@ -2993,7 +3222,7 @@ extension on Translations {
'market.contact' => 'Message',
'market.mine' => 'You',
'market.configTitle' => 'Sharing setup',
'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.',
'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — what you offer travels through community servers, run by people and collectives rather than a company, so others nearby can find it.',
'market.areaLabel' => 'Your area',
'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.',
'market.areaSet' => 'Your area is set — kept coarse, never your exact spot',
@ -3025,6 +3254,8 @@ extension on Translations {
'market.copyId' => 'Copy code',
'market.idCopied' => 'Code copied',
'market.photo' => 'Photo',
'market.sharingOnLabel' => 'Sharing is on',
'market.sharingOnHelp' => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.',
'profile.title' => 'Your profile',
'profile.name' => 'Display name',
'profile.nameHint' => 'How others see you',
@ -3099,6 +3330,8 @@ extension on Translations {
'plantare.delete' => 'Remove',
'plantare.statusReturned' => 'Returned',
'plantare.statusForgiven' => 'Settled',
_ => null,
} ?? switch (path) {
'plantare.openSection' => 'Open',
'plantare.settledSection' => 'Done',
'plantare.removeConfirm' => 'Remove this commitment?',
@ -3119,8 +3352,6 @@ extension on Translations {
'plantare.returnSimilar' => 'A similar amount of seed',
'plantare.returnSimilarNote' => 'open-pollinated · non-GMO · organically grown',
'plantare.returnWork' => 'Some hours of work',
_ => null,
} ?? switch (path) {
'plantare.returnOther' => 'Something else',
'plantare.workHoursLabel' => 'How many hours?',
'plantare.proposalsSection' => 'Waiting for your reply',
@ -3145,6 +3376,35 @@ extension on Translations {
'handover.paymentChip' => 'Money changed hands',
'handover.promiseGave' => 'They\'ll return me seed',
'handover.promiseReceived' => 'I\'ll return seed',
'history.title' => 'Story of this batch',
'history.tooltip' => 'Story',
'history.sowToday' => 'Sown today',
'history.harvestToday' => 'Harvested today',
'history.sownRecorded' => 'Sowing noted',
'history.harvestRecorded' => 'Harvest noted',
'history.movementReceived' => 'Received',
'history.movementGiven' => 'Given away',
'history.movementSown' => 'Sown',
'history.movementHarvested' => 'Harvested',
'history.movementGerminationTest' => 'Germination test',
'history.movementSplit' => 'Split into batches',
'history.movementDiscarded' => 'Discarded',
'history.created' => 'Added to your collection',
'history.from' => ({required Object origin}) => 'From ${origin}',
'history.germinationResult' => ({required Object percent}) => 'Germination test — ${percent}%',
'history.linkedEarlier' => 'Comes from an earlier batch',
'history.outcomeQuestion' => 'How did it do?',
'history.outcomeGood' => 'Well',
'history.outcomeMixed' => 'So-so',
'history.outcomePoor' => 'Poorly',
'history.outcomeNoteHint' => 'A note for your future self (optional)',
'history.outcomeSaved' => 'Noted',
'history.ratedGood' => 'It did well',
'history.ratedMixed' => 'It did so-so',
'history.ratedPoor' => 'It did poorly',
'history.outcomeTitle' => 'Season note',
'history.outcomeYear' => ({required Object year}) => 'Season ${year}',
'history.isolationHint' => ({required Object meters}) => 'This species crosses with its neighbours — grow it about ${meters} m away from others to keep it true',
'sale.title' => 'Sales',
'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.',
'sale.add' => 'Record a sale',
@ -3180,6 +3440,7 @@ extension on Translations {
'marketGate.viewLegal' => 'Privacy & rules',
'marketGate.accept' => 'I agree',
'marketGate.decline' => 'Not now',
'marketGate.networkNote' => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.',
'report.offer' => 'Report this offer',
'report.person' => 'Report this person',
'report.title' => 'Report',
@ -3201,6 +3462,13 @@ extension on Translations {
'block.manageTitle' => 'Blocked people',
'block.manageEmpty' => 'You haven\'t blocked anyone',
'block.unblock' => 'Unblock',
'sharingInvite.title' => 'This wakes up when you start sharing',
'sharingInvite.perkChat' => 'Write to whoever has seeds near you',
'sharingInvite.perkFavorites' => 'Keep the offers you like',
'sharingInvite.perkPeople' => 'Your circle of people you trust',
'sharingInvite.networkNote' => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.',
'sharingInvite.start' => 'Start sharing',
'sharingInvite.notNow' => 'Not now',
_ => null,
};
}

View file

@ -41,6 +41,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
// Translations
@override late final _Translations$avatar$es avatar = _Translations$avatar$es._(_root);
@override late final _Translations$favorites$es favorites = _Translations$favorites$es._(_root);
@override late final _Translations$savedSearches$es savedSearches = _Translations$savedSearches$es._(_root);
@override late final _Translations$seedSaving$es seedSaving = _Translations$seedSaving$es._(_root);
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
@override late final _Translations$app$es app = _Translations$app$es._(_root);
@ -68,6 +69,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$abundance$es abundance = _Translations$abundance$es._(_root);
@override late final _Translations$share$es share = _Translations$share$es._(_root);
@override late final _Translations$printLabels$es printLabels = _Translations$printLabels$es._(_root);
@override late final _Translations$scan$es scan = _Translations$scan$es._(_root);
@override late final _Translations$cropCalendar$es cropCalendar = _Translations$cropCalendar$es._(_root);
@override late final _Translations$needsReproduction$es needsReproduction = _Translations$needsReproduction$es._(_root);
@override late final _Translations$preservation$es preservation = _Translations$preservation$es._(_root);
@ -84,11 +86,13 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$notifications$es notifications = _Translations$notifications$es._(_root);
@override late final _Translations$plantare$es plantare = _Translations$plantare$es._(_root);
@override late final _Translations$handover$es handover = _Translations$handover$es._(_root);
@override late final _Translations$history$es history = _Translations$history$es._(_root);
@override late final _Translations$sale$es sale = _Translations$sale$es._(_root);
@override late final _Translations$legal$es legal = _Translations$legal$es._(_root);
@override late final _Translations$marketGate$es marketGate = _Translations$marketGate$es._(_root);
@override late final _Translations$report$es report = _Translations$report$es._(_root);
@override late final _Translations$block$es block = _Translations$block$es._(_root);
@override late final _Translations$sharingInvite$es sharingInvite = _Translations$sharingInvite$es._(_root);
}
// Path: avatar
@ -118,6 +122,26 @@ class _Translations$favorites$es extends Translations$favorites$en {
@override String get unavailable => 'Ya no está disponible';
}
// Path: savedSearches
class _Translations$savedSearches$es extends Translations$savedSearches$en {
_Translations$savedSearches$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Búsquedas guardadas';
@override String get empty => 'Aún no tienes búsquedas guardadas. Guarda una búsqueda desde el mercado y te avisaremos cuando aparezca algo así en tu zona.';
@override String get save => 'Guardar esta búsqueda';
@override String get nameLabel => 'Nombra esta búsqueda';
@override String get namePlaceholder => 'p. ej. Tomates cerca';
@override String get saved => 'Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca';
@override String get openTooltip => 'Búsquedas guardadas';
@override String get delete => 'Eliminar';
@override String get deleteConfirm => '¿Eliminar esta búsqueda guardada?';
@override String newMatchesBadge({required Object n}) => '${n} nuevas';
@override String alert({required Object label}) => 'Semillas nuevas cerca: ${label}';
}
// Path: seedSaving
class _Translations$seedSaving$es extends Translations$seedSaving$en {
_Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -199,7 +223,6 @@ class _Translations$common$es extends Translations$common$en {
@override String get delete => 'Eliminar';
@override String get edit => 'Editar';
@override String get type => 'Tipo';
@override String get comingSoon => 'Pronto';
@override String get offline => 'Sin conexión — el compartir está en pausa';
}
@ -263,6 +286,7 @@ class _Translations$settings$es extends Translations$settings$en {
@override String get langEs => 'Español';
@override String get langEn => 'English';
@override String get langPt => 'Português';
@override String get langPtBr => 'Português (Brasil)';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@override String get langJa => '日本語';
@ -628,6 +652,22 @@ class _Translations$printLabels$es extends Translations$printLabels$en {
@override String get cancelled => 'Cancelado';
}
// Path: scan
class _Translations$scan$es extends Translations$scan$en {
_Translations$scan$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get action => 'Escanear una etiqueta';
@override String get title => 'Escanear etiqueta';
@override String get notALabel => 'Ese código no es una etiqueta de semillas';
@override String get addTitle => 'No está en tu colección';
@override String addBody({required Object label}) => '¿Añadir «${label}» a tus semillas?';
@override String get add => 'Añadirla';
@override String get added => 'Añadida a tu colección';
}
// Path: cropCalendar
class _Translations$cropCalendar$es extends Translations$cropCalendar$en {
_Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -767,7 +807,7 @@ class _Translations$market$es extends Translations$market$en {
@override String get contact => 'Mensaje';
@override String get mine => '';
@override String get configTitle => 'Configuración de compartir';
@override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.';
@override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — lo que ofreces viaja por servidores comunitarios, mantenidos por personas y colectivos, no por una empresa, para que otras personas cercanas puedan encontrarlo.';
@override String get areaLabel => 'Tu zona';
@override String get areaHelp => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.';
@override String get areaSet => 'Tu zona está puesta — aproximada, nunca tu punto exacto';
@ -799,6 +839,8 @@ class _Translations$market$es extends Translations$market$en {
@override String get copyId => 'Copiar código';
@override String get idCopied => 'Código copiado';
@override String get photo => 'Foto';
@override String get sharingOnLabel => 'Compartir está activado';
@override String get sharingOnHelp => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.';
}
// Path: profile
@ -1000,6 +1042,44 @@ class _Translations$handover$es extends Translations$handover$en {
@override String get promiseReceived => 'Devolveré semilla';
}
// Path: history
class _Translations$history$es extends Translations$history$en {
_Translations$history$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Historia de este lote';
@override String get tooltip => 'Historia';
@override String get sowToday => 'Sembrado hoy';
@override String get harvestToday => 'Cosechado hoy';
@override String get sownRecorded => 'Siembra anotada';
@override String get harvestRecorded => 'Cosecha anotada';
@override String get movementReceived => 'Recibido';
@override String get movementGiven => 'Regalado';
@override String get movementSown => 'Sembrado';
@override String get movementHarvested => 'Cosechado';
@override String get movementGerminationTest => 'Prueba de germinación';
@override String get movementSplit => 'Repartido en lotes';
@override String get movementDiscarded => 'Descartado';
@override String get created => 'Entró en tu colección';
@override String from({required Object origin}) => 'De ${origin}';
@override String germinationResult({required Object percent}) => 'Prueba de germinación — ${percent}%';
@override String get linkedEarlier => 'Viene de un lote anterior';
@override String get outcomeQuestion => '¿Qué tal se dio?';
@override String get outcomeGood => 'Bien';
@override String get outcomeMixed => 'Regular';
@override String get outcomePoor => 'Mal';
@override String get outcomeNoteHint => 'Una nota para tu yo del futuro (opcional)';
@override String get outcomeSaved => 'Anotado';
@override String get ratedGood => 'Se dio bien';
@override String get ratedMixed => 'Se dio regular';
@override String get ratedPoor => 'Se dio mal';
@override String get outcomeTitle => 'Nota de temporada';
@override String outcomeYear({required Object year}) => 'Temporada ${year}';
@override String isolationHint({required Object meters}) => 'Esta especie se cruza con sus vecinas — cultívala a unos ${meters} m de otras para que se mantenga fiel';
}
// Path: sale
class _Translations$sale$es extends Translations$sale$en {
_Translations$sale$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1060,6 +1140,7 @@ class _Translations$marketGate$es extends Translations$marketGate$en {
@override String get viewLegal => 'Privacidad y normas';
@override String get accept => 'Acepto';
@override String get decline => 'Ahora no';
@override String get networkNote => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.';
}
// Path: report
@ -1101,6 +1182,22 @@ class _Translations$block$es extends Translations$block$en {
@override String get unblock => 'Desbloquear';
}
// Path: sharingInvite
class _Translations$sharingInvite$es extends Translations$sharingInvite$en {
_Translations$sharingInvite$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Esto se enciende cuando empiezas a compartir';
@override String get perkChat => 'Escribirte con quien tiene semillas cerca';
@override String get perkFavorites => 'Guardar las ofertas que te gustan';
@override String get perkPeople => 'Tu círculo de gente de confianza';
@override String get networkNote => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.';
@override String get start => 'Empezar a compartir';
@override String get notNow => 'Ahora no';
}
// Path: intro.slides
class _Translations$intro$slides$es extends Translations$intro$slides$en {
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1451,6 +1548,17 @@ extension on TranslationsEs {
'favorites.save' => 'Guardar en favoritos',
'favorites.remove' => 'Quitar de favoritos',
'favorites.unavailable' => 'Ya no está disponible',
'savedSearches.title' => 'Búsquedas guardadas',
'savedSearches.empty' => 'Aún no tienes búsquedas guardadas. Guarda una búsqueda desde el mercado y te avisaremos cuando aparezca algo así en tu zona.',
'savedSearches.save' => 'Guardar esta búsqueda',
'savedSearches.nameLabel' => 'Nombra esta búsqueda',
'savedSearches.namePlaceholder' => 'p. ej. Tomates cerca',
'savedSearches.saved' => 'Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca',
'savedSearches.openTooltip' => 'Búsquedas guardadas',
'savedSearches.delete' => 'Eliminar',
'savedSearches.deleteConfirm' => '¿Eliminar esta búsqueda guardada?',
'savedSearches.newMatchesBadge' => ({required Object n}) => '${n} nuevas',
'savedSearches.alert' => ({required Object label}) => 'Semillas nuevas cerca: ${label}',
'seedSaving.title' => 'Conservar su semilla',
'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel',
'seedSaving.lifeCycle' => 'Ciclo',
@ -1489,7 +1597,6 @@ extension on TranslationsEs {
'common.delete' => 'Eliminar',
'common.edit' => 'Editar',
'common.type' => 'Tipo',
'common.comingSoon' => 'Pronto',
'common.offline' => 'Sin conexión — el compartir está en pausa',
'home.tagline' => 'Comparte y cultiva semillas locales',
'home.openMarket' => 'Mercado',
@ -1517,6 +1624,7 @@ extension on TranslationsEs {
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.langPtBr' => 'Português (Brasil)',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
'settings.langJa' => '日本語',
@ -1725,6 +1833,13 @@ extension on TranslationsEs {
'printLabels.save' => 'Guardar etiquetas',
'printLabels.saved' => 'Etiquetas guardadas',
'printLabels.cancelled' => 'Cancelado',
'scan.action' => 'Escanear una etiqueta',
'scan.title' => 'Escanear etiqueta',
'scan.notALabel' => 'Ese código no es una etiqueta de semillas',
'scan.addTitle' => 'No está en tu colección',
'scan.addBody' => ({required Object label}) => '¿Añadir «${label}» a tus semillas?',
'scan.add' => 'Añadirla',
'scan.added' => 'Añadida a tu colección',
'cropCalendar.add' => 'Calendario de cultivo',
'cropCalendar.title' => 'Calendario de cultivo',
'cropCalendar.sow' => 'Siembra',
@ -1827,7 +1942,7 @@ extension on TranslationsEs {
'market.contact' => 'Mensaje',
'market.mine' => '',
'market.configTitle' => 'Configuración de compartir',
'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.',
'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — lo que ofreces viaja por servidores comunitarios, mantenidos por personas y colectivos, no por una empresa, para que otras personas cercanas puedan encontrarlo.',
'market.areaLabel' => 'Tu zona',
'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.',
'market.areaSet' => 'Tu zona está puesta — aproximada, nunca tu punto exacto',
@ -1859,6 +1974,8 @@ extension on TranslationsEs {
'market.copyId' => 'Copiar código',
'market.idCopied' => 'Código copiado',
'market.photo' => 'Foto',
'market.sharingOnLabel' => 'Compartir está activado',
'market.sharingOnHelp' => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.',
'profile.title' => 'Tu perfil',
'profile.name' => 'Nombre',
'profile.nameHint' => 'Cómo te ven los demás',
@ -1934,6 +2051,8 @@ extension on TranslationsEs {
'plantare.statusReturned' => 'Devuelto',
'plantare.statusForgiven' => 'Saldado',
'plantare.openSection' => 'Pendientes',
_ => null,
} ?? switch (path) {
'plantare.settledSection' => 'Hechos',
'plantare.removeConfirm' => '¿Quitar este compromiso?',
'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}',
@ -1954,8 +2073,6 @@ extension on TranslationsEs {
'plantare.returnSimilarNote' => 'polinización abierta · no transgénica · cultivada en ecológico',
'plantare.returnWork' => 'Unas horas de trabajo',
'plantare.returnOther' => 'Otra cosa',
_ => null,
} ?? switch (path) {
'plantare.workHoursLabel' => '¿Cuántas horas?',
'plantare.proposalsSection' => 'Esperando tu respuesta',
'plantare.incomingFrom' => ({required Object name}) => '${name} te propone un Plantaré',
@ -1979,6 +2096,35 @@ extension on TranslationsEs {
'handover.paymentChip' => 'Hubo dinero por medio',
'handover.promiseGave' => 'Me devolverán semilla',
'handover.promiseReceived' => 'Devolveré semilla',
'history.title' => 'Historia de este lote',
'history.tooltip' => 'Historia',
'history.sowToday' => 'Sembrado hoy',
'history.harvestToday' => 'Cosechado hoy',
'history.sownRecorded' => 'Siembra anotada',
'history.harvestRecorded' => 'Cosecha anotada',
'history.movementReceived' => 'Recibido',
'history.movementGiven' => 'Regalado',
'history.movementSown' => 'Sembrado',
'history.movementHarvested' => 'Cosechado',
'history.movementGerminationTest' => 'Prueba de germinación',
'history.movementSplit' => 'Repartido en lotes',
'history.movementDiscarded' => 'Descartado',
'history.created' => 'Entró en tu colección',
'history.from' => ({required Object origin}) => 'De ${origin}',
'history.germinationResult' => ({required Object percent}) => 'Prueba de germinación — ${percent}%',
'history.linkedEarlier' => 'Viene de un lote anterior',
'history.outcomeQuestion' => '¿Qué tal se dio?',
'history.outcomeGood' => 'Bien',
'history.outcomeMixed' => 'Regular',
'history.outcomePoor' => 'Mal',
'history.outcomeNoteHint' => 'Una nota para tu yo del futuro (opcional)',
'history.outcomeSaved' => 'Anotado',
'history.ratedGood' => 'Se dio bien',
'history.ratedMixed' => 'Se dio regular',
'history.ratedPoor' => 'Se dio mal',
'history.outcomeTitle' => 'Nota de temporada',
'history.outcomeYear' => ({required Object year}) => 'Temporada ${year}',
'history.isolationHint' => ({required Object meters}) => 'Esta especie se cruza con sus vecinas — cultívala a unos ${meters} m de otras para que se mantenga fiel',
'sale.title' => 'Ventas',
'sale.help' => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.',
'sale.add' => 'Registrar venta',
@ -2014,6 +2160,7 @@ extension on TranslationsEs {
'marketGate.viewLegal' => 'Privacidad y normas',
'marketGate.accept' => 'Acepto',
'marketGate.decline' => 'Ahora no',
'marketGate.networkNote' => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.',
'report.offer' => 'Denunciar esta oferta',
'report.person' => 'Denunciar a esta persona',
'report.title' => 'Denunciar',
@ -2035,6 +2182,13 @@ extension on TranslationsEs {
'block.manageTitle' => 'Personas bloqueadas',
'block.manageEmpty' => 'No has bloqueado a nadie',
'block.unblock' => 'Desbloquear',
'sharingInvite.title' => 'Esto se enciende cuando empiezas a compartir',
'sharingInvite.perkChat' => 'Escribirte con quien tiene semillas cerca',
'sharingInvite.perkFavorites' => 'Guardar las ofertas que te gustan',
'sharingInvite.perkPeople' => 'Tu círculo de gente de confianza',
'sharingInvite.networkNote' => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.',
'sharingInvite.start' => 'Empezar a compartir',
'sharingInvite.notNow' => 'Ahora no',
_ => null,
};
}

View file

@ -199,7 +199,6 @@ class _Translations$common$fr extends Translations$common$en {
@override String get delete => 'Supprimer';
@override String get edit => 'Modifier';
@override String get type => 'Type';
@override String get comingSoon => 'À venir';
@override String get offline => 'Vous êtes hors ligne — le partage est en pause';
}
@ -263,6 +262,7 @@ class _Translations$settings$fr extends Translations$settings$en {
@override String get langEs => 'Español';
@override String get langEn => 'English';
@override String get langPt => 'Português';
@override String get langPtBr => 'Português (Brasil)';
@override String get langAst => 'Asturianu';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@ -768,7 +768,7 @@ class _Translations$market$fr extends Translations$market$en {
@override String get contact => 'Message';
@override String get mine => 'Vous';
@override String get configTitle => 'Configuration du partage';
@override String get setupIntro => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.';
@override String get setupIntro => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d\'autres près de chez vous puissent le trouver.';
@override String get areaLabel => 'Votre zone';
@override String get areaHelp => 'Gardée approximative volontairement — votre zone, jamais un point exact.';
@override String get areaSet => 'Votre zone est définie — approximative, jamais votre point exact';
@ -1460,7 +1460,6 @@ extension on TranslationsFr {
'common.delete' => 'Supprimer',
'common.edit' => 'Modifier',
'common.type' => 'Type',
'common.comingSoon' => 'À venir',
'common.offline' => 'Vous êtes hors ligne — le partage est en pause',
'home.tagline' => 'Partagez et cultivez des graines locales',
'home.openMarket' => 'Marché',
@ -1488,6 +1487,7 @@ extension on TranslationsFr {
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.langPtBr' => 'Português (Brasil)',
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
@ -1799,7 +1799,7 @@ extension on TranslationsFr {
'market.contact' => 'Message',
'market.mine' => 'Vous',
'market.configTitle' => 'Configuration du partage',
'market.setupIntro' => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — vous êtes déjà connectés aux serveurs communautaires partagés pour que les gens trouvent ce que vous offrez, sans aucune entreprise au milieu.',
'market.setupIntro' => 'Le partage avec les gens à proximité est optionnel. Définissez simplement votre zone approximative — ce que vous offrez passe par des serveurs communautaires, tenus par des personnes et des collectifs et non par une entreprise, pour que d\'autres près de chez vous puissent le trouver.',
'market.areaLabel' => 'Votre zone',
'market.areaHelp' => 'Gardée approximative volontairement — votre zone, jamais un point exact.',
'market.areaSet' => 'Votre zone est définie — approximative, jamais votre point exact',

View file

@ -68,7 +68,6 @@ class _Translations$common$ja extends Translations$common$en {
@override String get delete => '削除';
@override String get edit => '編集';
@override String get type => '種類';
@override String get comingSoon => '近日公開';
@override String get offline => 'オフラインです — 共有を一時停止しています';
}
@ -102,6 +101,7 @@ class _Translations$settings$ja extends Translations$settings$en {
@override String get langEs => 'Español';
@override String get langEn => 'English';
@override String get langPt => 'Português';
@override String get langPtBr => 'Português (Brasil)';
@override String get langAst => 'Asturianu';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@ -139,7 +139,6 @@ extension on TranslationsJa {
'common.delete' => '削除',
'common.edit' => '編集',
'common.type' => '種類',
'common.comingSoon' => '近日公開',
'common.offline' => 'オフラインです — 共有を一時停止しています',
'menu.tagline' => 'あなたの種子バンク',
'menu.inventory' => '在庫',
@ -155,6 +154,7 @@ extension on TranslationsJa {
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.langPtBr' => 'Português (Brasil)',
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'bootstrap.dart';
@ -6,6 +7,16 @@ import 'ui/restart_widget.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Draw behind the system bars (edge-to-edge) with transparent bars, so the app
// fills the screen on Android 15+ (where edge-to-edge is enforced) and stays
// consistent elsewhere. Scaffolds use SafeArea to keep content off the insets.
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Color(0x00000000),
systemNavigationBarColor: Color(0x00000000),
),
);
// 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.

View file

@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Whether this device has any camera. Resolved once at startup (via the native
/// channel, `PackageManager.FEATURE_CAMERA_ANY`) and cached so `build()` methods
/// can read it synchronously the QR scan button ([qrScanSupported]) and the
/// photo-source sheet hide their camera options when it is false.
///
/// Defaults to `true` so a normal phone never loses camera UI over a transient
/// channel error; only genuinely camera-less devices (Chromebooks, Android
/// Automotive, some TVs kept installable by the `uses-feature required=false`
/// in AndroidManifest) flip it to false. Non-Android platforms keep the default
/// (iOS devices have a camera; desktop/web gate camera UI elsewhere).
bool _deviceHasCamera = true;
bool get deviceHasCamera => _deviceHasCamera;
@visibleForTesting
set deviceHasCamera(bool value) => _deviceHasCamera = value;
const _channel = MethodChannel('org.comunes.tane/coarse_location');
/// Queries the native camera-presence check and caches it. Called once during
/// bootstrap, before the first real frame, so synchronous readers see the right
/// value. Android-only; elsewhere the default stands. Never throws a channel
/// error leaves the safe default in place.
Future<void> initCameraAvailability() async {
if (defaultTargetPlatform != TargetPlatform.android) return;
try {
final has = await _channel.invokeMethod<bool>('hasCamera');
if (has != null) _deviceHasCamera = has;
} catch (_) {
// Keep the default; a normal phone shouldn't lose camera UI over this.
}
}

View file

@ -1,50 +1,40 @@
import 'package:geolocator/geolocator.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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
/// platform code 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();
/// Platform-channel implementation backed by Android's own LocationManager —
/// deliberately no Google Play Services, so the APK stays free of proprietary
/// classes (F-Droid inclusion). The native side handles the permission prompt,
/// a coarse single fix with a timeout, and a last-known-position fallback.
/// Returns null on any denial/error rather than throwing, so the UI can
/// degrade quietly. Android only; other platforms return null.
class NativeCoarseLocation implements CoarseLocationProvider {
const NativeCoarseLocation();
static const _channel = MethodChannel('org.comunes.tane/coarse_location');
@override
Future<({double lat, double lon})?> currentCoarseLatLon() async {
if (defaultTargetPlatform != TargetPlatform.android) return null;
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);
// Safety net over the native 12s fix timeout; the permission prompt can
// legitimately keep the call open for a while, hence the wide margin.
final result = await _channel
.invokeMapMethod<String, double>('getCoarseLatLon')
.timeout(const Duration(seconds: 60));
final lat = result?['lat'];
final lon = result?['lon'];
if (lat == null || lon == null) return null;
return (lat: lat, lon: lon);
} catch (_) {
return null; // plugin unsupported (desktop), etc.
return null; // denied, timeout, channel error
}
}
}

View file

@ -25,7 +25,16 @@ class NotificationService {
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
void Function(String peerPubkey)? onTapChat;
/// Called with a saved-search id when the user taps a search-alert
/// notification. The app wires this to open the saved-searches list.
void Function(String searchId)? onTapSearch;
static const _channelId = 'messages';
static const _alertsChannelId = 'alerts';
/// Tap payloads are prefixed so one handler can route by kind. A bare payload
/// (no prefix) stays a chat peer pubkey, for backward compatibility.
static const _searchPayloadPrefix = 'search:';
static bool get _platformSupported {
if (kIsWeb) return false;
@ -57,7 +66,12 @@ class NotificationService {
),
onDidReceiveNotificationResponse: (response) {
final payload = response.payload;
if (payload != null && payload.isNotEmpty) onTapChat?.call(payload);
if (payload == null || payload.isEmpty) return;
if (payload.startsWith(_searchPayloadPrefix)) {
onTapSearch?.call(payload.substring(_searchPayloadPrefix.length));
} else {
onTapChat?.call(payload);
}
},
);
await _plugin
@ -100,4 +114,35 @@ class NotificationService {
await _plugin.show(peerPubkey.hashCode, title, null, details,
payload: peerPubkey);
}
/// Shows a notification for a new offer matching a saved search. [title] is a
/// generic line built by the caller (e.g. "New match: Tomatoes"); [searchId]
/// rides along so a tap opens that search. No-op on unsupported platforms.
Future<void> showSearchAlert({
required String searchId,
required String title,
}) async {
if (!_supported) return;
const details = NotificationDetails(
android: AndroidNotificationDetails(
_alertsChannelId,
'Search alerts',
channelDescription: 'New offers matching your saved searches',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
);
// One notification per search (same id replaces the previous), so repeated
// matches for one search don't stack up.
await _plugin.show(
_searchPayloadPrefix.hashCode ^ searchId.hashCode,
title,
null,
details,
payload: '$_searchPayloadPrefix$searchId',
);
}
}

View file

@ -34,6 +34,26 @@ String? offerThumbnailDataUri(
}
}
/// Builds a small JPEG thumbnail (longest edge [edge]px) for the inventory-list
/// avatar, so the list never has to decode the full-resolution photo. Returns
/// raw JPEG bytes, or null on undecodable input (the caller falls back to the
/// full photo). Kept as local bytes regenerable, so it is excluded from sync
/// and backups.
Uint8List? inventoryThumbnailBytes(
Uint8List bytes, {
int edge = 96,
int quality = 72,
}) {
try {
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
final resized = _fitWithin(decoded, edge);
return Uint8List.fromList(img.encodeJpg(resized, quality: quality));
} catch (_) {
return null;
}
}
/// 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.

View file

@ -0,0 +1,147 @@
import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/foundation.dart';
import 'discovery_area.dart';
import 'notification_service.dart';
import 'saved_searches_store.dart';
import 'social_connection.dart';
import 'social_service.dart' show SocialSession;
import 'social_settings.dart';
/// Builds the notification title for a fresh match on [search]. Injected so the
/// app can supply a localized string while tests stay i18n-free.
typedef AlertTitle = String Function(SavedSearch search);
/// App-wide listener that turns incoming market offers into saved-search alerts
/// (Wallapop's "búsquedas favoritas"). One discovery subscription over the
/// SHARED [SocialConnection] for the user's whole current zone; each offer is
/// matched locally against every saved search.
///
/// The discover stream replays a relay's stored offers before going live, so
/// this doubles as the catch-up scan on app start the per-search seen-key
/// dedup in [SavedSearchesStore] keeps replays and edits silent, so only
/// genuinely new matches notify. It re-subscribes when the connection
/// reconnects and when the user's search area changes.
///
/// Foreground only background/push delivery is a later, larger concern.
class SavedSearchAlertService {
SavedSearchAlertService({
required SocialConnection connection,
required String selfPubkey,
required SavedSearchesStore store,
required SocialSettings settings,
NotificationService? notifications,
AlertTitle? alertTitle,
}) : _connection = connection,
_selfPubkey = selfPubkey,
_store = store,
_settings = settings,
_notifications = notifications,
_alertTitle = alertTitle ?? ((s) => s.label);
final SocialConnection _connection;
final String _selfPubkey;
final SavedSearchesStore _store;
final SocialSettings _settings;
final NotificationService? _notifications;
final AlertTitle _alertTitle;
StreamSubscription<SocialSession?>? _sessionsSub;
StreamSubscription<Offer>? _offersSub;
SocialSession? _session;
String _prefix = '';
bool _started = false;
/// Begins listening. Subscribe to the connection's sessions BEFORE it starts
/// connecting, so the first session is caught too.
void start() {
if (_started) return;
_started = true;
_sessionsSub = _connection.sessions.listen(_onSession);
final current = _connection.current;
if (current != null) _onSession(current);
}
/// Re-subscribes discovery, e.g. after the user changes their search area.
/// Cheap no-op when the session and prefix are unchanged.
Future<void> refreshArea() async {
final session = _session;
if (session != null) await _bind(session, force: true);
}
void _onSession(SocialSession? session) {
if (identical(session, _session) && _offersSub != null) return;
_session = session;
if (session == null) {
unawaited(_offersSub?.cancel());
_offersSub = null;
return;
}
unawaited(_bind(session, force: true));
}
/// (Re)binds the offer subscription to [session] over the user's current
/// zone. Skips re-subscribing when nothing changed unless [force].
Future<void> _bind(SocialSession session, {bool force = false}) async {
final area = await _settings.areaGeohash();
if (area.isEmpty) {
// No zone set yet nothing to search. Drop any stale subscription.
await _offersSub?.cancel();
_offersSub = null;
_prefix = '';
return;
}
final prefix = searchPrefix(area, await _settings.searchPrecision());
if (!force && prefix == _prefix && _offersSub != null) return;
await _offersSub?.cancel();
_prefix = prefix;
_offersSub = session.offers
.discover(DiscoveryQuery(geohashPrefix: prefix))
.listen(
ingest,
onError: (_) {}, // handled by the connection's reconnect
);
}
/// Matches one incoming [offer] against every saved search and fires an alert
/// for each fresh match. A testable seam (no relay).
@visibleForTesting
Future<void> ingest(Offer offer) async {
// Never alert on your own listings.
if (offer.authorPubkeyHex == _selfPubkey) return;
// Only live offers are worth surfacing.
if (offer.status != OfferLifecycle.active) return;
// Respect the same moderation the market applies.
if (await _settings.isBlocked(offer.authorPubkeyHex)) return;
final hidden = await _settings.hiddenOfferKeys();
if (hidden.contains(SocialSettings.offerKey(offer.authorPubkeyHex, offer.id))) {
return;
}
final searches = await _store.list();
if (searches.isEmpty) return;
final key = SavedSearchesStore.keyOf(offer);
for (final search in searches) {
if (!search.matches(offer)) continue;
final isNew = await _store.markMatched(search.id, key);
if (isNew) {
await _notifications?.showSearchAlert(
searchId: search.id,
title: _alertTitle(search),
);
}
}
}
Future<void> stop() async {
_started = false;
await _sessionsSub?.cancel();
_sessionsSub = null;
await _offersSub?.cancel();
_offersSub = null;
_session = null;
_prefix = '';
}
}

View file

@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'package:commons_core/commons_core.dart';
import '../security/secret_store.dart';
/// A person's saved market searches with their alert bookkeeping — the query
/// and facets they want to be notified about, Wallapop-style. Keystore-backed
/// (no plaintext at rest) and namespaced by the active identity's
/// [accountScope], like [SavedOffersStore].
///
/// Per search we also persist:
/// - [_seenKeysField]: offer coordinates (`authorPubkeyHex:id`) already alerted
/// on, so a relay replaying stored/edited events never re-notifies. Capped at
/// [_seenCap] (oldest dropped) to keep the blob small.
/// - [_newKeysField]: matches the user hasn't looked at yet — the badge source,
/// cleared when they open the search.
class SavedSearchesStore {
SavedSearchesStore(this._store, {String accountScope = ''})
: _key = accountScope.isEmpty
? 'tane.social.saved_searches'
: 'tane.social.$accountScope.saved_searches';
final SecretStore _store;
final String _key;
static const _seenCap = 500;
static const _seenKeysField = 'seenKeys';
static const _newKeysField = 'newKeys';
final _changes = StreamController<void>.broadcast();
/// Emits whenever the stored searches or their alert counts change, so open
/// screens and badges can refresh.
Stream<void> get changes => _changes.stream;
/// The stable per-offer key used for dedup: its addressable coordinate
/// (`authorPubkeyHex:id`), matching [SavedOffersStore.keyOf].
static String keyOf(Offer offer) => '${offer.authorPubkeyHex}:${offer.id}';
/// Every saved search, newest first (by creation time).
Future<List<SavedSearch>> list() async {
final entries = await _rawList()
..sort((a, b) =>
((b['createdAt'] as int?) ?? 0).compareTo((a['createdAt'] as int?) ?? 0));
return [for (final e in entries) SavedSearch.fromJson(e)];
}
/// Whether a search with [id] exists.
Future<bool> exists(String id) async =>
(await _rawList()).any((e) => e['id'] == id);
/// Saves [search] (idempotent on its id). [seedSeenKeys] pre-marks offers the
/// user has already seen (the current on-screen matches), so they aren't
/// alerted about listings that were visible when they saved the search.
Future<void> save(
SavedSearch search, {
Iterable<String> seedSeenKeys = const [],
}) async {
final list = await _rawList();
final existing = _find(list, search.id);
final seen = <String>{
...?(existing?[_seenKeysField] as List?)?.cast<String>(),
...seedSeenKeys,
};
list
..removeWhere((e) => e['id'] == search.id)
..add({
...search.toJson(),
_seenKeysField: _capped(seen.toList()),
// Keep any unread matches on re-save (e.g. an edit), else start empty.
_newKeysField:
(existing?[_newKeysField] as List?)?.cast<String>() ?? const [],
});
await _writeRaw(list);
}
/// Removes the search with [id]; no-op if absent.
Future<void> remove(String id) async {
final list = await _rawList()..removeWhere((e) => e['id'] == id);
await _writeRaw(list);
}
/// Records that [offerKey] matched search [id]: adds it to both the seen set
/// (so it never re-alerts) and the unread set (so the badge shows it).
/// Returns true when this is a fresh match worth notifying about; false when
/// the key was already seen (a replay/echo) or the search is gone.
Future<bool> markMatched(String id, String offerKey) async {
final list = await _rawList();
final entry = _find(list, id);
if (entry == null) return false;
final seen = (entry[_seenKeysField] as List?)?.cast<String>() ?? const [];
if (seen.contains(offerKey)) return false;
entry[_seenKeysField] = _capped([...seen, offerKey]);
final unread = (entry[_newKeysField] as List?)?.cast<String>() ?? const [];
if (!unread.contains(offerKey)) {
entry[_newKeysField] = [...unread, offerKey];
}
await _writeRaw(list);
return true;
}
/// Clears the unread matches for search [id] (the user opened it).
Future<void> markViewed(String id) async {
final list = await _rawList();
final entry = _find(list, id);
if (entry == null) return;
if (((entry[_newKeysField] as List?) ?? const []).isEmpty) return;
entry[_newKeysField] = const [];
await _writeRaw(list);
}
/// Unread match count for a single search.
Future<int> newMatchCount(String id) async {
final entry = _find(await _rawList(), id);
return ((entry?[_newKeysField] as List?) ?? const []).length;
}
/// Total unread matches across every saved search (the app-level badge).
Future<int> totalNewMatchCount() async {
var total = 0;
for (final e in await _rawList()) {
total += ((e[_newKeysField] as List?) ?? const []).length;
}
return total;
}
static Map<String, dynamic>? _find(
List<Map<String, dynamic>> list,
String id,
) {
for (final e in list) {
if (e['id'] == id) return e;
}
return null;
}
/// Keeps only the newest [_seenCap] keys (list is append-ordered, so drop
/// from the front).
static List<String> _capped(List<String> keys) => keys.length <= _seenCap
? keys
: keys.sublist(keys.length - _seenCap);
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>>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
Future<void> _writeRaw(List<Map<String, dynamic>> list) async {
await _store.write(_key, jsonEncode(list));
if (!_changes.isClosed) _changes.add(null);
}
Future<void> close() async {
await _changes.close();
}
}

View file

@ -0,0 +1,66 @@
import 'package:equatable/equatable.dart';
import '../data/export_import/seed_label_codec.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
/// What scanning a QR yielded: not a seed label at all, a variety already in
/// the collection ([varietyId] set), or a seed not held yet ([data] set the
/// UI asks before adding anything).
class SeedScanResult extends Equatable {
const SeedScanResult.notALabel() : varietyId = null, data = null;
const SeedScanResult.match(String this.varietyId) : data = null;
const SeedScanResult.unknown(SeedLabelData this.data) : varietyId = null;
final String? varietyId;
final SeedLabelData? data;
@override
List<Object?> get props => [varietyId, data];
}
/// Resolves a scanned QR payload against the collection: decodes the
/// `tane://seed` label and looks the variety up by its label (the identity a
/// keeper printed on the envelope). This is how a physical packet finds its
/// way back to its record even a re-saved one whose digital thread broke.
Future<SeedScanResult> resolveScannedLabel(
VarietyRepository repository,
String raw,
) async {
final data = SeedLabelCodec.decode(raw);
if (data == null) return const SeedScanResult.notALabel();
final varietyId = await repository.findVarietyIdByLabel(data.varietyLabel);
return varietyId == null
? SeedScanResult.unknown(data)
: SeedScanResult.match(varietyId);
}
/// Adds the scanned seed to the collection a variety named as the label
/// says, linked to the bundled species when the scientific name matches
/// exactly, with one lot carrying the label's harvest year and origin.
/// Called only after the person confirmed the add prompt.
Future<String> importScannedLabel({
required VarietyRepository repository,
required SpeciesRepository species,
required SeedLabelData data,
}) async {
final varietyId = await repository.addQuickVariety(label: data.varietyLabel);
final scientific = data.scientificName?.trim();
if (scientific != null && scientific.isNotEmpty) {
final matches = await species.search(scientific, limit: 4);
for (final m in matches) {
if (m.scientificName.toLowerCase() == scientific.toLowerCase()) {
await repository.linkSpecies(varietyId, m.id);
break;
}
}
}
await repository.addLot(
varietyId: varietyId,
harvestYear: data.year,
originName: data.origin,
);
return varietyId;
}

View file

@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
import 'social_connection.dart';
import 'social_settings.dart';
/// The one place that turns the sharing side of Tane on and off.
///
/// Sharing is opt-in: until someone joins it, the app opens no connection at
/// all and the seed book is entirely offline. Joining has to move three things
/// at once the stored choice, the live relay connection, and the flag the UI
/// listens to and doing that from several screens is how they drift apart.
/// So every caller (the community-rules gate, the invite sheet, the sharing
/// setup) goes through here instead.
class SharingSwitch {
SharingSwitch({
required SocialSettings settings,
SocialConnection? connection,
bool enabled = false,
}) : _settings = settings,
_connection = connection,
on = ValueNotifier(enabled);
final SocialSettings _settings;
final SocialConnection? _connection;
/// Whether sharing is on right now. Screens listen so the drawer's social
/// entries light up the moment someone joins, with no restart.
final ValueNotifier<bool> on;
Future<void> enable() async {
await _settings.setSharingEnabled(true);
// Safe to call even if it is already running: `start` guards itself.
_connection?.start();
on.value = true;
}
/// Turning it off must actually go offline closing the live session, not
/// just recording the choice for the next launch.
Future<void> disable() async {
await _settings.setSharingEnabled(false);
await _connection?.stop();
on.value = false;
}
void dispose() => on.dispose();
}

View file

@ -25,19 +25,36 @@ class SocialConnection {
required SocialSettings settings,
SessionOpener? open,
Stream<bool>? online,
List<Duration>? retrySchedule,
}) : _settings = settings,
_open = open ?? social.openSession,
_online = online;
_online = online,
_retrySchedule = retrySchedule ?? _defaultRetrySchedule;
/// Backoff for self-retries after a failed attempt while started and not
/// knowingly offline (the fresh-install case: online the whole time, first
/// connect fails, so no connectivity change ever retriggers a connect).
static const _defaultRetrySchedule = [
Duration(seconds: 5),
Duration(seconds: 15),
Duration(seconds: 45),
Duration(seconds: 90),
];
final SocialSettings _settings;
final SessionOpener _open;
final Stream<bool>? _online;
final List<Duration> _retrySchedule;
final _sessions = StreamController<SocialSession?>.broadcast();
SocialSession? _current;
Future<SocialSession?>? _pending;
StreamSubscription<bool>? _onlineSub;
bool _disposed = false;
bool _started = false;
bool _knownOffline = false;
Timer? _retryTimer;
int _retryIndex = 0;
/// Emits the live session on each (re)connect, and null when it drops.
Stream<SocialSession?> get sessions => _sessions.stream;
@ -46,12 +63,19 @@ class SocialConnection {
SocialSession? get current => _current;
/// Begins watching connectivity (reconnect on regain, drop when offline) and
/// attempts an initial connect. Idempotent-ish; call once at startup.
/// attempts an initial connect. Called at startup when sharing is already on,
/// and again the moment someone joins the sharing side hence the guard, so
/// a second call never stacks a second connectivity subscription.
void start() {
if (_started || _disposed) return;
_started = true;
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
_knownOffline = !isOnline;
if (!isOnline) {
_cancelRetry();
_drop();
} else if (_current == null) {
_retryIndex = 0; // fresh network start the backoff over
unawaited(session());
}
});
@ -75,15 +99,40 @@ class SocialConnection {
return null;
}
_current = s;
_retryIndex = 0;
_cancelRetry();
_sessions.add(s);
return s;
} catch (_) {
return null; // unreachable a later connectivity change retries
_scheduleRetry(); // unreachable retry with backoff (see below)
return null;
} finally {
_pending = null;
}
}
/// After a failed attempt, retries by itself while started and not knowingly
/// offline a connectivity change may never come if the device was online
/// all along. Un-started connections (one-shot [session] callers, tests)
/// never leave a timer behind.
void _scheduleRetry() {
if (!_started || _disposed || _knownOffline || _current != null) return;
_cancelRetry();
final i = _retryIndex < _retrySchedule.length
? _retryIndex
: _retrySchedule.length - 1;
_retryIndex++;
_retryTimer = Timer(_retrySchedule[i], () {
if (_disposed || _current != null) return;
unawaited(session());
});
}
void _cancelRetry() {
_retryTimer?.cancel();
_retryTimer = null;
}
void _drop() {
final s = _current;
_current = null;
@ -93,8 +142,22 @@ class SocialConnection {
}
}
/// Goes offline for good until [start] is called again: stops watching
/// connectivity, cancels any pending retry and tears the live session down.
/// This is what "turn sharing off" must do before it existed, clearing the
/// server list only took effect on the next launch, because the already-open
/// session was never closed. Unlike [dispose] the object stays usable.
Future<void> stop() async {
_started = false;
_cancelRetry();
await _onlineSub?.cancel();
_onlineSub = null;
_drop();
}
Future<void> dispose() async {
_disposed = true;
_cancelRetry();
await _onlineSub?.cancel();
_onlineSub = null;
_drop();

View file

@ -5,9 +5,11 @@ import '../security/secret_store.dart';
/// keystore (via [SecretStore]) to honour "no plaintext at rest" no
/// shared_preferences.
///
/// Relays default to a small set of well-known public servers so the market
/// works out of the box; the exposure is minimal (offers are opt-in and carry
/// only a coarse geohash) and the user can swap them for a community server.
/// Sharing is off until the person joins it ([sharingEnabled]); until then the
/// app opens no connection at all. Once they do, relays default to a small set
/// of well-known public servers so the market works out of the box; the exposure
/// is minimal (offers are opt-in and carry only a coarse geohash) and the user
/// can swap them for a community server or turn them all off.
/// The area stays unset until the user picks one (it's inherently personal).
class SocialSettings {
SocialSettings(this._store);
@ -16,6 +18,7 @@ class SocialSettings {
static const _areaKey = 'tane.social.area_geohash';
static const _relaysKey = 'tane.social.relays';
static const _sharingKey = 'tane.social.sharing_enabled';
static const _searchPrecisionKey = 'tane.social.search_precision';
static const _blockedKey = 'tane.social.blocked_pubkeys';
static const _hiddenOffersKey = 'tane.social.hidden_offers';
@ -29,11 +32,11 @@ class SocialSettings {
static const int maxSearchPrecision = 5;
static const int defaultSearchPrecision = 4;
/// Community servers used automatically so sharing works from the first
/// launch. The relay pool skips any that are unreachable, so a dead one never
/// breaks the market; the user never has to know these exist. The Comunes
/// relay comes first as the reliable, non-commercial home; the public ones
/// are backup.
/// Community servers used automatically once the person joins the sharing
/// side, so the market works without any setup. The relay pool skips any that
/// are unreachable, so a dead one never breaks the market; the user never has
/// to know these exist. The Comunes relay comes first as the reliable,
/// non-commercial home; the public ones are backup.
static const List<String> defaultRelays = [
'wss://relay.comunes.org',
'wss://nos.lol',
@ -62,6 +65,38 @@ class SocialSettings {
urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'),
);
/// Whether the person has said yes to the sharing side of the app. Until they
/// do, Tane opens no connection at all the seed book is entirely offline.
///
/// Three states on purpose: `null` means "never asked", which is what lets an
/// install that predates this setting keep working exactly as before (see
/// `migrateSharingEnabled`). Once written it is a plain yes/no the person
/// controls from the sharing setup.
Future<bool?> sharingEnabled() async {
final raw = await _store.read(_sharingKey);
if (raw == null) return null;
return raw == '1';
}
Future<void> setSharingEnabled(bool enabled) =>
_store.write(_sharingKey, enabled ? '1' : '0');
/// Decides, once, what an install that predates the setting should get, and
/// records it. Anyone who had already been through the intro was on a build
/// that connected at launch, so they keep sharing on and lose nothing
/// messages, device sync and offer alerts keep arriving. A fresh install has
/// not seen the intro yet, so it starts fully offline and only goes online
/// when the person joins the sharing side.
///
/// Returns the effective value. Safe to call on every launch: it writes only
/// when nothing has been recorded yet.
Future<bool> migrateSharingEnabled({required bool introSeen}) async {
final stored = await sharingEnabled();
if (stored != null) return stored;
await setSharingEnabled(introSeen);
return introSeen;
}
/// How wide to search a geohash prefix length in [minSearchPrecision,
/// maxSearchPrecision]. Defaults (and falls back on any garbage) to
/// [defaultSearchPrecision].

View file

@ -10,6 +10,7 @@ import '../services/offer_mapper.dart';
import '../services/offer_outbox.dart';
import '../services/offer_thumbnail.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
/// State of the offer discovery/publish screen. Transport-agnostic it holds
/// only what the UI shows, never a relay handle.
@ -24,12 +25,17 @@ class OffersState extends Equatable {
this.blockedAuthors = const {},
this.hiddenOfferKeys = const {},
this.searching = false,
this.loadingMore = false,
this.nextPageCursor,
this.publishing = false,
this.hasSearched = false,
this.connectionEpoch = 0,
this.error,
});
/// Offers discovered so far for [areaGeohash], newest appended as they arrive.
/// Offers discovered so far for [areaGeohash], newest first. Bounded in size
/// (see [OffersCubit.maxOffersKept]) so a busy area can't grow the list — and
/// the inline photo thumbnails it carries without limit.
final List<Offer> offers;
/// The coarse area currently being browsed.
@ -57,32 +63,51 @@ class OffersState extends Equatable {
final Set<String> hiddenOfferKeys;
final bool searching;
/// True while a "load more" page is in flight, so the UI shows a footer
/// spinner and doesn't fire overlapping page requests.
final bool loadingMore;
/// Cursor (Unix seconds) for the next older page, or null when the newest
/// page was short (nothing older) or the in-memory cap was reached in both
/// cases there is no more to load.
final int? nextPageCursor;
final bool publishing;
/// Whether more offers can be paged in (drives the infinite-scroll trigger).
bool get canLoadMore => nextPageCursor != null;
/// True once a discovery has been started, so the UI can tell "no search yet"
/// from "searched, found nothing".
final bool hasSearched;
/// Bumped whenever the underlying transport comes or goes, so the UI rebuilds
/// and re-reads [OffersCubit.isOnline] states differing only here are
/// otherwise Equatable-equal and bloc would skip the emit.
final int connectionEpoch;
/// Last error, in human terms for the UI (null when fine).
final String? error;
/// [offers] narrowed by the text [query] and the chip filters (all ANDed).
/// Kept separate from [offers] so filtering never drops the discoveries.
List<Offer> get visibleOffers {
final q = query.trim().toLowerCase();
return offers.where((o) {
// Moderation state (block/hide) is app-side, not part of the search facets.
if (blockedAuthors.contains(o.authorPubkeyHex)) return false;
if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) {
return false;
}
if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
if (categoryFilter.isNotEmpty &&
(o.category == null || !categoryFilter.contains(o.category))) {
return false;
}
if (organicOnly && !o.isOrganic) return false;
return true;
// The query/facet chain is shared with saved-search alerts so the two
// never disagree on what "matches".
return SavedSearch.matchesFilters(
o,
query: query,
types: typeFilter,
categories: categoryFilter,
organicOnly: organicOnly,
);
}).toList();
}
@ -114,8 +139,11 @@ class OffersState extends Equatable {
Set<String>? blockedAuthors,
Set<String>? hiddenOfferKeys,
bool? searching,
bool? loadingMore,
int? Function()? nextPageCursor,
bool? publishing,
bool? hasSearched,
int? connectionEpoch,
String? Function()? error,
}) {
return OffersState(
@ -128,8 +156,12 @@ class OffersState extends Equatable {
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys,
searching: searching ?? this.searching,
loadingMore: loadingMore ?? this.loadingMore,
nextPageCursor:
nextPageCursor != null ? nextPageCursor() : this.nextPageCursor,
publishing: publishing ?? this.publishing,
hasSearched: hasSearched ?? this.hasSearched,
connectionEpoch: connectionEpoch ?? this.connectionEpoch,
error: error != null ? error() : this.error,
);
}
@ -145,8 +177,11 @@ class OffersState extends Equatable {
blockedAuthors,
hiddenOfferKeys,
searching,
loadingMore,
nextPageCursor,
publishing,
hasSearched,
connectionEpoch,
error,
];
}
@ -158,15 +193,40 @@ class OffersState extends Equatable {
class OffersCubit extends Cubit<OffersState> {
OffersCubit(
this._transport, {
SocialConnection? connection,
Future<Uint8List?> Function(String varietyId)? coverPhoto,
String? Function(Uint8List bytes)? thumbnail,
Future<void> Function()? onDispose,
}) : _coverPhoto = coverPhoto,
_thumbnail = thumbnail,
_onDispose = onDispose,
super(const OffersState());
super(const OffersState()) {
// Fresh-install fix: the transport captured at build time may be null while
// the shared connection is still coming up (or dropped). Follow the
// connection so the market recovers by itself instead of staying offline
// until a manual retry.
_connSub = connection?.sessions.listen(_onSession);
}
final OfferTransport? _transport;
OfferTransport? _transport;
StreamSubscription<SocialSession?>? _connSub;
/// The last area prefix asked of [discover]; re-run when the connection
/// (re)appears so results show without the user tapping anything.
String? _lastPrefix;
void _onSession(SocialSession? session) {
if (isClosed) return;
final transport = session?.offers;
if (identical(transport, _transport)) return;
_transport = transport;
final prefix = _lastPrefix;
if (transport != null && prefix != null) {
unawaited(discover(prefix)); // emits fresh states as results arrive
} else {
emit(state.copyWith(connectionEpoch: state.connectionEpoch + 1));
}
}
/// Fetches a variety's cover photo bytes; null in tests or when no inventory
/// repo is wired.
@ -182,11 +242,25 @@ class OffersCubit extends Cubit<OffersState> {
StreamSubscription<Offer>? _sub;
Timer? _searchTimeout;
/// Upper bound on offers held in memory. Each offer can carry an inline
/// (~40 KB) photo thumbnail, so an unbounded list in a busy area is a real
/// out-of-memory risk on mobile. Paging stops once the list reaches this, and
/// live offers beyond it evict the oldest the newest stay visible.
static const int maxOffersKept = 400;
/// Whether a live transport is available (relay configured and reachable).
bool get isOnline => _transport != null;
/// Starts (or restarts) discovery for [geohashPrefix]. Results stream in.
/// Current time in Unix seconds the granularity Nostr filters use for
/// `since`/`until`.
int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
/// Starts (or restarts) discovery for [geohashPrefix]. Fetches the newest page
/// up front (bounded), then keeps a live subscription for offers published
/// from now on older results arrive via [loadNextPage], not by draining the
/// whole area into memory.
Future<void> discover(String geohashPrefix) async {
_lastPrefix = geohashPrefix;
final transport = _transport;
if (transport == null) {
emit(state.copyWith(error: () => 'offline', hasSearched: true));
@ -206,15 +280,38 @@ class OffersCubit extends Cubit<OffersState> {
searching: true,
hasSearched: true,
));
_sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen(
(offer) =>
emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)),
onError: (Object e) =>
emit(state.copyWith(searching: false, error: () => '$e')),
final query = DiscoveryQuery(
geohashPrefix: geohashPrefix,
types: state.typeFilter,
);
// The discover stream stays open for live offers and never signals "done",
// so stop the spinner after a beat: no results show the empty state, not
// an endless "searching".
// Live subscription first, bounded to offers published from now on, so a new
// listing shows up immediately without re-dumping the whole area.
final since = _now();
_sub = transport.discover(query, since: since).listen(
(offer) => emit(state.copyWith(
offers: _capped(_prepend(state.offers, offer)),
searching: false,
)),
onError: (Object e) =>
emit(state.copyWith(searching: false, error: () => '$e')),
);
try {
final page = await transport.discoverPage(query);
if (!isClosed) {
emit(state.copyWith(
offers: _capped(_mergePage(state.offers, page.offers)),
nextPageCursor: () => page.nextCursor,
searching: false,
));
}
} catch (e) {
if (!isClosed) emit(state.copyWith(searching: false, error: () => '$e'));
}
// Safety net: if the first page hangs and no live offer arrives, still stop
// the spinner so the screen shows the empty state, not an endless search.
_searchTimeout = Timer(const Duration(seconds: 6), () {
if (!isClosed && state.searching) {
emit(state.copyWith(searching: false));
@ -222,18 +319,64 @@ class OffersCubit extends Cubit<OffersState> {
});
}
/// Appends [incoming] to [current], replacing any existing offer with the same
/// author + id. Relays legitimately resend addressable events (a stored copy
/// plus a live echo after publishing), so a plain append would double the
/// listing; keeping one entry per (author, id) is the NIP-99 semantics.
static List<Offer> _merge(List<Offer> current, Offer incoming) => [
for (final o in current)
if (!(o.id == incoming.id &&
o.authorPubkeyHex == incoming.authorPubkeyHex))
o,
/// Loads the next (older) page of offers for the current area. Called by the
/// list as it nears the end. No-op when offline, already loading, or there is
/// nothing older to fetch.
Future<void> loadNextPage() async {
final transport = _transport;
final cursor = state.nextPageCursor;
if (transport == null || cursor == null || state.loadingMore) return;
emit(state.copyWith(loadingMore: true));
try {
final page = await transport.discoverPage(DiscoveryQuery(
geohashPrefix: state.areaGeohash,
types: state.typeFilter,
until: cursor,
));
final merged = _mergePage(state.offers, page.offers);
// Stop paging once the cap is reached the list is already as large as we
// keep otherwise carry the transport's cursor onward.
final reachedCap = merged.length >= maxOffersKept;
emit(state.copyWith(
offers: _capped(merged),
nextPageCursor: () => reachedCap ? null : page.nextCursor,
loadingMore: false,
));
} catch (e) {
emit(state.copyWith(loadingMore: false, error: () => '$e'));
}
}
/// The offer's identity for de-duplication: NIP-99 addressable events are keyed
/// by (author, `d`-tag id), so the same listing from two relays or a stored
/// copy plus a live echo collapses to one entry.
static String _key(Offer o) => '${o.authorPubkeyHex}:${o.id}';
/// Prepends a freshly-arrived (newer) [incoming] offer, dropping any existing
/// entry with the same identity so a live echo never doubles the listing.
static List<Offer> _prepend(List<Offer> current, Offer incoming) => [
incoming,
for (final o in current)
if (_key(o) != _key(incoming)) o,
];
/// Appends an older [page] after [current] (older offers sort below newer),
/// skipping any already present. Keeps the newest-first ordering.
static List<Offer> _mergePage(List<Offer> current, List<Offer> page) {
final seen = {for (final o in current) _key(o)};
return [
...current,
for (final o in page)
if (seen.add(_key(o))) o,
];
}
/// Caps the list to [maxOffersKept], keeping the newest (front) and dropping
/// the oldest (tail) bounds memory in a busy area.
static List<Offer> _capped(List<Offer> offers) => offers.length <= maxOffersKept
? offers
: offers.sublist(0, maxOffersKept);
/// Narrows the visible offers to those whose summary matches [query]. Purely
/// local over the already-discovered list; does not re-hit the transport.
void search(String query) => emit(state.copyWith(query: query));
@ -266,6 +409,16 @@ class OffersCubit extends Cubit<OffersState> {
void setHiddenOffers(Set<String> offerKeys) =>
emit(state.copyWith(hiddenOfferKeys: offerKeys));
/// Applies a saved search's query and facets to the current discoveries.
/// Purely local (does not re-hit the transport) the area is unchanged, so
/// the same discovered offers are simply re-narrowed by the saved filters.
void applySavedSearch(SavedSearch search) => emit(state.copyWith(
query: search.query,
typeFilter: search.types,
categoryFilter: search.categories,
organicOnly: search.organicOnly,
));
/// Clears every chip filter (leaves the text search untouched).
void clearFilters() => emit(state.copyWith(
typeFilter: const {},
@ -350,6 +503,7 @@ class OffersCubit extends Cubit<OffersState> {
@override
Future<void> close() async {
_searchTimeout?.cancel();
await _connSub?.cancel();
await _sub?.cancel();
await _onDispose?.call();
return super.close();
@ -367,6 +521,7 @@ Future<OffersCubit> createOffersCubit(
final session = await connection.session();
return OffersCubit(
session?.offers,
connection: connection, // keeps following (re)connects see _onSession
coverPhoto: repository?.coverPhotoFor,
thumbnail: offerThumbnailDataUri,
);

View file

@ -3,24 +3,66 @@ import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
import '../services/onboarding_store.dart';
import '../services/sharing_switch.dart';
import 'seed_glyph.dart';
import 'sharing_invite_sheet.dart';
import 'theme.dart';
import 'unread_badge.dart';
/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is
/// the live destination (green seed glyph), the social items (market, profile,
/// chat) belong to Block 2 and are greyed with a "soon" tag, and Settings sits
/// pinned at the bottom.
/// the live destination (green seed glyph), the social items sit below a divider,
/// and Settings is pinned at the bottom.
///
/// While sharing is off the social items stay visible but quiet, with a small
/// padlock: tapping one explains what it does and offers to join. The Market is
/// the exception it is always live, because it is the door people go through
/// to join in the first place.
class AppDrawer extends StatelessWidget {
const AppDrawer({this.marketEnabled = false, super.key});
const AppDrawer({this.sharing, this.onboarding, super.key});
/// When the Block 2 social layer is wired, the Market becomes a live drawer
/// destination; other social items stay "soon" until they're built.
final bool marketEnabled;
/// The sharing on/off switch. Null when the social layer isn't there at all
/// (identity derivation failed) then the social items are not drawn, since
/// there is nothing the person could do to turn them on.
final SharingSwitch? sharing;
/// Needed to run the community-rules step when someone accepts the invite.
final OnboardingStore? onboarding;
@override
Widget build(BuildContext context) {
final sharing = this.sharing;
if (sharing == null) return _build(context, social: false, sharingOn: false);
return ValueListenableBuilder<bool>(
valueListenable: sharing.on,
builder: (context, on, _) =>
_build(context, social: true, sharingOn: on),
);
}
Widget _build(
BuildContext context, {
required bool social,
required bool sharingOn,
}) {
final t = context.t;
// While sharing is off, tapping a social entry invites the person in rather
// than doing nothing.
Future<void> invite() async {
final sharing = this.sharing;
final onboarding = this.onboarding;
if (sharing == null || onboarding == null) return;
// Close the drawer first, then run the sheet off the navigator's own
// context this one dies with the drawer.
final navigator = Navigator.of(context);
navigator.pop();
await showSharingInvite(
navigator.context,
onboarding: onboarding,
sharing: sharing,
);
}
return Drawer(
child: SafeArea(
child: Column(
@ -69,59 +111,68 @@ class AppDrawer extends StatelessWidget {
context.push('/calendar');
},
),
_DrawerItem(
icon: const Icon(Symbols.storefront),
label: t.menu.market,
divider: true,
onTap: marketEnabled
? () {
Navigator.of(context).pop();
context.push('/market');
}
: null,
),
_DrawerItem(
icon: const Icon(Icons.person),
label: t.menu.profile,
onTap: marketEnabled
? () {
Navigator.of(context).pop();
context.push('/profile');
}
: null,
),
_DrawerItem(
icon: const UnreadBadge(child: Icon(Icons.chat_bubble)),
label: t.menu.chat,
onTap: marketEnabled
? () {
Navigator.of(context).pop();
context.push('/messages');
}
: null,
),
_DrawerItem(
icon: const Icon(Icons.favorite),
label: t.menu.wishlist,
onTap: marketEnabled
? () {
Navigator.of(context).pop();
context.push('/favorites');
}
: null,
),
// The ego-centric web of trust ("your people") live once the
// social layer is on.
_DrawerItem(
icon: const Icon(Icons.group),
label: t.menu.following,
onTap: marketEnabled
? () {
Navigator.of(context).pop();
context.push('/your-people');
}
: null,
),
// The market is always live when the social layer exists: it
// is where people join sharing, so locking it would lock the
// only door.
if (social)
_DrawerItem(
icon: const Icon(Symbols.storefront),
label: t.menu.market,
divider: true,
onTap: () {
Navigator.of(context).pop();
context.push('/market');
},
),
if (social)
_DrawerItem(
icon: const Icon(Icons.person),
label: t.menu.profile,
locked: !sharingOn,
onTap: sharingOn
? () {
Navigator.of(context).pop();
context.push('/profile');
}
: invite,
),
if (social)
_DrawerItem(
icon: const UnreadBadge(child: Icon(Icons.chat_bubble)),
label: t.menu.chat,
locked: !sharingOn,
onTap: sharingOn
? () {
Navigator.of(context).pop();
context.push('/messages');
}
: invite,
),
if (social)
_DrawerItem(
icon: const Icon(Icons.favorite),
label: t.menu.wishlist,
locked: !sharingOn,
onTap: sharingOn
? () {
Navigator.of(context).pop();
context.push('/favorites');
}
: invite,
),
// The ego-centric web of trust ("your people").
if (social)
_DrawerItem(
icon: const Icon(Icons.group),
label: t.menu.following,
locked: !sharingOn,
onTap: sharingOn
? () {
Navigator.of(context).pop();
context.push('/your-people');
}
: invite,
),
],
),
),
@ -203,6 +254,7 @@ class _DrawerItem extends StatelessWidget {
required this.label,
this.onTap,
this.divider = false,
this.locked = false,
});
final Widget icon;
@ -210,9 +262,13 @@ class _DrawerItem extends StatelessWidget {
final VoidCallback? onTap;
final bool divider;
/// Drawn quiet, with a padlock, but still tappable it leads to the
/// invitation to join sharing rather than to the destination itself.
final bool locked;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final enabled = onTap != null && !locked;
final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F);
final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F);
final row = InkWell(
@ -247,15 +303,11 @@ class _DrawerItem extends StatelessWidget {
),
),
),
if (!enabled)
Text(
context.t.common.comingSoon.toUpperCase(),
style: const TextStyle(
color: Color(0xFFB3BDA8),
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
if (locked)
const Icon(
Icons.lock_outline,
size: 16,
color: Color(0xFFB3BDA8),
),
],
),

View file

@ -235,8 +235,14 @@ class _CalendarRow extends StatelessWidget {
leading: CircleAvatar(
radius: 20,
backgroundColor: swatch.fill,
foregroundImage:
entry.photo == null ? null : MemoryImage(entry.photo!),
// Decode to the 40px avatar size (× dpr), not the full photo.
foregroundImage: entry.photo == null
? null
: ResizeImage(
MemoryImage(entry.photo!),
width: (40 * MediaQuery.devicePixelRatioOf(context)).round(),
height: (40 * MediaQuery.devicePixelRatioOf(context)).round(),
),
child: entry.photo == null
? Text(
entry.label.isEmpty

View file

@ -108,6 +108,10 @@ class _DraftTile extends StatelessWidget {
photo,
width: 48,
height: 48,
cacheWidth:
(48 * MediaQuery.devicePixelRatioOf(context)).round(),
cacheHeight:
(48 * MediaQuery.devicePixelRatioOf(context)).round(),
fit: BoxFit.cover,
),
),
@ -192,6 +196,8 @@ class _NameDraftDialogState extends State<NameDraftDialog> {
child: Image.memory(
widget.photo!,
height: 140,
cacheHeight:
(140 * MediaQuery.devicePixelRatioOf(context)).round(),
fit: BoxFit.cover,
),
),

View file

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart';
import '../services/onboarding_store.dart';
import '../services/sharing_switch.dart';
import 'app_drawer.dart';
import 'seed_glyph.dart';
import 'theme.dart';
@ -9,14 +11,19 @@ import 'unread_badge.dart';
/// The main menu (redesign screen 00): a sprout logo in a soft green disc over a
/// faint seed-glyph watermark, with "Your inventory" as the primary green call
/// to action and "Open market" (Block 2) as a disabled outlined card. The
/// hamburger opens [AppDrawer].
/// to action and "Open market" as an outlined card below it. The hamburger opens
/// [AppDrawer].
///
/// The market card is always live when the social layer exists it is the door
/// people go through to join sharing and simply isn't drawn when it doesn't.
class HomeScreen extends StatelessWidget {
const HomeScreen({this.marketEnabled = false, super.key});
const HomeScreen({this.sharing, this.onboarding, super.key});
/// When the Block 2 social layer is wired, the market becomes a live
/// destination; otherwise it stays a disabled "coming soon" card.
final bool marketEnabled;
/// The sharing on/off switch, or null when there is no social layer at all.
final SharingSwitch? sharing;
/// Needed to run the community-rules step from the drawer's invitation.
final OnboardingStore? onboarding;
@override
Widget build(BuildContext context) {
@ -34,7 +41,7 @@ class HomeScreen extends StatelessWidget {
),
),
),
drawer: AppDrawer(marketEnabled: marketEnabled),
drawer: AppDrawer(sharing: sharing, onboarding: onboarding),
body: Stack(
children: [
const Positioned.fill(child: _SeedWatermark()),
@ -99,17 +106,16 @@ class HomeScreen extends StatelessWidget {
subtitle: t.home.yourInventorySubtitle,
onTap: () => context.push('/inventory'),
),
const SizedBox(height: 16),
_OutlinedMenuCard(
key: const Key('home.market'),
icon: Icons.storefront_outlined,
label: t.home.openMarket,
subtitle: t.home.openMarketSubtitle,
tag: marketEnabled ? null : t.common.comingSoon,
onTap: marketEnabled
? () => context.push('/market')
: null,
),
if (sharing != null) ...[
const SizedBox(height: 16),
_OutlinedMenuCard(
key: const Key('home.market'),
icon: Icons.storefront_outlined,
label: t.home.openMarket,
subtitle: t.home.openMarketSubtitle,
onTap: () => context.push('/market'),
),
],
],
),
),
@ -175,14 +181,12 @@ class _PrimaryMenuCard extends StatelessWidget {
}
}
/// A disabled Block-2 destination: an outlined white card with a green-disc
/// icon and a "soon" tag.
/// A secondary destination: an outlined white card with a green-disc icon.
class _OutlinedMenuCard extends StatelessWidget {
const _OutlinedMenuCard({
required this.icon,
required this.label,
required this.subtitle,
this.tag,
this.onTap,
super.key,
});
@ -191,9 +195,6 @@ class _OutlinedMenuCard extends StatelessWidget {
final String label;
final String subtitle;
/// A small trailing tag (e.g. "coming soon"); omitted for live cards.
final String? tag;
/// When set, the card is tappable; otherwise it reads as disabled.
final VoidCallback? onTap;
@ -221,17 +222,7 @@ class _OutlinedMenuCard extends StatelessWidget {
subtitleColor: seedMuted,
),
),
if (tag != null)
Text(
tag!.toUpperCase(),
style: const TextStyle(
color: Color(0xFFB3BDA8),
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
)
else if (onTap != null)
if (onTap != null)
const Icon(Icons.chevron_right, color: seedMuted),
],
),

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../di/injector.dart';
@ -14,6 +15,7 @@ import 'draft_triage.dart';
import 'edge_fade.dart';
import 'filter_chips.dart';
import 'label_print_sheet.dart';
import 'qr_scan.dart';
import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart';
import 'quick_add_sheet.dart';
@ -133,6 +135,15 @@ class InventoryListScreen extends StatelessWidget {
tooltip: t.printLabels.action,
onPressed: context.read<InventoryCubit>().startSelection,
),
// Scan a printed seed label back into its record the physical
// envelope re-finds its digital thread. Mobile-only (camera).
if (qrScanSupported)
IconButton(
key: const Key('inventory.scanLabel'),
icon: const Icon(Icons.qr_code_scanner),
tooltip: t.scan.action,
onPressed: () => _scanLabel(context),
),
IconButton(
key: const Key('inventory.captureBurst'),
icon: const Icon(Icons.add_a_photo_outlined),
@ -233,6 +244,20 @@ class InventoryListScreen extends StatelessWidget {
return parts.isEmpty ? null : parts.join(' · ');
}
Future<void> _scanLabel(BuildContext context) async {
final repository = context.read<VarietyRepository>();
final species = context.read<SpeciesRepository>();
final payload = await scanSeedLabelQr(context);
if (payload == null || !context.mounted) return;
await handleScannedPayload(
context,
repository: repository,
species: species,
payload: payload,
openVariety: (id) => context.push('/variety/$id'),
);
}
Future<void> _captureBurst(BuildContext context) async {
final t = context.t;
final repository = context.read<VarietyRepository>();
@ -557,27 +582,47 @@ class _InventoryBody extends StatelessWidget {
}
// Items arrive ordered by category then label; insert a header whenever the
// category changes.
final rows = <Widget>[];
// category changes. Precompute a flat row model (header or item) so the
// ListView can build tiles lazily with a large inventory, building every
// tile upfront (the old `ListView(children:)`) stalled the first paint.
final rows = <_InventoryRow>[];
String? currentCategory;
for (final item in items) {
final category = item.category ?? t.inventory.uncategorized;
if (category != currentCategory) {
currentCategory = category;
rows.add(_CategoryHeader(title: category));
rows.add(_InventoryRow.header(category));
}
rows.add(
_VarietyTile(
item: item,
selectionMode: selectionMode,
selected: selectedIds.contains(item.id),
),
);
rows.add(_InventoryRow.variety(item));
}
return ListView(children: rows);
return ListView.builder(
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
final header = row.header;
if (header != null) {
return _CategoryHeader(title: header);
}
return _VarietyTile(
item: row.item!,
selectionMode: selectionMode,
selected: selectedIds.contains(row.item!.id),
);
},
);
}
}
/// A single row in the flattened inventory list: either a category [header] or
/// a variety [item] (exactly one is non-null).
class _InventoryRow {
const _InventoryRow.header(this.header) : item = null;
const _InventoryRow.variety(this.item) : header = null;
final String? header;
final VarietyListItem? item;
}
class _CategoryHeader extends StatelessWidget {
const _CategoryHeader({required this.title});
@ -742,10 +787,21 @@ class _Avatar extends StatelessWidget {
// Decorative: the tile title already announces the variety name, so keep
// the thumbnail / initial out of the semantics tree.
if (photo != null) {
// Decode straight to the 48px avatar size (× device pixel ratio) instead
// of holding the full-resolution photo in the image cache decisive for
// memory with a large inventory of photographed varieties.
final cachePx = (48 * MediaQuery.devicePixelRatioOf(context)).round();
return ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover),
child: Image.memory(
photo,
width: 48,
height: 48,
cacheWidth: cachePx,
cacheHeight: cachePx,
fit: BoxFit.cover,
),
),
);
}

View file

@ -0,0 +1,401 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../domain/seed_saving.dart';
import '../i18n/strings.g.dart';
import 'quantity_kind_l10n.dart';
/// Opens the batch's story — a read-only timeline of everything already
/// recorded about it (movements, germination tests, storage checks, and how it
/// entered the collection), plus two one-tap chips to note "sown today" /
/// "harvested today". The happy path is literally one tap; there is no form.
Future<void> showLotHistorySheet(
BuildContext context, {
required VarietyRepository repository,
required VarietyLot lot,
SeedSavingGuide? guide,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) =>
_LotHistorySheet(repository: repository, lot: lot, guide: guide),
);
}
class _LotHistorySheet extends StatefulWidget {
const _LotHistorySheet({
required this.repository,
required this.lot,
this.guide,
});
final VarietyRepository repository;
final VarietyLot lot;
/// Bundled species guidance, or null. Only read to drop the one-line
/// isolation hint when a cross-pollinating crop is sown selfers get
/// nothing (the fields people record religiously where they don't matter).
final SeedSavingGuide? guide;
@override
State<_LotHistorySheet> createState() => _LotHistorySheetState();
}
class _LotHistorySheetState extends State<_LotHistorySheet> {
// One-shot future (data-model rule: a transient sheet must never hold a
// live subscription). Re-assigned after a quick record to refresh the list.
late Future<List<LotHistoryEntry>> _history;
bool _saving = false;
// The optional, skippable "how did it do?" asked right after a harvest is
// noted the only moment it appears. Dismissed, it never nags again.
bool _askOutcome = false;
final _outcomeNote = TextEditingController();
// One passive line after sowing a crosser ("this species crosses — isolate
// ~400 m"). Never shown for selfers, never an input.
bool _showSowingHint = false;
@override
void initState() {
super.initState();
_history = widget.repository.lotHistory(widget.lot.id);
}
@override
void dispose() {
_outcomeNote.dispose();
super.dispose();
}
Future<void> _record(MovementType type) async {
if (_saving) return;
setState(() => _saving = true);
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
await widget.repository.recordCultivation(
lotId: widget.lot.id,
type: type,
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
type == MovementType.sown
? t.history.sownRecorded
: t.history.harvestRecorded,
),
),
);
final guide = widget.guide;
setState(() {
_saving = false;
_askOutcome = type == MovementType.harvested;
_showSowingHint =
type == MovementType.sown &&
guide?.pollination == Pollination.cross &&
guide?.isolationMinM != null;
_history = widget.repository.lotHistory(widget.lot.id);
});
}
Future<void> _saveOutcome(GardenOutcomeRating rating) async {
if (_saving) return;
setState(() => _saving = true);
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final note = _outcomeNote.text.trim();
await widget.repository.addGardenOutcome(
lotId: widget.lot.id,
year: DateTime.now().year,
rating: rating,
notes: note.isEmpty ? null : note,
);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(t.history.outcomeSaved)));
_outcomeNote.clear();
setState(() {
_saving = false;
_askOutcome = false;
_history = widget.repository.lotHistory(widget.lot.id);
});
}
@override
Widget build(BuildContext context) {
final t = context.t;
return SafeArea(
child: Padding(
// Bottom inset keeps the optional note field above the keyboard.
padding: EdgeInsets.fromLTRB(
16,
16,
16,
24 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.history.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
if (widget.lot.type == LotType.seed)
ActionChip(
key: const Key('history.sowToday'),
avatar: const Icon(Icons.grass_outlined, size: 18),
label: Text(t.history.sowToday),
onPressed: _saving
? null
: () => _record(MovementType.sown),
),
ActionChip(
key: const Key('history.harvestToday'),
avatar: const Icon(Icons.agriculture_outlined, size: 18),
label: Text(t.history.harvestToday),
onPressed: _saving
? null
: () => _record(MovementType.harvested),
),
],
),
if (_showSowingHint)
Padding(
key: const Key('history.isolationHint'),
padding: const EdgeInsetsDirectional.only(top: 8, start: 4),
child: Row(
children: [
const Icon(Icons.lightbulb_outline, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
context.t.history.isolationHint(
meters: widget.guide!.isolationMinM!,
),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
if (_askOutcome) ...[
const SizedBox(height: 8),
_OutcomeQuestion(
note: _outcomeNote,
saving: _saving,
onRate: _saveOutcome,
onDismiss: () => setState(() => _askOutcome = false),
),
],
const SizedBox(height: 8),
Flexible(
child: FutureBuilder<List<LotHistoryEntry>>(
future: _history,
builder: (context, snapshot) {
final entries = snapshot.data;
if (entries == null) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: entries.length,
itemBuilder: (context, i) =>
_HistoryTile(entry: entries[i]),
);
},
),
),
],
),
),
);
}
}
/// The one skippable question of the whole flow: three faces and an optional
/// note. Tapping a face saves; the X dismisses without a trace.
class _OutcomeQuestion extends StatelessWidget {
const _OutcomeQuestion({
required this.note,
required this.saving,
required this.onRate,
required this.onDismiss,
});
final TextEditingController note;
final bool saving;
final ValueChanged<GardenOutcomeRating> onRate;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final t = context.t;
return Card(
key: const Key('history.outcomeQuestion'),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 4, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
t.history.outcomeQuestion,
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
key: const Key('history.outcomeDismiss'),
icon: const Icon(Icons.close, size: 18),
onPressed: onDismiss,
),
],
),
Wrap(
spacing: 8,
children: [
ActionChip(
key: const Key('history.outcomeGood'),
avatar: const Icon(Icons.sentiment_satisfied_alt, size: 18),
label: Text(t.history.outcomeGood),
onPressed: saving
? null
: () => onRate(GardenOutcomeRating.good),
),
ActionChip(
key: const Key('history.outcomeMixed'),
avatar: const Icon(Icons.sentiment_neutral, size: 18),
label: Text(t.history.outcomeMixed),
onPressed: saving
? null
: () => onRate(GardenOutcomeRating.mixed),
),
ActionChip(
key: const Key('history.outcomePoor'),
avatar: const Icon(Icons.sentiment_dissatisfied, size: 18),
label: Text(t.history.outcomePoor),
onPressed: saving
? null
: () => onRate(GardenOutcomeRating.poor),
),
],
),
TextField(
key: const Key('history.outcomeNote'),
controller: note,
decoration: InputDecoration(
hintText: t.history.outcomeNoteHint,
isDense: true,
border: InputBorder.none,
),
),
],
),
),
);
}
}
class _HistoryTile extends StatelessWidget {
const _HistoryTile({required this.entry});
final LotHistoryEntry entry;
IconData get _icon => switch (entry.kind) {
LotHistoryKind.created => Icons.spa_outlined,
LotHistoryKind.germinationTest => Icons.science_outlined,
LotHistoryKind.conditionCheck => Icons.inventory_2_outlined,
LotHistoryKind.gardenOutcome => switch (entry.rating) {
GardenOutcomeRating.good => Icons.sentiment_satisfied_alt,
GardenOutcomeRating.mixed => Icons.sentiment_neutral,
GardenOutcomeRating.poor => Icons.sentiment_dissatisfied,
null => Icons.edit_note,
},
LotHistoryKind.movement => switch (entry.movementType!) {
MovementType.received => Icons.call_received,
MovementType.given => Icons.redeem_outlined,
MovementType.sown => Icons.grass_outlined,
MovementType.harvested => Icons.agriculture_outlined,
MovementType.germinationTest => Icons.science_outlined,
MovementType.split => Icons.call_split,
MovementType.discarded => Icons.delete_outline,
},
};
String _title(Translations t) => switch (entry.kind) {
LotHistoryKind.created => t.history.created,
LotHistoryKind.conditionCheck => t.conditionCheck.title,
LotHistoryKind.gardenOutcome => switch (entry.rating) {
GardenOutcomeRating.good => t.history.ratedGood,
GardenOutcomeRating.mixed => t.history.ratedMixed,
GardenOutcomeRating.poor => t.history.ratedPoor,
null => t.history.outcomeTitle,
},
LotHistoryKind.germinationTest => entry.germinationRate == null
? t.germination.title
: t.history.germinationResult(
percent: (entry.germinationRate! * 100).round(),
),
LotHistoryKind.movement => switch (entry.movementType!) {
MovementType.received => t.history.movementReceived,
MovementType.given => t.history.movementGiven,
MovementType.sown => t.history.movementSown,
MovementType.harvested => t.history.movementHarvested,
MovementType.germinationTest => t.history.movementGerminationTest,
MovementType.split => t.history.movementSplit,
MovementType.discarded => t.history.movementDiscarded,
},
};
@override
Widget build(BuildContext context) {
final t = context.t;
final theme = Theme.of(context);
final when = entry.when;
final date = when == null
? null
// Format via the Localizations locale, not the raw app locale (see
// backup_section.dart: `ast` has no intl symbols, mapped to `es`).
: DateFormat.yMMMd(
Localizations.localeOf(context).languageCode,
).format(DateTime.fromMillisecondsSinceEpoch(when));
final origin = [
entry.originName,
entry.originPlace,
].whereType<String>().where((s) => s.trim().isNotEmpty).join(' · ');
final details = [
if (entry.kind == LotHistoryKind.created && origin.isNotEmpty)
t.history.from(origin: origin),
if (entry.year case final year?) t.history.outcomeYear(year: year),
if (entry.quantity != null) quantityDisplay(t, entry.quantity!),
?entry.counterpartyName,
if (entry.hasParentLink) t.history.linkedEarlier,
if (entry.notes case final n? when n.trim().isNotEmpty) n.trim(),
].join('\n');
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(_icon, size: 20, color: theme.colorScheme.primary),
title: Text(_title(t)),
subtitle: details.isEmpty ? null : Text(details),
trailing: date == null
? null
: Text(date, style: theme.textTheme.bodySmall),
);
}
}

View file

@ -3,17 +3,29 @@ import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart';
import '../services/onboarding_store.dart';
import '../services/sharing_switch.dart';
import 'theme.dart';
/// Makes sure the community rules have been accepted once before the user
/// joins the market or publishes anything. Returns true when the rules are
/// (or become) accepted; false when the user declines. Play/App Store UGC
/// policies require this acceptance before content can be created.
///
/// This is also where Tane goes online for the first time: agreeing here is the
/// opt-in that turns [sharing] on. Keeping both in one step means there is a
/// single moment where someone says yes, and it is a moment that explains
/// itself rather than a connection that happened at launch without asking.
Future<bool> ensureMarketRulesAccepted(
BuildContext context,
OnboardingStore store,
) async {
if (await store.marketRulesAccepted()) return true;
OnboardingStore store, {
SharingSwitch? sharing,
}) async {
if (await store.marketRulesAccepted()) {
// Already agreed, but sharing may still be off (they turned it off in the
// sharing setup, or agreed on a build that had no switch): honour the ask.
if (sharing != null && !sharing.on.value) await sharing.enable();
return true;
}
if (!context.mounted) return false;
final accepted = await showModalBottomSheet<bool>(
context: context,
@ -24,6 +36,7 @@ Future<bool> ensureMarketRulesAccepted(
);
if (accepted == true) {
await store.markMarketRulesAccepted();
await sharing?.enable();
return true;
}
return false;
@ -77,6 +90,14 @@ class MarketGateSheet extends StatelessWidget {
height: 1.4,
),
),
const SizedBox(height: 8),
Text(
t.marketGate.networkNote,
style: theme.textTheme.bodySmall?.copyWith(
color: seedMuted,
height: 1.4,
),
),
TextButton.icon(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,

View file

@ -8,8 +8,10 @@ import '../i18n/strings.g.dart';
import '../services/coarse_location.dart';
import '../services/discovery_area.dart';
import '../services/offer_outbox.dart';
import '../services/saved_searches_store.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
import '../services/sharing_switch.dart';
import '../services/social_settings.dart';
import '../services/onboarding_store.dart';
import '../state/offers_cubit.dart';
@ -18,6 +20,7 @@ import 'edge_fade.dart';
import 'filter_chips.dart';
import 'market_gate.dart';
import 'market_widgets.dart';
import 'saved_searches_screen.dart';
import 'theme.dart';
/// The market (redesign screens 04/05): discover seeds shared near you. Opens
@ -31,17 +34,32 @@ class MarketScreen extends StatefulWidget {
this.location,
this.outbox,
this.onboarding,
this.savedSearches,
this.initialSearch,
this.sharing,
super.key,
});
final SocialService social;
final SocialSettings settings;
/// Optional store of the user's saved searches (with alerts). Null in tests /
/// inventory-only the save affordance and the saved-searches action hide.
final SavedSearchesStore? savedSearches;
/// When set (arriving from the saved-searches list), the market opens with
/// this search's query and facets already applied.
final SavedSearch? initialSearch;
/// When set, joining the market is gated on a one-time acceptance of the
/// community rules (store UGC policies require it before content is
/// created). Null in tests no gate.
final OnboardingStore? onboarding;
/// The sharing on/off switch. Accepting the rules turns it on (that is the
/// moment Tane first goes online); the sharing setup can turn it back off.
final SharingSwitch? sharing;
/// The shared relay connection (one per identity), reused for discovery.
final SocialConnection connection;
@ -72,11 +90,18 @@ class _MarketScreenState extends State<MarketScreen> {
/// network; declining leaves the market.
Future<void> _start() async {
final store = widget.onboarding;
if (store != null && !await store.marketRulesAccepted()) {
final sharing = widget.sharing;
// The rules step is also the moment Tane first goes online, so it runs
// whenever sharing is still off even for someone who agreed long ago and
// later switched sharing back off.
if (store != null &&
(!await store.marketRulesAccepted() ||
(sharing != null && !sharing.on.value))) {
// Wait for the first frame so the sheet has a surface to attach to.
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
final ok = await ensureMarketRulesAccepted(context, store);
final ok = await ensureMarketRulesAccepted(context, store,
sharing: sharing);
if (!ok) {
if (mounted) context.pop();
return;
@ -88,11 +113,11 @@ class _MarketScreenState extends State<MarketScreen> {
Future<void> _init() async {
// Only needed to flush the outbox; read here (while mounted) to avoid using
// context across the awaits below. Null when there's no outbox (e.g. tests).
final repo =
widget.outbox != null ? context.read<VarietyRepository>() : null;
final repo = widget.outbox != null
? context.read<VarietyRepository>()
: null;
setState(() => _loading = true);
final cubit =
await createOffersCubit(widget.connection, repository: repo);
final cubit = await createOffersCubit(widget.connection, repository: repo);
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
final area = await widget.settings.areaGeohash();
@ -100,6 +125,9 @@ class _MarketScreenState extends State<MarketScreen> {
await cubit.close();
return;
}
// Arriving from the saved-searches list: pre-apply its query and facets.
final initial = widget.initialSearch;
if (initial != null) cubit.applySavedSearch(initial);
final previous = _cubit;
setState(() {
_cubit = cubit;
@ -107,10 +135,10 @@ class _MarketScreenState extends State<MarketScreen> {
_loading = false;
});
await previous?.close();
if (area.isNotEmpty && cubit.isOnline) {
if (area.isNotEmpty) {
// Flush anything parked while offline, then show what's out there.
final outbox = widget.outbox;
if (outbox != null && repo != null) {
if (outbox != null && repo != null && cubit.isOnline) {
await flushOutbox(
outbox: outbox,
cubit: cubit,
@ -121,6 +149,8 @@ class _MarketScreenState extends State<MarketScreen> {
}
if (mounted) {
final precision = await widget.settings.searchPrecision();
// Even when offline: this records the wanted area on the cubit, which
// re-runs the discovery by itself once the connection comes up.
if (mounted) await cubit.discover(searchPrefix(area, precision));
}
}
@ -139,8 +169,11 @@ class _MarketScreenState extends State<MarketScreen> {
final changed = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) =>
_ConfigSheet(settings: widget.settings, location: widget.location),
builder: (_) => _ConfigSheet(
settings: widget.settings,
location: widget.location,
sharing: widget.sharing,
),
);
if (changed == true) await _init();
}
@ -191,12 +224,18 @@ class _MarketScreenState extends State<MarketScreen> {
await widget.outbox?.remove(ids); // published now, so unpark any duplicates
// count == 0 with lots to share means every relay refused say so plainly
// rather than "shared 0", which reads like success.
messenger.showSnackBar(SnackBar(
content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)),
));
messenger.showSnackBar(
SnackBar(
content: Text(
count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count),
),
),
);
final precision = await widget.settings.searchPrecision();
if (!mounted) return;
await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared
await cubit.discover(
searchPrefix(area, precision),
); // refresh incl. just-shared
}
@override
@ -213,6 +252,8 @@ class _MarketScreenState extends State<MarketScreen> {
appBar: AppBar(
title: Text(t.market.title),
actions: [
if (widget.savedSearches != null)
SavedSearchesAction(store: widget.savedSearches!),
IconButton(
key: const Key('market.config'),
icon: const Icon(Icons.tune),
@ -223,13 +264,13 @@ class _MarketScreenState extends State<MarketScreen> {
),
floatingActionButton:
cubit != null && (cubit.isOnline || widget.outbox != null)
? FloatingActionButton.extended(
key: const Key('market.shareMine'),
onPressed: _shareMine,
icon: const Icon(Icons.campaign_outlined),
label: Text(t.market.shareMine),
)
: null,
? FloatingActionButton.extended(
key: const Key('market.shareMine'),
onPressed: _shareMine,
icon: const Icon(Icons.campaign_outlined),
label: Text(t.market.shareMine),
)
: null,
body: _loading || cubit == null
? const Center(child: CircularProgressIndicator())
: BlocProvider.value(
@ -240,6 +281,7 @@ class _MarketScreenState extends State<MarketScreen> {
hasArea: _hasArea,
selfPubkey: widget.social.publicKeyHex,
onOfferClosed: _reloadBlocked,
savedSearches: widget.savedSearches,
),
),
);
@ -253,11 +295,15 @@ class MarketBody extends StatelessWidget {
this.hasArea = true,
this.selfPubkey,
this.onOfferClosed,
this.savedSearches,
super.key,
});
final VoidCallback onConfigure;
/// Store for the "save this search" affordance; null the button hides.
final SavedSearchesStore? savedSearches;
/// Re-opens the connection (for the "can't reach servers" retry).
final VoidCallback? onRetry;
@ -276,6 +322,17 @@ class MarketBody extends StatelessWidget {
final t = context.t;
return BlocBuilder<OffersCubit, OffersState>(
builder: (context, state) {
// A new user's first step is setting a zone — which works offline — so
// that prompt wins over the connection error.
if (!hasArea) {
return _EmptyState(
icon: Icons.place_outlined,
title: t.market.setArea,
body: t.market.setAreaBody,
actionLabel: t.market.setUp,
onAction: onConfigure,
);
}
if (!context.read<OffersCubit>().isOnline) {
// Default community servers exist, so "offline" means unreachable
// offer a retry rather than "set up sharing".
@ -287,15 +344,6 @@ class MarketBody extends StatelessWidget {
onAction: onRetry,
);
}
if (!hasArea) {
return _EmptyState(
icon: Icons.place_outlined,
title: t.market.setArea,
body: t.market.setAreaBody,
actionLabel: t.market.setUp,
onAction: onConfigure,
);
}
if (state.searching && state.offers.isEmpty) {
return Center(
child: Column(
@ -303,8 +351,10 @@ class MarketBody extends StatelessWidget {
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(t.market.searching,
style: const TextStyle(color: seedMuted)),
Text(
t.market.searching,
style: const TextStyle(color: seedMuted),
),
],
),
);
@ -346,6 +396,26 @@ class MarketBody extends StatelessWidget {
decoration: InputDecoration(
hintText: t.market.searchHint,
prefixIcon: const Icon(Icons.search, color: seedMuted),
// Offer to save the current search once it's worth alerting on
// (some text typed or a chip active), never for the bare list.
suffixIcon:
savedSearches != null &&
(state.query.trim().isNotEmpty ||
state.hasActiveFilter)
? IconButton(
key: const Key('market.saveSearch'),
icon: const Icon(
Icons.bookmark_add_outlined,
color: seedGreen,
),
tooltip: t.savedSearches.save,
onPressed: () => _saveCurrentSearch(
context,
savedSearches!,
state,
),
)
: null,
hintStyle: const TextStyle(color: seedMuted),
filled: true,
fillColor: Colors.white,
@ -378,23 +448,48 @@ class MarketBody extends StatelessWidget {
),
],
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: visible.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final o = visible[i];
final mine = o.authorPubkeyHex == selfPubkey;
return _OfferCard(
offer: o,
mine: mine,
onTap: () async {
await context.push('/market/offer', extra: o);
await onOfferClosed?.call();
},
);
: NotificationListener<ScrollNotification>(
// Page in older offers as the list nears its end, so a
// large area streams in on demand rather than all at
// once. The cubit guards against overlapping loads.
onNotification: (n) {
if (state.canLoadMore &&
!state.loadingMore &&
n.metrics.axis == Axis.vertical &&
n.metrics.extentAfter < 500) {
cubit.loadNextPage();
}
return false;
},
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
// A trailing spinner row while the next page loads.
itemCount:
visible.length + (state.loadingMore ? 1 : 0),
separatorBuilder: (_, _) =>
const SizedBox(height: 12),
itemBuilder: (context, i) {
if (i >= visible.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: CircularProgressIndicator(),
),
);
}
final o = visible[i];
final mine = o.authorPubkeyHex == selfPubkey;
return _OfferCard(
offer: o,
mine: mine,
onTap: () async {
await context.push('/market/offer', extra: o);
await onOfferClosed?.call();
},
);
},
),
),
),
),
@ -405,6 +500,63 @@ class MarketBody extends StatelessWidget {
}
}
/// Prompts for a name, then saves the current query + facets as a search the
/// user will be alerted about. Offers already visible are seeded as "seen" so
/// saving never immediately alerts about listings the user is looking at.
Future<void> _saveCurrentSearch(
BuildContext context,
SavedSearchesStore store,
OffersState state,
) async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final defaultLabel = state.query.trim();
final controller = TextEditingController(text: defaultLabel);
final label = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.savedSearches.save),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: t.savedSearches.nameLabel,
hintText: t.savedSearches.namePlaceholder,
),
onSubmitted: (v) => Navigator.of(context).pop(v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text),
child: Text(t.common.save),
),
],
),
);
if (label == null) return; // cancelled
final trimmed = label.trim();
final search = SavedSearch(
id: IdGen().newId(),
label: trimmed.isEmpty ? t.savedSearches.title : trimmed,
query: state.query.trim(),
types: state.typeFilter,
categories: state.categoryFilter,
organicOnly: state.organicOnly,
createdAt: DateTime.now().millisecondsSinceEpoch,
);
await store.save(
search,
seedSeenKeys: [
for (final o in state.visibleOffers) SavedSearchesStore.keyOf(o),
],
);
messenger.showSnackBar(SnackBar(content: Text(t.savedSearches.saved)));
}
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
/// instead of any precise location; price only when it's a sale.
class _OfferCard extends StatelessWidget {
@ -460,7 +612,9 @@ class _OfferCard extends StatelessWidget {
if (mine) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: seedGreen,
borderRadius: BorderRadius.circular(20),
@ -484,8 +638,10 @@ class _OfferCard extends StatelessWidget {
children: [
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
const SizedBox(width: 4),
Text(t.market.near,
style: const TextStyle(color: seedMuted, fontSize: 13)),
Text(
t.market.near,
style: const TextStyle(color: seedMuted, fontSize: 13),
),
if (offer.isOrganic) ...[
const SizedBox(width: 10),
const Icon(Icons.eco, size: 15, color: seedGreen),
@ -493,7 +649,8 @@ class _OfferCard extends StatelessWidget {
const Spacer(),
if (offer.type == OfferType.sale && offer.priceAmount != null)
Text(
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'
.trim(),
style: const TextStyle(
color: seedOnSurface,
fontWeight: FontWeight.w600,
@ -645,10 +802,11 @@ class _EmptyState extends StatelessWidget {
/// Coarse-area + community-server setup. Kept behind progressive disclosure a
/// power-user surface with human-worded labels.
class _ConfigSheet extends StatefulWidget {
const _ConfigSheet({required this.settings, this.location});
const _ConfigSheet({required this.settings, this.location, this.sharing});
final SocialSettings settings;
final CoarseLocationProvider? location;
final SharingSwitch? sharing;
@override
State<_ConfigSheet> createState() => _ConfigSheetState();
@ -792,194 +950,250 @@ class _ConfigSheetState extends State<_ConfigSheet> {
Widget build(BuildContext context) {
final t = context.t;
final hasArea = _area.text.trim().isNotEmpty;
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: _loading
? const SizedBox(
height: 120, child: Center(child: CircularProgressIndicator()))
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.market.configTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: seedTitle,
),
),
const SizedBox(height: 8),
Text(
t.market.setupIntro,
style: const TextStyle(
color: seedMuted, fontSize: 13, height: 1.4),
),
const SizedBox(height: 18),
// Setting your area from device location is the human path;
// typing a code is the advanced fallback.
if (widget.location != null)
FilledButton.tonalIcon(
key: const Key('market.useLocation'),
onPressed: _locationBusy ? null : _useLocation,
icon: _locationBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location, size: 18),
label: Text(t.market.useLocation),
),
if (_locationError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
_locationError!,
style: const TextStyle(
color: Color(0xFFB3261E), fontSize: 12),
// SafeArea keeps the Save button above the system nav bar on edge-to-edge
// devices; the viewInsets padding still lifts it above the keyboard.
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: _loading
? const SizedBox(
height: 120,
child: Center(child: CircularProgressIndicator()),
)
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.market.configTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: seedTitle,
),
),
const SizedBox(height: 12),
Row(
children: [
Icon(
hasArea
? Icons.check_circle
: Icons.pending_outlined,
size: 18,
color: hasArea ? seedGreen : seedMuted,
const SizedBox(height: 8),
Text(
t.market.setupIntro,
style: const TextStyle(
color: seedMuted,
fontSize: 13,
height: 1.4,
),
const SizedBox(width: 8),
Expanded(
),
const SizedBox(height: 18),
// Setting your area from device location is the human path;
// typing a code is the advanced fallback.
if (widget.location != null)
FilledButton.tonalIcon(
key: const Key('market.useLocation'),
onPressed: _locationBusy ? null : _useLocation,
icon: _locationBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.my_location, size: 18),
label: Text(t.market.useLocation),
),
if (_locationError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
hasArea ? t.market.areaSet : t.market.areaNotSet,
style: TextStyle(
color: hasArea ? seedOnSurface : seedMuted,
fontSize: 13,
_locationError!,
style: const TextStyle(
color: Color(0xFFB3261E),
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.rangeLabel,
style: const TextStyle(fontSize: 13, color: seedMuted),
),
),
const SizedBox(height: 8),
SegmentedButton<int>(
key: const Key('market.range'),
segments: [
ButtonSegment(value: 5, label: Text(t.market.rangeNear)),
ButtonSegment(value: 4, label: Text(t.market.rangeArea)),
ButtonSegment(value: 3, label: Text(t.market.rangeRegion)),
],
selected: {_precision},
showSelectedIcon: false,
onSelectionChanged: (s) =>
setState(() => _precision = s.first),
),
const SizedBox(height: 8),
Theme(
data: Theme.of(context)
.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: const Key('market.advanced'),
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
title: Text(
t.market.advanced,
style: const TextStyle(fontSize: 13, color: seedMuted),
),
const SizedBox(height: 12),
Row(
children: [
TextField(
key: const Key('market.area'),
controller: _area,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: t.market.areaCodeLabel,
helperText: t.market.areaCodeHint,
helperMaxLines: 2,
),
Icon(
hasArea ? Icons.check_circle : Icons.pending_outlined,
size: 18,
color: hasArea ? seedGreen : seedMuted,
),
const SizedBox(height: 14),
Align(
alignment: AlignmentDirectional.centerStart,
const SizedBox(width: 8),
Expanded(
child: Text(
t.market.serversLabel,
style: const TextStyle(
fontSize: 13, color: seedMuted),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 2, bottom: 4),
child: Text(
t.market.serversHelp,
style: const TextStyle(
fontSize: 12, color: seedMuted, height: 1.3),
),
),
for (final url in _knownRelays)
CheckboxListTile(
key: Key('market.server.$url'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(url),
title: Text(_relayLabel(url)),
onChanged: (on) => setState(() {
if (on ?? false) {
_selectedRelays.add(url);
} else {
_selectedRelays.remove(url);
}
}),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('market.addServer'),
onPressed: _addServer,
icon: const Icon(Icons.add, size: 18),
label: Text(t.market.serversAdvanced),
),
),
ListTile(
key: const Key('market.blockedPeople'),
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.block, color: seedMuted),
title: Text(t.block.manageTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) =>
BlockedPeopleSheet(settings: widget.settings),
hasArea ? t.market.areaSet : t.market.areaNotSet,
style: TextStyle(
color: hasArea ? seedOnSurface : seedMuted,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
],
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.rangeLabel,
style: const TextStyle(fontSize: 13, color: seedMuted),
),
),
const SizedBox(height: 8),
SegmentedButton<int>(
key: const Key('market.range'),
segments: [
ButtonSegment(
value: 5,
label: Text(t.market.rangeNear),
),
ButtonSegment(
value: 4,
label: Text(t.market.rangeArea),
),
ButtonSegment(
value: 3,
label: Text(t.market.rangeRegion),
),
],
selected: {_precision},
showSelectedIcon: false,
onSelectionChanged: (s) =>
setState(() => _precision = s.first),
),
const SizedBox(height: 8),
Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: const Key('market.advanced'),
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
title: Text(
t.market.advanced,
style: const TextStyle(
fontSize: 13,
color: seedMuted,
),
),
children: [
// The master switch: turning it off takes Tane fully
// offline right away, not at the next launch.
if (widget.sharing != null)
ValueListenableBuilder<bool>(
valueListenable: widget.sharing!.on,
builder: (context, on, _) => SwitchListTile(
key: const Key('market.sharingOn'),
contentPadding: EdgeInsets.zero,
dense: true,
value: on,
title: Text(t.market.sharingOnLabel),
subtitle: Text(
t.market.sharingOnHelp,
style: const TextStyle(
fontSize: 12,
color: seedMuted,
height: 1.3,
),
),
onChanged: (want) => want
? widget.sharing!.enable()
: widget.sharing!.disable(),
),
),
TextField(
key: const Key('market.area'),
controller: _area,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: t.market.areaCodeLabel,
helperText: t.market.areaCodeHint,
helperMaxLines: 2,
),
),
const SizedBox(height: 14),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.serversLabel,
style: const TextStyle(
fontSize: 13,
color: seedMuted,
),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 2,
bottom: 4,
),
child: Text(
t.market.serversHelp,
style: const TextStyle(
fontSize: 12,
color: seedMuted,
height: 1.3,
),
),
),
for (final url in _knownRelays)
CheckboxListTile(
key: Key('market.server.$url'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(url),
title: Text(_relayLabel(url)),
onChanged: (on) => setState(() {
if (on ?? false) {
_selectedRelays.add(url);
} else {
_selectedRelays.remove(url);
}
}),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('market.addServer'),
onPressed: _addServer,
icon: const Icon(Icons.add, size: 18),
label: Text(t.market.serversAdvanced),
),
),
ListTile(
key: const Key('market.blockedPeople'),
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.block, color: seedMuted),
title: Text(t.block.manageTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) =>
BlockedPeopleSheet(settings: widget.settings),
),
),
],
),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
],
),
),
),
),
);
}
}

View file

@ -36,6 +36,7 @@ class OfferThumbnail extends StatelessWidget {
return ClipRRect(
borderRadius: BorderRadius.circular(10),
child: _offerImage(
context,
url,
semanticLabel: semanticLabel,
width: size,
@ -63,7 +64,7 @@ class OfferHeroImage extends StatelessWidget {
borderRadius: BorderRadius.circular(14),
child: AspectRatio(
aspectRatio: 4 / 3,
child: _offerImage(url, semanticLabel: semanticLabel),
child: _offerImage(context, url, semanticLabel: semanticLabel),
),
);
}
@ -73,6 +74,7 @@ class OfferHeroImage extends StatelessWidget {
/// remote URL (via network), always cropping to fill and falling back to a
/// neutral placeholder a broken image must never block the card.
Widget _offerImage(
BuildContext context,
String url, {
required String semanticLabel,
double? width,
@ -80,10 +82,16 @@ Widget _offerImage(
}) {
final inline = decodeDataUri(url);
if (inline != null) {
// For a sized thumbnail, decode down to its on-screen pixels instead of the
// photo's full resolution (Play's "bitmap downscaling"). The hero image
// passes no width, so it decodes at native size as intended.
final dpr = MediaQuery.devicePixelRatioOf(context);
return Image.memory(
inline,
width: width,
height: height,
cacheWidth: width == null ? null : (width * dpr).round(),
cacheHeight: height == null ? null : (height * dpr).round(),
fit: BoxFit.cover,
semanticLabel: semanticLabel,
errorBuilder: (context, _, _) =>

View file

@ -34,7 +34,19 @@ class PeerAvatar extends StatelessWidget {
if (pic.startsWith('data:')) {
final bytes = decodeDataUri(pic);
if (bytes != null) {
return CircleAvatar(radius: radius, backgroundImage: MemoryImage(bytes));
// Decode the photo down to the avatar's on-screen size (in physical
// pixels) instead of full resolution the "bitmap downscaling" Play
// recommends. A tiny disc never needs a multi-megapixel decode.
final side = (radius * 2 * MediaQuery.devicePixelRatioOf(context))
.round();
return CircleAvatar(
radius: radius,
backgroundImage: ResizeImage(
MemoryImage(bytes),
width: side,
height: side,
),
);
}
} else if (pic.startsWith(avatarGlyphPrefix)) {
final glyph = avatarGlyphChar(pic.substring(avatarGlyphPrefix.length));

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../i18n/strings.g.dart';
import '../services/camera_availability.dart';
/// Asks the user to take a photo or pick one from the gallery, then returns its
/// bytes (or null if cancelled/unavailable). Camera failures e.g. on desktop,
@ -16,12 +17,15 @@ Future<Uint8List?> pickPhoto(BuildContext context) async {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('photo.source.camera'),
leading: const Icon(Icons.photo_camera_outlined),
title: Text(t.photo.camera),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
),
// Only offer the camera when the device actually has one; camera-less
// devices (Chromebooks, Automotive, some TVs) fall back to gallery.
if (deviceHasCamera)
ListTile(
key: const Key('photo.source.camera'),
leading: const Icon(Icons.photo_camera_outlined),
title: Text(t.photo.camera),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
),
ListTile(
key: const Key('photo.source.gallery'),
leading: const Icon(Icons.photo_library_outlined),
@ -57,12 +61,15 @@ Future<List<Uint8List>> pickPhotos(BuildContext context) async {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('photos.source.camera'),
leading: const Icon(Icons.photo_camera_outlined),
title: Text(t.photo.camera),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
),
// Only offer the camera when the device actually has one; camera-less
// devices (Chromebooks, Automotive, some TVs) fall back to gallery.
if (deviceHasCamera)
ListTile(
key: const Key('photos.source.camera'),
leading: const Icon(Icons.photo_camera_outlined),
title: Text(t.photo.camera),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
),
ListTile(
key: const Key('photos.source.gallery'),
leading: const Icon(Icons.photo_library_outlined),

View file

@ -0,0 +1,125 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:zxing_barcode_scanner/zxing_barcode_scanner.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
import '../services/camera_availability.dart';
import '../services/seed_label_scan.dart';
/// Whether this platform can open the camera scanner. Mobile-only for now
/// (pure-ZXing platform views; no Google Play Services) elsewhere the scan
/// button simply isn't offered, same pattern as the OCR capture. On Android it
/// also requires an actual camera, so camera-less devices (Chromebooks,
/// Automotive, some TVs) don't get a scan button that can't open see
/// [deviceHasCamera].
bool get qrScanSupported =>
!kIsWeb && ((Platform.isAndroid && deviceHasCamera) || Platform.isIOS);
/// Opens the full-screen camera scanner and returns the first decoded QR
/// payload, or null if the person backed out. (Ğ1nkgo's scanner pattern on
/// the same pure-ZXing stack.)
Future<String?> scanSeedLabelQr(BuildContext context) {
return Navigator.of(context).push<String>(
MaterialPageRoute<String>(builder: (_) => const _ScannerScreen()),
);
}
/// Acts on a scanned payload: a known seed opens its record (via
/// [openVariety]); an unknown seed label asks before adding anything; anything
/// else says so and moves on. Navigation is injected so the flow is
/// widget-testable without a camera or a router.
Future<void> handleScannedPayload(
BuildContext context, {
required VarietyRepository repository,
required SpeciesRepository species,
required String payload,
required void Function(String varietyId) openVariety,
}) async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final result = await resolveScannedLabel(repository, payload);
if (!context.mounted) return;
if (result.varietyId case final id?) {
openVariety(id);
return;
}
final data = result.data;
if (data == null) {
messenger.showSnackBar(SnackBar(content: Text(t.scan.notALabel)));
return;
}
final add = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
key: const Key('scan.addPrompt'),
title: Text(t.scan.addTitle),
content: Text(t.scan.addBody(label: data.varietyLabel)),
actions: [
TextButton(
key: const Key('scan.cancel'),
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('scan.add'),
onPressed: () => Navigator.of(context).pop(true),
child: Text(t.scan.add),
),
],
),
);
if (add != true) return;
final varietyId = await importScannedLabel(
repository: repository,
species: species,
data: data,
);
messenger.showSnackBar(SnackBar(content: Text(t.scan.added)));
openVariety(varietyId);
}
class _ScannerScreen extends StatefulWidget {
const _ScannerScreen();
@override
State<_ScannerScreen> createState() => _ScannerScreenState();
}
class _ScannerScreenState extends State<_ScannerScreen> {
bool _done = false;
void _onScan(List<BarcodeResult> results) {
if (_done || !mounted || results.isEmpty) return;
final text = results.first.text;
if (text == null || text.isEmpty) return;
_done = true;
// A beat so the camera view settles before popping (Ğ1nkgo does the same).
Future<void>.delayed(const Duration(milliseconds: 150), () {
if (mounted) Navigator.of(context).pop(text);
});
}
@override
Widget build(BuildContext context) {
final t = context.t;
return Scaffold(
appBar: AppBar(title: Text(t.scan.title)),
body: ZxingBarcodeScanner(
onScan: _onScan,
onError: (error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(error.message ?? t.common.offline),
),
),
),
);
}
}

View file

@ -199,6 +199,8 @@ class _MoreSection extends StatelessWidget {
child: Image.memory(
state.photoBytes!,
height: 120,
cacheHeight:
(120 * MediaQuery.devicePixelRatioOf(context)).round(),
fit: BoxFit.cover,
),
),

View file

@ -0,0 +1,282 @@
import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart';
import '../services/saved_searches_store.dart';
import 'market_widgets.dart';
import 'theme.dart';
/// The Wallapop-style "Saved searches": queries the user asked to be alerted
/// about. Tapping one runs it in the market (applying its query and facets);
/// each shows a badge when new matching offers have arrived while the app was
/// open. Delete removes the search and its alerts.
class SavedSearchesScreen extends StatefulWidget {
const SavedSearchesScreen({required this.store, super.key});
final SavedSearchesStore store;
@override
State<SavedSearchesScreen> createState() => _SavedSearchesScreenState();
}
class _SavedSearchesScreenState extends State<SavedSearchesScreen> {
List<SavedSearch>? _searches;
Map<String, int> _newCounts = const {};
StreamSubscription<void>? _changesSub;
@override
void initState() {
super.initState();
_changesSub = widget.store.changes.listen((_) => _load());
_load();
}
@override
void dispose() {
_changesSub?.cancel();
super.dispose();
}
Future<void> _load() async {
final searches = await widget.store.list();
final counts = <String, int>{
for (final s in searches) s.id: await widget.store.newMatchCount(s.id),
};
if (!mounted) return;
setState(() {
_searches = searches;
_newCounts = counts;
});
}
/// Runs [search] in the market: mark it seen (clears its badge), then open the
/// market with the search applied.
Future<void> _open(SavedSearch search) async {
await widget.store.markViewed(search.id);
if (!mounted) return;
context.go('/market', extra: search);
}
Future<void> _confirmDelete(SavedSearch search) async {
final t = context.t;
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
content: Text(t.savedSearches.deleteConfirm),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(t.savedSearches.delete),
),
],
),
);
if (ok == true) await widget.store.remove(search.id);
}
@override
Widget build(BuildContext context) {
final t = context.t;
final searches = _searches;
return Scaffold(
appBar: AppBar(title: Text(t.savedSearches.title)),
body: searches == null
? const Center(child: CircularProgressIndicator())
: searches.isEmpty
? _Empty(text: t.savedSearches.empty)
: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: searches.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final search = searches[i];
return _SavedSearchCard(
search: search,
newCount: _newCounts[search.id] ?? 0,
onOpen: () => _open(search),
onDelete: () => _confirmDelete(search),
);
},
),
);
}
}
/// An AppBar action that opens the saved-searches list, badged with the total
/// number of unseen matches. Stream-driven off the store's [changes], mirroring
/// how [UnreadBadge] tracks unread messages.
class SavedSearchesAction extends StatefulWidget {
const SavedSearchesAction({required this.store, super.key});
final SavedSearchesStore store;
@override
State<SavedSearchesAction> createState() => _SavedSearchesActionState();
}
class _SavedSearchesActionState extends State<SavedSearchesAction> {
int _count = 0;
StreamSubscription<void>? _changesSub;
@override
void initState() {
super.initState();
_changesSub = widget.store.changes.listen((_) => _refresh());
_refresh();
}
@override
void dispose() {
_changesSub?.cancel();
super.dispose();
}
Future<void> _refresh() async {
final count = await widget.store.totalNewMatchCount();
if (!mounted) return;
setState(() => _count = count);
}
@override
Widget build(BuildContext context) {
final t = context.t;
final icon = const Icon(Icons.bookmarks_outlined);
return IconButton(
key: const Key('market.savedSearches'),
icon: _count > 0
? Badge(
label: Text('$_count'),
backgroundColor: seedGreen,
child: icon,
)
: icon,
tooltip: t.savedSearches.openTooltip,
onPressed: () => context.push('/saved-searches'),
);
}
}
class _SavedSearchCard extends StatelessWidget {
const _SavedSearchCard({
required this.search,
required this.newCount,
required this.onOpen,
required this.onDelete,
});
final SavedSearch search;
final int newCount;
final VoidCallback onOpen;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final t = context.t;
final radius = BorderRadius.circular(14);
final facets = _facetSummary(t, search);
return Material(
color: Colors.white,
borderRadius: radius,
child: InkWell(
key: Key('savedSearches.item.${search.id}'),
onTap: onOpen,
borderRadius: radius,
child: Container(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: seedOutline),
),
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
search.label,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w500,
color: seedOnSurface,
),
),
if (facets.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
facets,
style: const TextStyle(color: seedMuted, fontSize: 13),
),
],
],
),
),
if (newCount > 0) ...[
Badge(
label: Text(t.savedSearches.newMatchesBadge(n: newCount)),
backgroundColor: seedGreen,
),
const SizedBox(width: 4),
],
IconButton(
key: Key('savedSearches.delete.${search.id}'),
icon: const Icon(Icons.delete_outline, color: seedMuted),
tooltip: t.savedSearches.delete,
onPressed: onDelete,
),
],
),
),
),
);
}
/// A short, human line describing the search's facets (never the raw query,
/// which is already the label by default).
static String _facetSummary(Translations t, SavedSearch s) {
final parts = <String>[
for (final type in s.types) offerTypeLabel(t, type),
...s.categories,
if (s.organicOnly) t.editVariety.organic,
]..removeWhere((e) => e.isEmpty);
return parts.join(' · ');
}
}
class _Empty extends StatelessWidget {
const _Empty({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bookmark_border,
size: 64,
color: seedGreen.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}

View file

@ -57,6 +57,7 @@ List<(AppLocale, String)> _languages(Translations t) => [
(AppLocale.ast, t.settings.langAst),
(AppLocale.en, t.settings.langEn),
(AppLocale.pt, t.settings.langPt),
(AppLocale.ptBr, t.settings.langPtBr),
(AppLocale.fr, t.settings.langFr),
(AppLocale.de, t.settings.langDe),
(AppLocale.ja, t.settings.langJa),

View file

@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import '../services/onboarding_store.dart';
import '../services/sharing_switch.dart';
import 'market_gate.dart';
import 'theme.dart';
/// Shown when someone taps a social entry (chat, profile, favourites, your
/// people) while sharing is still off.
///
/// The alternative was hiding those entries until sharing is on, but then
/// nobody would ever discover that Tane does any of it. So they stay in the
/// drawer, quiet, and tapping one explains what they are and offers to switch
/// them on with the community rules, which is the same single consent step
/// the market uses. Returns true when sharing ended up on.
Future<bool> showSharingInvite(
BuildContext context, {
required OnboardingStore onboarding,
required SharingSwitch sharing,
}) async {
final wants = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => const SharingInviteSheet(),
);
if (wants != true || !context.mounted) return false;
return ensureMarketRulesAccepted(context, onboarding, sharing: sharing);
}
/// The invitation itself: what lights up when you join, and the plain fact that
/// it needs a connection.
class SharingInviteSheet extends StatelessWidget {
const SharingInviteSheet({super.key});
@override
Widget build(BuildContext context) {
final t = context.t;
final theme = Theme.of(context);
return SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 24,
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
t.sharingInvite.title,
style: theme.textTheme.titleLarge?.copyWith(
color: seedOnSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_Perk(Icons.chat_bubble_outline, t.sharingInvite.perkChat),
_Perk(Icons.favorite_border, t.sharingInvite.perkFavorites),
_Perk(Icons.group_outlined, t.sharingInvite.perkPeople),
const SizedBox(height: 12),
Text(
t.sharingInvite.networkNote,
style: theme.textTheme.bodySmall?.copyWith(
color: seedMuted,
height: 1.4,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
key: const Key('sharingInvite.notNow'),
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.sharingInvite.notNow),
),
const SizedBox(width: 8),
FilledButton(
key: const Key('sharingInvite.start'),
onPressed: () => Navigator.of(context).pop(true),
child: Text(t.sharingInvite.start),
),
],
),
],
),
),
);
}
}
class _Perk extends StatelessWidget {
const _Perk(this.icon, this.text);
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: seedGreen),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: seedOnSurface,
height: 1.35,
),
),
),
],
),
);
}
}

View file

@ -19,6 +19,7 @@ import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'currency_quick_picks.dart';
import 'handover_sheet.dart';
import 'lot_history_sheet.dart';
import 'harvest_date_picker.dart';
import 'photo_pick.dart';
import 'quantity_kind_l10n.dart';
@ -236,6 +237,10 @@ class _DetailView extends StatelessWidget {
cubit: cubit,
lot: lot,
viabilityYears: detail.viabilityYears,
guide: SeedSavingCatalog.guideFor(
scientificName: detail.scientificName,
family: detail.family ?? detail.category,
),
),
_PlantaresSection(varietyId: detail.id),
],
@ -374,6 +379,7 @@ class _LotTile extends StatelessWidget {
required this.cubit,
required this.lot,
required this.viabilityYears,
this.guide,
});
final VarietyDetailCubit cubit;
@ -382,6 +388,10 @@ class _LotTile extends StatelessWidget {
/// Typical seed longevity of the variety's species (years), or null.
final int? viabilityYears;
/// Bundled seed-saving guidance for the species, or null lets the history
/// sheet drop a one-line isolation hint when a crosser is sown.
final SeedSavingGuide? guide;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -434,6 +444,17 @@ class _LotTile extends StatelessWidget {
children: [
if (lot.type == LotType.seed && lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.history.${lot.id}'),
icon: const Icon(Icons.history),
tooltip: t.history.tooltip,
onPressed: () => showLotHistorySheet(
context,
repository: context.read<VarietyRepository>(),
lot: lot,
guide: guide,
),
),
IconButton(
key: Key('lot.handover.${lot.id}'),
icon: const Icon(Icons.handshake_outlined),
@ -2223,10 +2244,16 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
itemBuilder: (_, i) => GestureDetector(
key: Key('photo.thumb.$i'),
onTap: () => _openPhotoViewer(context, cubit, i),
// Decode to the 140px thumbnail size (× dpr), not full photo
// resolution the full-res decode happens in the viewer.
child: Image.memory(
photos[i].bytes,
width: 140,
height: 140,
cacheWidth:
(140 * MediaQuery.devicePixelRatioOf(context)).round(),
cacheHeight:
(140 * MediaQuery.devicePixelRatioOf(context)).round(),
fit: BoxFit.cover,
),
),

View file

@ -1,7 +1,7 @@
name: tane
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
publish_to: 'none'
version: 0.1.0+2
version: 0.1.16+18
environment:
sdk: ^3.11.5
@ -60,6 +60,10 @@ dependencies:
# Pure-Dart barcode/QR generation (Apache-2.0; already used transitively by
# pdf). Painted on screen for the profile's shareable identity QR.
barcode: ^2.2.9
# Camera QR scanning via pure ZXing (MIT) — no ML Kit, no Google Play
# Services (F-Droid-safe; same stack Ğ1nkgo ships). Mobile-only; other
# platforms simply don't offer the scan button.
zxing_barcode_scanner: ^1.0.3
path: ^1.9.0
path_provider: ^2.1.5
@ -67,10 +71,10 @@ dependencies:
material_symbols_icons: ^4.2951.0
url_launcher: ^6.3.2
package_info_plus: ^9.0.1
# Optional coarse device location (BSD-3) to set the sharing area without
# typing a code. Low accuracy only; the value is immediately reduced to a
# low-precision geohash. Behind an interface so it's fakeable and pluggable.
geolocator: ^13.0.1
# Optional coarse device location goes through a small platform channel to
# Android's own LocationManager (lib/services/coarse_location.dart) — no
# location plugin, and deliberately no Google Play Services, so the APK
# stays free of proprietary classes (F-Droid).
# Network reachability (BSD-3) to show a global "you're offline" banner — the
# social layer needs a connection; the inventory works regardless.
connectivity_plus: ^6.1.0

View file

@ -68,6 +68,12 @@ void main() {
containerCount: 2,
desiccantState: DesiccantState.dry,
);
await source.addGardenOutcome(
lotId: lotId,
year: 2024,
rating: GardenOutcomeRating.good,
notes: 'kept it',
);
await source.addVernacularName(
varietyId,
'tomate de la abuela',
@ -126,6 +132,7 @@ void main() {
expect(targetRows.externalLinks, sourceRows.externalLinks);
expect(targetRows.germinationTests, sourceRows.germinationTests);
expect(targetRows.conditionChecks, sourceRows.conditionChecks);
expect(targetRows.gardenOutcomes, sourceRows.gardenOutcomes);
expect(targetRows.movements, sourceRows.movements);
expect(targetRows.attachments, sourceRows.attachments);

View file

@ -19,8 +19,8 @@ void main() {
});
tearDown(() => db.close());
test('the inventory view loads 3000 varieties quickly', () async {
const count = 3000;
test('the inventory view loads 10000 varieties quickly', () async {
const count = 10000;
for (var i = 0; i < count; i++) {
final id = await repo.addQuickVariety(
label: 'Variety $i',
@ -43,11 +43,12 @@ void main() {
sw.stop();
expect(view.items, hasLength(count));
// Generous ceiling for CI; typical local runs are well under 500ms. The
// point is to catch an accidental N+1 regression, not to micro-benchmark.
// Generous ceiling for CI (the point is to catch an N+1 regression, not to
// micro-benchmark). Includes the ~250ms debounce on the first emit; typical
// local runs are well under 2s for 10k rows with the v14 indexes.
expect(
sw.elapsedMilliseconds,
lessThan(3000),
lessThan(6000),
reason: 'inventory view took ${sw.elapsedMilliseconds}ms for $count rows',
);
});

View file

@ -11,19 +11,19 @@ void main() {
verifier = SchemaVerifier(GeneratedHelper());
});
test('freshly created database matches the exported schema v12', () async {
final schema = await verifier.schemaAt(12);
test('freshly created database matches the exported schema v14', () async {
final schema = await verifier.schemaAt(14);
final db = AppDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 12);
await verifier.migrateAndValidate(db, 14);
await db.close();
});
// Every historical version upgrades cleanly to the current schema (v12).
for (var from = 1; from <= 11; from++) {
test('upgrades v$from → v12 and matches the fresh schema', () async {
// Every historical version upgrades cleanly to the current schema (v14).
for (var from = 1; from <= 13; from++) {
test('upgrades v$from → v14 and matches the fresh schema', () async {
final connection = await verifier.startAt(from);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 12);
await verifier.migrateAndValidate(db, 14);
await db.close();
});
}

View file

@ -16,6 +16,8 @@ import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -45,10 +47,14 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v11.DatabaseAtV11(db);
case 12:
return v12.DatabaseAtV12(db);
case 13:
return v13.DatabaseAtV13(db);
case 14:
return v14.DatabaseAtV14(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -105,7 +105,7 @@ void main() {
seeded = await seedShowcase(db, repo, locale);
});
final child = switch (name) {
'home' => const HomeScreen(marketEnabled: true),
'home' => HomeScreen(sharing: newTestSharingSwitch()),
'inventory' => const InventoryListScreen(),
'market' => marketWidget(locale),
'calendar' => const CalendarScreen(initialMonth: 4),
@ -156,7 +156,11 @@ class _SeededOffersCubit extends OffersCubit {
/// A transport that is present (so the market reads as online) but never used.
class _NoopOfferTransport implements OfferTransport {
@override
Stream<Offer> discover(DiscoveryQuery query) => const Stream.empty();
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
const Stream.empty();
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async =>
const OfferPage(offers: []);
@override
Future<PublishResult> publish(Offer offer) => throw UnimplementedError();
@override

View file

@ -0,0 +1,59 @@
import 'package:tane/services/coarse_location.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('org.comunes.tane/coarse_location');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
debugDefaultTargetPlatformOverride = null;
});
test('returns the lat/lon the platform channel provides', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'getCoarseLatLon');
return {'lat': 41.39, 'lon': 2.16};
});
final loc = await const NativeCoarseLocation().currentCoarseLatLon();
expect(loc, isNotNull);
expect(loc!.lat, 41.39);
expect(loc.lon, 2.16);
});
test('returns null when the platform reports no fix (denied/off)', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(channel, (call) async => null);
expect(await const NativeCoarseLocation().currentCoarseLatLon(), isNull);
});
test('returns null instead of throwing on a channel error', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(channel, (call) async {
throw PlatformException(code: 'boom');
});
expect(await const NativeCoarseLocation().currentCoarseLatLon(), isNull);
});
test('returns null off Android without touching the channel', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
var invoked = false;
messenger.setMockMethodCallHandler(channel, (call) async {
invoked = true;
return {'lat': 1.0, 'lon': 1.0};
});
expect(await const NativeCoarseLocation().currentCoarseLatLon(), isNull);
expect(invoked, isFalse);
});
}

View file

@ -0,0 +1,41 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart';
/// Brazilian Portuguese (`pt_BR`) is the first locale in the app to carry a
/// region code alongside a base language that already has its own locale
/// (`pt`, European Portuguese) a case slang and the settings picker had
/// never exercised before. These tests pin that both variants are wired
/// distinctly and neither silently falls back to the other.
void main() {
test('pt and pt_BR are both supported, as distinct locales', () {
expect(
AppLocaleUtils.supportedLocales,
containsAll([const Locale('pt'), const Locale('pt', 'BR')]),
);
});
test('core strings resolve in Brazilian Portuguese', () {
final ptBr = AppLocale.ptBr.buildSync();
expect(ptBr.menu.inventory, 'Inventário');
expect(ptBr.common.save, 'Salvar');
expect(ptBr.settings.langPtBr, 'Português (Brasil)');
});
test('pt_BR wording diverges from pt where Brazilian usage differs', () {
final pt = AppLocale.pt.buildSync();
final ptBr = AppLocale.ptBr.buildSync();
// European Portuguese uses "Guardar"; Brazilian software uses "Salvar".
expect(pt.common.save, 'Guardar');
expect(ptBr.common.save, 'Salvar');
});
test('pt_BR translates a key added after the initial pt rollout', () {
final en = AppLocale.en.buildSync();
final ptBr = AppLocale.ptBr.buildSync();
// savedSearches was a later addition; if pt_BR were missing it, this
// would silently surface the English fallback instead.
expect(ptBr.savedSearches.title, isNot(en.savedSearches.title));
expect(ptBr.savedSearches.title, isNotEmpty);
});
}

View file

@ -0,0 +1,170 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
late VarietyRepository repo;
setUp(() {
db = newTestDatabase();
repo = newTestRepository(db);
});
tearDown(() async => db.close());
Future<String> newLot({String? originName, String? originPlace}) async {
final varietyId = await repo.addQuickVariety(label: 'Tomate rosa');
return repo.addLot(
varietyId: varietyId,
harvestYear: 2025,
originName: originName,
originPlace: originPlace,
);
}
group('recordCultivation', () {
test('records a sown movement with defaults', () async {
final lotId = await newLot();
final id = await repo.recordCultivation(
lotId: lotId,
type: MovementType.sown,
);
expect(id, isNotEmpty);
final history = await repo.lotHistory(lotId);
final sown = history.where(
(e) => e.movementType == MovementType.sown,
);
expect(sown, hasLength(1));
expect(sown.first.kind, LotHistoryKind.movement);
expect(sown.first.when, isNotNull);
});
test('records a harvested movement with a note and date', () async {
final lotId = await newLot();
await repo.recordCultivation(
lotId: lotId,
type: MovementType.harvested,
occurredOn: DateTime(2026, 7, 1).millisecondsSinceEpoch,
notes: 'great year',
);
final history = await repo.lotHistory(lotId);
final harvested = history.singleWhere(
(e) => e.movementType == MovementType.harvested,
);
expect(harvested.notes, 'great year');
expect(
harvested.when,
DateTime(2026, 7, 1).millisecondsSinceEpoch,
);
});
test('rejects non-cultivation movement types', () async {
final lotId = await newLot();
expect(
() => repo.recordCultivation(lotId: lotId, type: MovementType.given),
throwsA(isA<ArgumentError>()),
);
});
});
group('lotHistory', () {
test('starts with the lot creation entry carrying its origin', () async {
final lotId = await newLot(originName: 'María', originPlace: 'Aiako');
final history = await repo.lotHistory(lotId);
expect(history, isNotEmpty);
// Most-recent first: creation is the last entry.
final created = history.last;
expect(created.kind, LotHistoryKind.created);
expect(created.originName, 'María');
expect(created.originPlace, 'Aiako');
});
test('merges movements, germination tests and condition checks', () async {
final lotId = await newLot();
await repo.addGerminationTest(
lotId: lotId,
testedOn: DateTime(2026, 2, 1).millisecondsSinceEpoch,
sampleSize: 10,
germinatedCount: 8,
);
await repo.addConditionCheck(
lotId: lotId,
checkedOn: DateTime(2026, 3, 1).millisecondsSinceEpoch,
containerCount: 2,
desiccantState: DesiccantState.dry,
);
await repo.recordCultivation(
lotId: lotId,
type: MovementType.sown,
occurredOn: DateTime(2026, 4, 1).millisecondsSinceEpoch,
);
await repo.recordHandover(
lotId: lotId,
direction: HandoverDirection.iGave,
counterparty: 'Pepe',
);
final history = await repo.lotHistory(lotId);
expect(history.map((e) => e.kind).toSet(), {
LotHistoryKind.created,
LotHistoryKind.movement,
LotHistoryKind.germinationTest,
LotHistoryKind.conditionCheck,
});
final germination = history.singleWhere(
(e) => e.kind == LotHistoryKind.germinationTest,
);
expect(germination.germinationRate, closeTo(0.8, 1e-9));
final check = history.singleWhere(
(e) => e.kind == LotHistoryKind.conditionCheck,
);
expect(check.containerCount, 2);
// Most-recent first: the handover (today) leads, creation closes.
expect(history.first.movementType, MovementType.given);
expect(history.last.kind, LotHistoryKind.created);
// The middle entries are ordered by their explicit dates, descending.
final dated = history
.where((e) => e.kind != LotHistoryKind.created)
.toList();
for (var i = 0; i + 1 < dated.length; i++) {
expect(dated[i].when ?? 0, greaterThanOrEqualTo(dated[i + 1].when ?? 0));
}
});
test('includes garden outcomes as story lines', () async {
final lotId = await newLot();
await repo.addGardenOutcome(
lotId: lotId,
year: 2026,
rating: GardenOutcomeRating.good,
notes: 'true to type, keeping it',
);
final history = await repo.lotHistory(lotId);
final outcome = history.singleWhere(
(e) => e.kind == LotHistoryKind.gardenOutcome,
);
expect(outcome.rating, GardenOutcomeRating.good);
expect(outcome.year, 2026);
expect(outcome.notes, 'true to type, keeping it');
});
test('ignores other lots', () async {
final lotId = await newLot();
final otherLot = await newLot();
await repo.recordCultivation(lotId: otherLot, type: MovementType.sown);
final history = await repo.lotHistory(lotId);
expect(
history.where((e) => e.kind == LotHistoryKind.movement),
isEmpty,
);
});
});
}

View file

@ -0,0 +1,148 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/notification_service.dart';
import 'package:tane/services/saved_search_alert_service.dart';
import 'package:tane/services/saved_searches_store.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import '../support/test_support.dart';
/// Records search-alert calls instead of touching the OS plugin.
class _RecordingNotifications extends NotificationService {
_RecordingNotifications() : super(supported: false);
final searchIds = <String>[];
final titles = <String>[];
@override
Future<void> showSearchAlert({
required String searchId,
required String title,
}) async {
searchIds.add(searchId);
titles.add(title);
}
}
void main() {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
const myPubkey = 'me';
// A connection that never actually opens ingest doesn't use it.
Future<SocialConnection> offlineConnection() async => SocialConnection(
social: await SocialService.fromRootSeedHex(seedHex),
settings: SocialSettings(InMemorySecretStore()),
open: (_) async => throw StateError('unused in ingest tests'),
online: const Stream.empty(),
);
late SavedSearchesStore store;
late SocialSettings settings;
late _RecordingNotifications notifications;
late SavedSearchAlertService service;
setUp(() async {
store = SavedSearchesStore(InMemorySecretStore());
settings = SocialSettings(InMemorySecretStore());
notifications = _RecordingNotifications();
service = SavedSearchAlertService(
connection: await offlineConnection(),
selfPubkey: myPubkey,
store: store,
settings: settings,
notifications: notifications,
alertTitle: (s) => 'New match: ${s.label}',
);
});
tearDown(() async {
await service.stop();
await store.close();
});
Offer offer({
String id = 'o1',
String author = 'grower',
String summary = 'Heirloom tomato seeds',
OfferType type = OfferType.gift,
OfferLifecycle status = OfferLifecycle.active,
String? category = 'Solanaceae',
bool isOrganic = false,
}) =>
Offer(
id: id,
authorPubkeyHex: author,
summary: summary,
type: type,
status: status,
approxGeohash: 'u09',
category: category,
isOrganic: isOrganic,
);
SavedSearch search(String id, {String query = ''}) =>
SavedSearch(id: id, label: id, query: query);
test('a matching offer fires one alert and records the match', () async {
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
expect(notifications.searchIds, ['tom']);
expect(notifications.titles, ['New match: tom']);
expect(await store.newMatchCount('tom'), 1);
});
test('a non-matching offer is silent', () async {
await store.save(search('pep', query: 'pepper'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('a replayed offer does not re-alert', () async {
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
await service.ingest(offer()); // relay replays the stored event
expect(notifications.searchIds, ['tom']);
});
test('one offer can match several searches', () async {
await store.save(search('all'));
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
expect(notifications.searchIds, containsAll(<String>['all', 'tom']));
expect(notifications.searchIds, hasLength(2));
});
test('my own offers never alert', () async {
await store.save(search('all'));
await service.ingest(offer(author: myPubkey));
expect(notifications.searchIds, isEmpty);
});
test('non-active offers never alert', () async {
await store.save(search('all'));
await service.ingest(offer(status: OfferLifecycle.closed));
expect(notifications.searchIds, isEmpty);
});
test('offers from a blocked author never alert', () async {
await settings.block('grower');
await store.save(search('all'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('a hidden (reported) offer never alerts', () async {
await settings.hideOffer(authorPubkeyHex: 'grower', offerId: 'o1');
await store.save(search('all'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('with no saved searches nothing fires', () async {
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
}

View file

@ -0,0 +1,97 @@
import 'package:tane/services/saved_searches_store.dart';
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/test_support.dart';
void main() {
late SavedSearchesStore store;
setUp(() => store = SavedSearchesStore(InMemorySecretStore()));
tearDown(() => store.close());
SavedSearch search(String id, {String query = '', int createdAt = 0}) =>
SavedSearch(id: id, label: id, query: query, createdAt: createdAt);
test('saves and lists newest first', () async {
await store.save(search('a', createdAt: 100));
await store.save(search('b', createdAt: 200));
final list = await store.list();
expect(list.map((s) => s.id), ['b', 'a']);
});
test('save is idempotent on id', () async {
await store.save(search('a', query: 'one'));
await store.save(search('a', query: 'two'));
final list = await store.list();
expect(list, hasLength(1));
expect(list.single.query, 'two');
});
test('remove deletes a search', () async {
await store.save(search('a'));
await store.remove('a');
expect(await store.list(), isEmpty);
expect(await store.exists('a'), isFalse);
});
test('markMatched reports a fresh match once, then dedups replays', () async {
await store.save(search('a'));
expect(await store.markMatched('a', 'author:o1'), isTrue);
// Same key again = replay/echo, not a new alert.
expect(await store.markMatched('a', 'author:o1'), isFalse);
expect(await store.markMatched('a', 'author:o2'), isTrue);
expect(await store.newMatchCount('a'), 2);
});
test('markMatched returns false for an unknown search', () async {
expect(await store.markMatched('ghost', 'author:o1'), isFalse);
});
test('seeded seen keys suppress alerts for already-visible offers', () async {
await store.save(search('a'), seedSeenKeys: ['author:o1']);
expect(await store.markMatched('a', 'author:o1'), isFalse);
expect(await store.newMatchCount('a'), 0);
});
test('markViewed clears unread but keeps seen (no re-alert)', () async {
await store.save(search('a'));
await store.markMatched('a', 'author:o1');
await store.markViewed('a');
expect(await store.newMatchCount('a'), 0);
// Still seen: the same offer won't alert again.
expect(await store.markMatched('a', 'author:o1'), isFalse);
});
test('totalNewMatchCount sums across searches', () async {
await store.save(search('a'));
await store.save(search('b'));
await store.markMatched('a', 'author:o1');
await store.markMatched('b', 'author:o2');
await store.markMatched('b', 'author:o3');
expect(await store.totalNewMatchCount(), 3);
});
test('changes stream fires on mutations', () async {
final events = <void>[];
final sub = store.changes.listen(events.add);
await store.save(search('a'));
await store.markMatched('a', 'author:o1');
await store.markViewed('a');
await store.remove('a');
await Future<void>.delayed(Duration.zero);
expect(events, hasLength(4));
await sub.cancel();
});
test('account scopes are isolated', () async {
final secret = InMemorySecretStore();
final s1 = SavedSearchesStore(secret, accountScope: 'acct1');
final s2 = SavedSearchesStore(secret, accountScope: 'acct2');
await s1.save(search('a'));
expect(await s1.list(), hasLength(1));
expect(await s2.list(), isEmpty);
await s1.close();
await s2.close();
});
}

View file

@ -0,0 +1,109 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/seed_label_codec.dart';
import 'package:tane/data/species_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/services/seed_label_scan.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
late var repo = newTestRepository(db);
late SpeciesRepository species;
setUp(() {
db = newTestDatabase();
repo = newTestRepository(db);
species = newTestSpeciesRepository(db);
});
tearDown(() async => db.close());
group('resolveScannedLabel', () {
test('rejects anything that is not a seed label', () async {
expect(
await resolveScannedLabel(repo, 'https://example.org'),
const SeedScanResult.notALabel(),
);
expect(
await resolveScannedLabel(repo, 'gibberish'),
const SeedScanResult.notALabel(),
);
});
test('finds the existing variety by its label, case-insensitively',
() async {
final id = await repo.addQuickVariety(label: 'Tomate Rosa');
final raw = SeedLabelCodec.encode(
const SeedLabelData(varietyLabel: 'tomate rosa'),
);
expect(
await resolveScannedLabel(repo, raw),
SeedScanResult.match(id),
);
});
test('an unknown label comes back with its data for the add prompt',
() async {
final raw = SeedLabelCodec.encode(
const SeedLabelData(
varietyLabel: 'Acelga de Perales',
scientificName: 'Beta vulgaris',
year: 2024,
origin: 'María · Aiako',
),
);
final result = await resolveScannedLabel(repo, raw);
expect(result.data?.varietyLabel, 'Acelga de Perales');
expect(result.data?.year, 2024);
});
});
group('importScannedLabel', () {
test('creates the variety with a lot carrying year and origin', () async {
const data = SeedLabelData(
varietyLabel: 'Acelga de Perales',
year: 2024,
origin: 'María · Aiako',
);
final varietyId = await importScannedLabel(
repository: repo,
species: species,
data: data,
);
final varieties = await db.select(db.varieties).get();
expect(varieties.single.id, varietyId);
expect(varieties.single.label, 'Acelga de Perales');
final lots = await db.select(db.lots).get();
expect(lots.single.varietyId, varietyId);
expect(lots.single.harvestYear, 2024);
expect(lots.single.originName, 'María · Aiako');
});
test('links the species by exact scientific name when bundled', () async {
await species.seedBundled(const [
SpeciesSeed(
scientificName: 'Beta vulgaris',
family: 'Amaranthaceae',
commonNames: {
'es': ['Acelga'],
},
),
]);
const data = SeedLabelData(
varietyLabel: 'Una acelga cualquiera',
scientificName: 'beta vulgaris',
);
final varietyId = await importScannedLabel(
repository: repo,
species: species,
data: data,
);
final variety = await (db.select(
db.varieties,
)..where((v) => v.id.equals(varietyId))).getSingle();
final bundled = await db.select(db.species).get();
expect(variety.speciesId, bundled.single.id);
});
});
}

View file

@ -45,11 +45,13 @@ void main() {
required List<FakeChannel> opened,
Stream<bool>? online,
bool Function()? fail,
List<Duration>? retrySchedule,
}) =>
SocialConnection(
social: social,
settings: settings,
online: online,
retrySchedule: retrySchedule,
open: (_) async {
if (fail?.call() ?? false) throw StateError('unreachable');
final ch = FakeChannel();
@ -106,6 +108,69 @@ void main() {
await online.close();
});
test(
'started connection retries by itself after a failed first attempt, '
'without any connectivity event', () async {
// The fresh-install case: device online the whole time, but the very first
// connect fails (cold DNS/TLS). The connectivity stream never fires, so
// only the backoff retry can bring the market up without a manual Retry.
final opened = <FakeChannel>[];
var down = true;
final conn = make(
opened: opened,
online: const Stream.empty(), // connectivity never speaks
fail: () => down,
retrySchedule: const [Duration(milliseconds: 5)],
);
final emitted = <SocialSession?>[];
conn.sessions.listen(emitted.add);
conn.start();
await Future<void>.delayed(Duration.zero);
expect(conn.current, isNull); // first attempt failed
down = false; // network path recovers
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(conn.current, isNotNull, reason: 'backoff retry reconnected');
expect(opened, hasLength(1));
expect(emitted.last, isNotNull, reason: 'recovery announced on sessions');
await conn.dispose();
});
test('a plain session() failure schedules no retry when never started',
() async {
// Widget tests build cubits against an un-started connection; a failed
// one-shot session() must not leave a pending retry timer behind.
final opened = <FakeChannel>[];
final conn = make(
opened: opened,
fail: () => true,
retrySchedule: const [Duration(milliseconds: 5)],
);
expect(await conn.session(), isNull);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(opened, isEmpty); // no background retry fired
await conn.dispose();
});
test('dispose cancels a pending backoff retry', () async {
final opened = <FakeChannel>[];
var down = true;
final conn = make(
opened: opened,
online: const Stream.empty(),
fail: () => down,
retrySchedule: const [Duration(milliseconds: 20)],
);
conn.start();
await Future<void>.delayed(Duration.zero); // first attempt fails
await conn.dispose(); // cancels the scheduled retry
down = false;
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(opened, isEmpty, reason: 'no reconnect after dispose');
});
test('returns null when the relay is unreachable, and retries later',
() async {
final opened = <FakeChannel>[];
@ -116,4 +181,70 @@ void main() {
expect(await conn.session(), isNotNull); // succeeds on retry
await conn.dispose();
});
test('start is idempotent: a second call adds no second connect', () async {
// Joining sharing calls start() while bootstrap may already have, so a
// repeat must not stack another connectivity subscription or dial again.
final opened = <FakeChannel>[];
final online = StreamController<bool>.broadcast();
final conn = make(opened: opened, online: online.stream);
conn.start();
await conn.session();
conn.start();
await Future<void>.delayed(Duration.zero);
expect(opened, hasLength(1));
// One subscription, so one drop not two competing reactions.
online.add(false);
await Future<void>.delayed(Duration.zero);
expect(conn.current, isNull);
expect(opened.first.closed, isTrue);
await conn.dispose();
await online.close();
});
test('stop goes offline now, not at the next launch', () async {
// Turning sharing off used to leave the live session open until restart.
final opened = <FakeChannel>[];
final online = StreamController<bool>.broadcast();
final conn = make(opened: opened, online: online.stream);
final emitted = <SocialSession?>[];
conn.sessions.listen(emitted.add);
conn.start();
expect(await conn.session(), isNotNull);
await conn.stop();
await Future<void>.delayed(Duration.zero); // let the drop be announced
expect(conn.current, isNull);
expect(opened.single.closed, isTrue);
expect(emitted.last, isNull);
// And it stays off: a connectivity event must not resurrect it.
online.add(true);
await Future<void>.delayed(Duration.zero);
expect(conn.current, isNull);
expect(opened, hasLength(1));
await conn.dispose();
await online.close();
});
test('stop leaves the connection usable: start brings it back', () async {
final opened = <FakeChannel>[];
final online = StreamController<bool>.broadcast();
final conn = make(opened: opened, online: online.stream);
conn.start();
await conn.session();
await conn.stop();
conn.start();
await Future<void>.delayed(Duration.zero);
expect(conn.current, isNotNull);
expect(opened, hasLength(2));
await conn.dispose();
await online.close();
});
}

View file

@ -101,4 +101,34 @@ void main() {
{SocialSettings.offerKey('ab' * 32, 'tomate-1')},
);
});
group('sharing opt-in', () {
test('starts unanswered, then round-trips', () async {
expect(await settings.sharingEnabled(), isNull);
await settings.setSharingEnabled(true);
expect(await settings.sharingEnabled(), isTrue);
await settings.setSharingEnabled(false);
expect(await settings.sharingEnabled(), isFalse);
});
test('an install that had been through the intro keeps sharing on',
() async {
// The upgrade path that must not regress: these people were on a build
// that connected at launch, so they keep messaging, sync and alerts.
expect(await settings.migrateSharingEnabled(introSeen: true), isTrue);
expect(await settings.sharingEnabled(), isTrue);
});
test('a fresh install starts offline', () async {
expect(await settings.migrateSharingEnabled(introSeen: false), isFalse);
expect(await settings.sharingEnabled(), isFalse);
});
test('migration never overwrites an answer already given', () async {
await settings.setSharingEnabled(false);
// Later launches see the intro as seen; the recorded "no" must survive.
expect(await settings.migrateSharingEnabled(introSeen: true), isFalse);
expect(await settings.sharingEnabled(), isFalse);
});
});
}

View file

@ -7,7 +7,11 @@ import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/services/discovery_area.dart';
import 'package:tane/services/offer_mapper.dart';
import 'package:nostr/nostr.dart';
import 'package:tane/services/offer_outbox.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/state/offers_cubit.dart';
import '../support/test_support.dart';
@ -25,7 +29,7 @@ class FakeOfferTransport implements OfferTransport {
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
Stream<Offer> discover(DiscoveryQuery query, {int? since}) {
final controller = StreamController<Offer>();
for (final o in _offers) {
if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o);
@ -33,6 +37,15 @@ class FakeOfferTransport implements OfferTransport {
return controller.stream; // left open (live), like a real subscription
}
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
final matches = [
for (final o in _offers)
if (o.approxGeohash.startsWith(query.geohashPrefix)) o,
];
return OfferPage(offers: matches); // short page nothing older to load
}
@override
Future<void> retract(String offerId) async =>
_offers.removeWhere((o) => o.id == offerId);
@ -54,7 +67,7 @@ class DuplicatingOfferTransport implements OfferTransport {
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
Stream<Offer> discover(DiscoveryQuery query, {int? since}) {
final controller = StreamController<Offer>();
for (final o in _offers) {
if (o.approxGeohash.startsWith(query.geohashPrefix)) {
@ -65,6 +78,17 @@ class DuplicatingOfferTransport implements OfferTransport {
return controller.stream;
}
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
// The stored copy (deduped at relay level); the live echo (the duplicate)
// still arrives via [discover], so the cubit's dedup is what's under test.
final matches = [
for (final o in _offers)
if (o.approxGeohash.startsWith(query.geohashPrefix)) o,
];
return OfferPage(offers: matches);
}
@override
Future<void> retract(String offerId) async =>
_offers.removeWhere((o) => o.id == offerId);
@ -73,6 +97,59 @@ class DuplicatingOfferTransport implements OfferTransport {
Future<void> close() async {}
}
/// A transport that serves offers in `until`-cursored pages (like a relay's
/// stored events), so the cubit's pagination and memory cap can be exercised.
/// Each seeded offer gets a descending createdAt; [discover] (live) is empty.
class PaginatingOfferTransport implements OfferTransport {
PaginatingOfferTransport(int count, {this.geohash = 'sp3e9'}) {
// Newest (highest createdAt) first: offer 0 is newest.
for (var i = 0; i < count; i++) {
_byCreatedAt.add((
createdAt: 1000000 - i,
offer: Offer(
id: 'o$i',
authorPubkeyHex: 'ab' * 32,
summary: 'offer $i',
type: OfferType.gift,
approxGeohash: geohash,
),
));
}
}
final String geohash;
final List<({int createdAt, Offer offer})> _byCreatedAt = [];
@override
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
const Stream.empty();
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
final matched = _byCreatedAt
.where((e) => e.offer.approxGeohash.startsWith(query.geohashPrefix))
.where((e) => query.until == null || e.createdAt <= query.until!)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final page = matched.take(query.limit).toList();
final nextCursor = page.length >= query.limit && page.isNotEmpty
? page.last.createdAt - 1
: null;
return OfferPage(
offers: [for (final e in page) e.offer],
nextCursor: nextCursor,
);
}
@override
Future<PublishResult> publish(Offer offer) async =>
PublishResult(accepted: true, transportRef: offer.id);
@override
Future<void> retract(String offerId) async {}
@override
Future<void> close() async {}
}
void main() {
group('OfferMapper', () {
test('maps local sharing intent to the network offer type', () {
@ -701,4 +778,172 @@ void main() {
await cubit.close();
});
});
group('OffersCubit pagination', () {
test('discover loads the first page and exposes a next-page cursor',
() async {
final cubit = OffersCubit(PaginatingOfferTransport(250));
await cubit.discover('sp3');
await pumpEventQueue();
// One page (default limit 100) not the whole 250 is kept in memory.
expect(cubit.state.offers, hasLength(100));
expect(cubit.state.canLoadMore, isTrue);
expect(cubit.state.searching, isFalse);
await cubit.close();
});
test('loadNextPage accumulates older offers until the source is exhausted',
() async {
final cubit = OffersCubit(PaginatingOfferTransport(250));
await cubit.discover('sp3');
await pumpEventQueue();
await cubit.loadNextPage();
expect(cubit.state.offers, hasLength(200));
expect(cubit.state.canLoadMore, isTrue);
await cubit.loadNextPage();
// Only 250 exist: the third page is short, so there is nothing older.
expect(cubit.state.offers, hasLength(250));
expect(cubit.state.canLoadMore, isFalse);
// Paging past the end is a harmless no-op.
await cubit.loadNextPage();
expect(cubit.state.offers, hasLength(250));
await cubit.close();
});
test('the in-memory list is capped and paging stops at the cap', () async {
final cubit = OffersCubit(PaginatingOfferTransport(600));
await cubit.discover('sp3');
await pumpEventQueue();
// Keep paging until the cubit says there is no more.
var guard = 0;
while (cubit.state.canLoadMore && guard++ < 20) {
await cubit.loadNextPage();
}
expect(cubit.state.offers, hasLength(OffersCubit.maxOffersKept));
expect(cubit.state.canLoadMore, isFalse);
await cubit.close();
});
test('offers are de-duplicated across pages by (author, id)', () async {
// Two pages that overlap on the boundary id must not double it.
final cubit = OffersCubit(PaginatingOfferTransport(150));
await cubit.discover('sp3');
await pumpEventQueue();
await cubit.loadNextPage();
final ids = cubit.state.offers.map((o) => o.id).toList();
expect(ids.toSet(), hasLength(ids.length)); // no duplicates
expect(cubit.state.offers, hasLength(150));
await cubit.close();
});
});
group('connection recovery (fresh-install fix)', () {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
late SocialService social;
late SocialSettings settings;
setUp(() async {
social = await SocialService.fromRootSeedHex(seedHex);
settings = SocialSettings(InMemorySecretStore());
});
SocialConnection makeConnection({
required bool Function() down,
Stream<bool>? online,
}) =>
SocialConnection(
social: social,
settings: settings,
online: online ?? const Stream.empty(),
retrySchedule: const [Duration(milliseconds: 5)],
open: (_) async {
if (down()) throw StateError('unreachable');
return SocialSession(_NoopChannel());
},
);
test('comes online by itself when the shared connection connects later',
() async {
var down = true;
final conn = makeConnection(down: () => down);
final cubit = await createOffersCubit(conn);
expect(cubit.isOnline, isFalse); // built while unreachable
final epochBefore = cubit.state.connectionEpoch;
conn.start();
down = false; // network path recovers; backoff retry reconnects
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(cubit.isOnline, isTrue, reason: 'no manual Retry needed');
expect(cubit.state.connectionEpoch, greaterThan(epochBefore),
reason: 'a state was emitted so the UI re-reads isOnline');
await cubit.close();
await conn.dispose();
});
test('re-runs the last discovery when the connection recovers', () async {
var down = true;
final conn = makeConnection(down: () => down);
final cubit = await createOffersCubit(conn);
await cubit.discover('sp3e9'); // offline error, but the wish is kept
expect(cubit.state.error, 'offline');
conn.start();
down = false;
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(cubit.isOnline, isTrue);
expect(cubit.state.areaGeohash, 'sp3e9', reason: 'discovery re-ran');
expect(cubit.state.hasSearched, isTrue);
expect(cubit.state.error, isNull, reason: 'offline error cleared');
await cubit.close();
await conn.dispose();
});
test('goes offline (and tells the UI) when the session drops', () async {
final online = StreamController<bool>.broadcast();
final conn = makeConnection(down: () => false, online: online.stream);
conn.start();
await pumpEventQueue();
final cubit = await createOffersCubit(conn);
expect(cubit.isOnline, isTrue);
final epochBefore = cubit.state.connectionEpoch;
online.add(false); // network lost
await pumpEventQueue();
expect(cubit.isOnline, isFalse);
expect(cubit.state.connectionEpoch, greaterThan(epochBefore));
await cubit.close();
await conn.dispose();
await online.close();
});
});
}
/// A no-op [NostrChannel]: just enough to build a [SocialSession] whose offer
/// transport answers with empty results.
class _NoopChannel implements NostrChannel {
@override
String get privateKeyHex => '00' * 32;
@override
String get publicKeyHex => 'ab' * 32;
@override
Future<({bool accepted, String message})> publish(Event event) async =>
(accepted: true, message: '');
@override
Stream<Event> subscribe(Filter filter) => const Stream.empty();
@override
Future<List<Event>> reqOnce(Filter filter) async => const [];
@override
Future<void> close() async {}
}

View file

@ -11,6 +11,8 @@ import 'package:tane/db/database.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/security/secret_store.dart';
import 'package:tane/services/onboarding_store.dart';
import 'package:tane/services/sharing_switch.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/app.dart' show materialLocaleFor;
import 'package:tane/state/inventory_cubit.dart';
import 'package:tane/state/variety_detail_cubit.dart';
@ -59,6 +61,14 @@ OnboardingStore newTestOnboardingStore({bool introSeen = true}) {
return OnboardingStore(store);
}
/// A [SharingSwitch] over in-memory storage and no relay connection, for
/// screens that only care whether sharing is on. Defaults to on, which is what
/// most screen tests want (the social entries live, nothing dialled).
SharingSwitch newTestSharingSwitch({bool enabled = true}) => SharingSwitch(
settings: SocialSettings(InMemorySecretStore()),
enabled: enabled,
);
/// Wraps [child] with the providers a screen expects (repository, inventory
/// cubit) plus i18n and Material localizations, pinned to [locale].
Widget wrapScreen({

View file

@ -2,15 +2,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/app.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/sharing_switch.dart';
import '../support/test_support.dart';
void main() {
Widget app(db) => TranslationProvider(
/// The everyday case: the social layer exists and sharing is already on.
Widget app(db, {SharingSwitch? sharing}) => TranslationProvider(
child: TaneApp(
repository: newTestRepository(db),
species: newTestSpeciesRepository(db),
onboarding: newTestOnboardingStore(),
sharing: sharing ?? newTestSharingSwitch(),
),
);
@ -24,7 +27,8 @@ void main() {
expect(find.text('Your inventory'), findsOneWidget);
expect(find.text('Market'), findsOneWidget);
expect(find.text('COMING SOON'), findsOneWidget); // market is Block 2
// "Coming soon" is gone for good: everything on this screen is built.
expect(find.textContaining('COMING SOON'), findsNothing);
// Redesign copy: tagline + the two destination subtitles.
expect(find.text('Share and grow local seeds'), findsOneWidget);
@ -49,8 +53,9 @@ void main() {
await tester.tap(find.byIcon(Icons.menu)); // hamburger
await tester.pumpAndSettle();
// Social destinations are shown but disabled.
// With sharing on, the social destinations are live.
expect(find.text('Your profile'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsNothing);
await tester.tap(find.text('Inventory')); // active drawer item
await tester.pumpAndSettle();
@ -84,6 +89,62 @@ void main() {
await disposeTree(tester);
});
testWidgets('sharing off: market stays open, the rest wears a padlock',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final db = newTestDatabase();
addTearDown(db.close);
await tester.pumpWidget(
app(db, sharing: newTestSharingSwitch(enabled: false)),
);
await tester.pumpAndSettle();
// The market card is the door into sharing, so it must stay reachable.
expect(find.byKey(const Key('home.market')), findsOneWidget);
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
// Market live; the social entries are visible but padlocked and tapping
// one invites the person in rather than doing nothing.
expect(find.text('Your profile'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsWidgets);
await tester.tap(find.text('Your profile'));
await tester.pumpAndSettle();
expect(find.text('This wakes up when you start sharing'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('no social layer: the market and its friends are not drawn',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final db = newTestDatabase();
addTearDown(db.close);
await tester.pumpWidget(
TranslationProvider(
child: TaneApp(
repository: newTestRepository(db),
species: newTestSpeciesRepository(db),
onboarding: newTestOnboardingStore(),
),
),
);
await tester.pumpAndSettle();
// Nothing here can be switched on, so it is hidden rather than teased.
expect(find.byKey(const Key('home.market')), findsNothing);
expect(find.text('Your inventory'), findsOneWidget);
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
expect(find.text('Your profile'), findsNothing);
expect(find.byIcon(Icons.lock_outline), findsNothing);
await disposeTree(tester);
});
testWidgets('drawer Settings opens the settings screen', (tester) async {

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/ui/inventory_list_screen.dart';
import '../support/test_support.dart';
/// The inventory list must render lazily: with a large catalogue only the tiles
/// near the viewport are built, not one widget per row. Guards the regression
/// from `ListView(children: )` (every tile built upfront) back in.
void main() {
testWidgets('builds only the on-screen tiles for a large inventory',
(tester) async {
final db = newTestDatabase();
final repo = newTestRepository(db);
// Enough rows that an eager list would build hundreds of tiles at once.
for (var i = 0; i < 300; i++) {
await repo.addQuickVariety(
label: 'Variety ${i.toString().padLeft(3, '0')}',
category: i.isEven ? 'Poaceae' : 'Fabaceae',
);
}
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
// Let the debounced inventory stream emit and the async load resolve
// (bounded pumps the screen holds a live Drift stream, so pumpAndSettle
// would hang; see testing.md).
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.pump(const Duration(milliseconds: 100));
// Every variety tile is a ListTile; a lazy list builds only a viewport-worth.
final built = find.byType(ListTile).evaluate().length;
expect(built, greaterThan(0), reason: 'the list rendered some tiles');
expect(
built,
lessThan(50),
reason: 'lazy list should build ~a screenful, not all 300 ($built built)',
);
await disposeTree(tester);
await db.close();
});
}

View file

@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/seed_saving_catalog.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/domain/seed_saving.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
setUp(() => db = newTestDatabase());
tearDown(() {
SeedSavingCatalog.data = null;
return db.close();
});
testWidgets('the lot tile opens the batch story, closed by its origin', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(
varietyId: id,
originName: 'María',
originPlace: 'Aiako',
);
await repo.addGerminationTest(
lotId: lotId,
sampleSize: 10,
germinatedCount: 9,
);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
// The story: germination test (90%) first, creation-with-origin last.
expect(find.text('Germination test — 90%'), findsOneWidget);
expect(find.text('Added to your collection'), findsOneWidget);
expect(find.text('From María · Aiako'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('one tap records sown today and it shows up in the story', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(varietyId: id);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('history.sowToday')));
await tester.pumpAndSettle();
// One-shot .get() (NOT watch().first hangs under the fake-async clock).
final movements = await db.select(db.movements).get();
expect(movements.single.type, MovementType.sown);
expect(movements.single.lotId, lotId);
expect(movements.single.occurredOn, isNotNull);
// And the story refreshed to show it.
expect(find.text('Sown'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('harvesting asks "how did it do?" once, a face tap saves it', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(varietyId: id);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
// Sowing does NOT ask; harvesting does.
await tester.tap(find.byKey(const Key('history.sowToday')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('history.outcomeQuestion')), findsNothing);
await tester.tap(find.byKey(const Key('history.harvestToday')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('history.outcomeQuestion')), findsOneWidget);
await tester.enterText(
find.byKey(const Key('history.outcomeNote')),
'true to type',
);
await tester.tap(find.byKey(const Key('history.outcomeGood')));
await tester.pumpAndSettle();
// Question gone, answer saved, story shows it.
expect(find.byKey(const Key('history.outcomeQuestion')), findsNothing);
final outcomes = await db.select(db.gardenOutcomes).get();
expect(outcomes.single.rating, GardenOutcomeRating.good);
expect(outcomes.single.notes, 'true to type');
expect(outcomes.single.year, DateTime.now().year);
expect(find.text('It did well'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('the harvest question can be dismissed without a trace', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(varietyId: id);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('history.harvestToday')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('history.outcomeDismiss')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('history.outcomeQuestion')), findsNothing);
expect(await db.select(db.gardenOutcomes).get(), isEmpty);
await disposeTree(tester);
});
testWidgets('sowing a crosser drops the isolation hint; a selfer gets '
'nothing', (tester) async {
SeedSavingCatalog.data = SeedSavingData(
families: {
'Cucurbitaceae': const SeedSavingGuide(
pollination: Pollination.cross,
isolationMinM: 400,
),
'Solanaceae': const SeedSavingGuide(
pollination: Pollination.self,
isolationMinM: 3,
),
},
taxa: const {},
);
final repo = newTestRepository(db);
Future<void> sowAndCheck(String category, {required bool hinted}) async {
final id = await repo.addQuickVariety(label: 'x-$category');
await repo.updateVariety(id: id, label: 'x-$category', category: category);
final lotId = await repo.addLot(varietyId: id);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('history.sowToday')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('history.isolationHint')),
hinted ? findsOneWidget : findsNothing,
);
await disposeTree(tester);
}
// The family drives it: squash (crosses, 400 m) hints; tomato does not.
await sowAndCheck('Cucurbitaceae', hinted: true);
expect(find.textContaining('400'), findsNothing); // tree disposed
await sowAndCheck('Solanaceae', hinted: false);
});
testWidgets('a plant lot offers harvest but not sowing', (tester) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Rosemary');
final lotId = await repo.addLot(varietyId: id, type: LotType.plant);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('history.sowToday')), findsNothing);
expect(find.byKey(const Key('history.harvestToday')), findsOneWidget);
await disposeTree(tester);
});
}

View file

@ -3,12 +3,17 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/onboarding_store.dart';
import 'package:tane/services/sharing_switch.dart';
import 'package:tane/ui/market_gate.dart';
import '../support/test_support.dart';
void main() {
Widget host(OnboardingStore store, void Function(bool) onResult) =>
Widget host(
OnboardingStore store,
void Function(bool) onResult, {
SharingSwitch? sharing,
}) =>
TranslationProvider(
child: MaterialApp(
localizationsDelegates: GlobalMaterialLocalizations.delegates,
@ -16,7 +21,13 @@ void main() {
builder: (context) => Center(
child: ElevatedButton(
onPressed: () async {
onResult(await ensureMarketRulesAccepted(context, store));
onResult(
await ensureMarketRulesAccepted(
context,
store,
sharing: sharing,
),
);
},
child: const Text('enter'),
),
@ -87,5 +98,52 @@ void main() {
expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget);
expect(find.textContaining('public'), findsWidgets);
expect(find.text('Privacy & rules'), findsOneWidget);
// The sheet is also where Tane says it is about to go online for the first
// time the reviewer's complaint was that this was never stated.
expect(find.textContaining('community servers'), findsOneWidget);
});
testWidgets('agreeing is what turns sharing on', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final store = OnboardingStore(InMemorySecretStore());
final sharing = newTestSharingSwitch(enabled: false);
await tester.pumpWidget(host(store, (_) {}, sharing: sharing));
await tester.tap(find.text('enter'));
await tester.pumpAndSettle();
expect(sharing.on.value, isFalse, reason: 'still offline while asking');
await tester.tap(find.text('I agree'));
await tester.pumpAndSettle();
expect(sharing.on.value, isTrue);
});
testWidgets('declining leaves the app offline', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final store = OnboardingStore(InMemorySecretStore());
final sharing = newTestSharingSwitch(enabled: false);
await tester.pumpWidget(host(store, (_) {}, sharing: sharing));
await tester.tap(find.text('enter'));
await tester.pumpAndSettle();
await tester.tap(find.text('Not now'));
await tester.pumpAndSettle();
expect(sharing.on.value, isFalse);
});
testWidgets('someone who agreed long ago but switched sharing off is asked '
'nothing, yet comes back online', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final store = OnboardingStore(InMemorySecretStore());
await store.markMarketRulesAccepted();
final sharing = newTestSharingSwitch(enabled: false);
await tester.pumpWidget(host(store, (_) {}, sharing: sharing));
await tester.tap(find.text('enter'));
await tester.pumpAndSettle();
expect(find.text('Before you join the market'), findsNothing);
expect(sharing.on.value, isTrue);
});
}

View file

@ -84,8 +84,9 @@ void main() {
findsOneWidget);
});
testWidgets('MarketScreen with no relays configured degrades to offline',
(tester) async {
testWidgets(
'a fresh install (no area, unreachable) asks for the area first, '
'not the connection', (tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
@ -94,7 +95,23 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Seeds near you'), findsOneWidget); // app bar title
expect(find.text('Retry'), findsOneWidget); // can't-reach state offers retry
// Setting a zone works offline and is the first step the connection
// error must not bury it.
expect(find.text('Set your area'), findsOneWidget);
expect(find.text('Retry'), findsNothing);
});
testWidgets('with an area set, unreachable servers degrade to retry',
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
await settings.setAreaGeohash('ezsn9');
await tester.pumpWidget(_wrapMarket(social, settings));
await tester.pumpAndSettle();
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('the range selector persists the chosen search precision',
@ -148,6 +165,36 @@ void main() {
expect(tester.widget<CheckboxListTile>(find.byKey(firstKey)).value, isTrue);
});
testWidgets('the config sheet Save button stays above the system nav bar',
(tester) async {
// Edge-to-edge Android: a 50-logical-px gesture/nav bar at the bottom.
tester.view.padding = const FakeViewPadding(bottom: 150); // physical px
tester.view.viewPadding = const FakeViewPadding(bottom: 150);
addTearDown(tester.view.reset);
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
await tester.pumpWidget(_wrapMarket(social, settings));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('market.config')));
await tester.pumpAndSettle();
final save = find.byKey(const Key('market.save'));
await tester.ensureVisible(save);
await tester.pumpAndSettle();
final screenHeight = tester.view.physicalSize.height /
tester.view.devicePixelRatio; // 600 on the default test surface
const navBarLogical = 150 / 3.0; // FakeViewPadding is physical px
expect(
tester.getRect(save).bottom,
lessThanOrEqualTo(screenHeight - navBarLogical + 0.1),
reason: 'Save must not sit under the system navigation bar',
);
});
testWidgets('"use my location" fills the area with a coarse geohash',
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);

View file

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/seed_label_codec.dart';
import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/ui/qr_scan.dart';
import '../support/test_support.dart';
/// Hosts a button that feeds [payload] to [handleScannedPayload], standing in
/// for the camera the handler is what carries the flow's logic.
class _ScanHost extends StatelessWidget {
const _ScanHost({
required this.repository,
required this.species,
required this.payload,
required this.opened,
});
final VarietyRepository repository;
final SpeciesRepository species;
final String payload;
final List<String> opened;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ElevatedButton(
key: const Key('scanHost.fire'),
onPressed: () => handleScannedPayload(
context,
repository: repository,
species: species,
payload: payload,
openVariety: opened.add,
),
child: const Text('scan'),
),
),
);
}
}
void main() {
late AppDatabase db;
late VarietyRepository repo;
late SpeciesRepository species;
setUp(() {
db = newTestDatabase();
repo = newTestRepository(db);
species = newTestSpeciesRepository(db);
});
tearDown(() => db.close());
Future<List<String>> pumpAndFire(WidgetTester tester, String payload) async {
final opened = <String>[];
await tester.pumpWidget(
wrapScreen(
repository: repo,
child: _ScanHost(
repository: repo,
species: species,
payload: payload,
opened: opened,
),
),
);
await tester.tap(find.byKey(const Key('scanHost.fire')));
await tester.pumpAndSettle();
return opened;
}
testWidgets('a known label opens its record straight away', (tester) async {
final id = await repo.addQuickVariety(label: 'Tomate Rosa');
final payload = SeedLabelCodec.encode(
const SeedLabelData(varietyLabel: 'tomate rosa'),
);
final opened = await pumpAndFire(tester, payload);
expect(opened, [id]);
expect(find.byKey(const Key('scan.addPrompt')), findsNothing);
await disposeTree(tester);
});
testWidgets('an unknown label asks first, then adds and opens it', (
tester,
) async {
final payload = SeedLabelCodec.encode(
const SeedLabelData(
varietyLabel: 'Acelga de Perales',
year: 2024,
origin: 'María',
),
);
final opened = await pumpAndFire(tester, payload);
expect(find.byKey(const Key('scan.addPrompt')), findsOneWidget);
expect(opened, isEmpty); // nothing created before the person says so
expect(await db.select(db.varieties).get(), isEmpty);
await tester.tap(find.byKey(const Key('scan.add')));
await tester.pumpAndSettle();
final varieties = await db.select(db.varieties).get();
expect(varieties.single.label, 'Acelga de Perales');
final lots = await db.select(db.lots).get();
expect(lots.single.harvestYear, 2024);
expect(lots.single.originName, 'María');
expect(opened, [varieties.single.id]);
await disposeTree(tester);
});
testWidgets('declining the prompt adds nothing', (tester) async {
final payload = SeedLabelCodec.encode(
const SeedLabelData(varietyLabel: 'Acelga de Perales'),
);
final opened = await pumpAndFire(tester, payload);
await tester.tap(find.byKey(const Key('scan.cancel')));
await tester.pumpAndSettle();
expect(await db.select(db.varieties).get(), isEmpty);
expect(opened, isEmpty);
await disposeTree(tester);
});
testWidgets('a non-label QR just says so', (tester) async {
final opened = await pumpAndFire(tester, 'https://example.org/whatever');
expect(find.text('That code is not a seed label'), findsOneWidget);
expect(opened, isEmpty);
await disposeTree(tester);
});
}

Some files were not shown because too many files have changed in this diff Show more