Compare commits

..

23 commits

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
71 changed files with 2064 additions and 964 deletions

View file

@ -36,10 +36,86 @@ on:
- 'v*' - 'v*'
# Manual trigger to test the fdroid_reference job on a branch without cutting a # 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). # 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: 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: jobs:
# 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: 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/') }} if: ${{ startsWith(github.ref, 'refs/tags/') }}
runs-on: docker runs-on: docker
container: container:
@ -111,6 +187,18 @@ jobs:
run: rm -f android/key.properties "$GITHUB_WORKSPACE/tane-upload.jks" run: rm -f android/key.properties "$GITHUB_WORKSPACE/tane-upload.jks"
fdroid_reference_armeabi_v7a: 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 runs-on: docker
container: container:
image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie
@ -122,6 +210,7 @@ jobs:
TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }} TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }}
TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }} TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }}
TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }} TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }}
REF_TAG: ${{ github.event.inputs.ref_tag }}
ABI: armeabi-v7a ABI: armeabi-v7a
OFFSET: 1 OFFSET: 1
run: | run: |
@ -134,8 +223,16 @@ jobs:
# 2) fdroidserver from master, exactly like the fdroiddata CI. # 2) fdroidserver from master, exactly like the fdroiddata CI.
fds=/opt/fdroidserver-src fds=/opt/fdroidserver-src
mkdir -p "$fds" mkdir -p "$fds"
curl -sL https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \ # Download to a file with retries: a mid-transfer vSwitch blip used to
| tar -xz -C "$fds" --strip-components=1 # 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 PATH="$fds:$PATH"
export PYTHONPATH="$fds:$fds/examples" export PYTHONPATH="$fds:$fds/examples"
git config --global --add safe.directory '*' git config --global --add safe.directory '*'
@ -146,11 +243,15 @@ jobs:
# 3) Fetch our in-repo recipe (the single source of truth) at this commit, # 3) Fetch our in-repo recipe (the single source of truth) at this commit,
# plus the pubspec versionCode base for this ABI's versionCode. # 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 work=/tmp/tane-src
mkdir -p "$work" && cd "$work" mkdir -p "$work" && cd "$work"
git init -q . git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "${GITHUB_SHA}" git fetch -q --depth 1 origin "$BUILD_REF"
git checkout -q FETCH_HEAD git checkout -q FETCH_HEAD
base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml) base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml)
[ -n "$base" ] [ -n "$base" ]
@ -159,14 +260,16 @@ jobs:
# 4) Assemble a minimal fdroiddata (config.yml + srclibs + our recipe) from the # 4) Assemble a minimal fdroiddata (config.yml + srclibs + our recipe) from the
# in-repo files. No external host needed — the job container reaches # in-repo files. No external host needed — the job container reaches
# git.comunes.org (via forgejo:3000) but not gitlab.com. # git.comunes.org (via forgejo:3000) but not gitlab.com.
fdd=/tmp/fdroiddata # 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" mkdir -p "$fdd/metadata" "$fdd/srclibs"
cp "$work/fdroid-ci/config.yml" "$fdd/config.yml" cp "$work/fdroid-ci/config.yml" "$fdd/config.yml"
cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/" cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/"
cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml" cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml"
cd "$fdd" cd "$fdd"
# Build THIS commit; drop binary: (we PRODUCE the reference here, not verify it). # Build THIS commit; drop binary: (we PRODUCE the reference here, not verify it).
sed -i "s#^\( *commit: \).*#\1${GITHUB_SHA}#" metadata/org.comunes.tane.yml sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml
sed -i '/^ *binary: /d' 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 # Pre-clone the app into fdroid's build dir: fdroidserver computes
@ -178,13 +281,20 @@ jobs:
# by the insteadOf rule above). # by the insteadOf rule above).
mkdir -p build mkdir -p build
git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane
git -C build/org.comunes.tane checkout -q "${GITHUB_SHA}" git -C build/org.comunes.tane checkout -q "${BUILD_REF}"
# 5) Build ONLY this ABI's split with F-Droid's own toolchain. Splitting # 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 # 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 — # one job) is what keeps each build inside the runner's max-job-runtime —
# each needs a full cold NDK+tesseract-native+flutter compile. # each needs a full cold NDK+tesseract-native+flutter compile.
fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" # 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. # `fdroid build` exits 0 even when builds fail — demand the APK.
f="unsigned/org.comunes.tane_${vc}.apk" f="unsigned/org.comunes.tane_${vc}.apk"
@ -202,27 +312,48 @@ jobs:
fi fi
echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks
out="/tmp/app-${ABI}-release.apk" 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" \ "$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \
--ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \ --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" --out "$out" "$f"
# Prove the signer cert matches AllowedAPKSigningKeys. # Prove the signer cert matches AllowedAPKSigningKeys.
"$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true "$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true
rm -f /tmp/ks.jks 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}" api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}"
tag="${GITHUB_REF_NAME}" UPLOAD_TAG=""
if [ "${GITHUB_REF_TYPE:-}" = tag ]; then 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" \ curl -sf -X POST "$api/releases" \
-H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \ -H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${tag}\",\"name\":\"${tag}\"}" >/dev/null || true -d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true
rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${tag}" \ rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') | 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" \ curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-F "attachment=@${out};type=application/vnd.android.package-archive" -F "attachment=@${out};type=application/vnd.android.package-archive"
echo "uploaded app-${ABI}-release.apk (from ${f##*/})" echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})"
else else
echo "built+signed app-${ABI}-release.apk (dispatch: upload skipped)" echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)"
fi fi
fdroid_reference_arm64_v8a: fdroid_reference_arm64_v8a:
@ -238,6 +369,7 @@ jobs:
TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }} TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }}
TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }} TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }}
TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }} TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }}
REF_TAG: ${{ github.event.inputs.ref_tag }}
ABI: arm64-v8a ABI: arm64-v8a
OFFSET: 2 OFFSET: 2
run: | run: |
@ -246,33 +378,54 @@ jobs:
. /etc/profile.d/bsenv.sh . /etc/profile.d/bsenv.sh
fds=/opt/fdroidserver-src fds=/opt/fdroidserver-src
mkdir -p "$fds" mkdir -p "$fds"
curl -sL https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \ # Download to a file with retries: a mid-transfer vSwitch blip used to
| tar -xz -C "$fds" --strip-components=1 # 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 PATH="$fds:$PATH"
export PYTHONPATH="$fds:$fds/examples" export PYTHONPATH="$fds:$fds/examples"
git config --global --add safe.directory '*' git config --global --add safe.directory '*'
git config --global url."http://x-access-token:${TOKEN}@forgejo:3000/".insteadOf "https://git.comunes.org/" 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 work=/tmp/tane-src
mkdir -p "$work" && cd "$work" mkdir -p "$work" && cd "$work"
git init -q . git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "${GITHUB_SHA}" git fetch -q --depth 1 origin "$BUILD_REF"
git checkout -q FETCH_HEAD git checkout -q FETCH_HEAD
base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml) base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml)
[ -n "$base" ] [ -n "$base" ]
vc=$((base * 10 + OFFSET)) vc=$((base * 10 + OFFSET))
fdd=/tmp/fdroiddata # 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" mkdir -p "$fdd/metadata" "$fdd/srclibs"
cp "$work/fdroid-ci/config.yml" "$fdd/config.yml" cp "$work/fdroid-ci/config.yml" "$fdd/config.yml"
cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/" cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/"
cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml" cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml"
cd "$fdd" cd "$fdd"
sed -i "s#^\( *commit: \).*#\1${GITHUB_SHA}#" metadata/org.comunes.tane.yml sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml
sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml
mkdir -p build mkdir -p build
git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane
git -C build/org.comunes.tane checkout -q "${GITHUB_SHA}" git -C build/org.comunes.tane checkout -q "${BUILD_REF}"
fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" # 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" f="unsigned/org.comunes.tane_${vc}.apk"
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo "expected $f, not found" >&2 echo "expected $f, not found" >&2
@ -286,25 +439,46 @@ jobs:
fi fi
echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks
out="/tmp/app-${ABI}-release.apk" 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" \ "$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \
--ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \ --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" --out "$out" "$f"
"$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true "$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true
rm -f /tmp/ks.jks 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}" api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}"
tag="${GITHUB_REF_NAME}" UPLOAD_TAG=""
if [ "${GITHUB_REF_TYPE:-}" = tag ]; then 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" \ curl -sf -X POST "$api/releases" \
-H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \ -H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${tag}\",\"name\":\"${tag}\"}" >/dev/null || true -d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true
rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${tag}" \ rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') | 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" \ curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-F "attachment=@${out};type=application/vnd.android.package-archive" -F "attachment=@${out};type=application/vnd.android.package-archive"
echo "uploaded app-${ABI}-release.apk (from ${f##*/})" echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})"
else else
echo "built+signed app-${ABI}-release.apk (dispatch: upload skipped)" echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)"
fi fi
fdroid_reference_x86_64: fdroid_reference_x86_64:
@ -320,6 +494,7 @@ jobs:
TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }} TANE_KEYSTORE_PASSWORD: ${{ secrets.TANE_KEYSTORE_PASSWORD }}
TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }} TANE_KEY_ALIAS: ${{ secrets.TANE_KEY_ALIAS }}
TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }} TANE_KEY_PASSWORD: ${{ secrets.TANE_KEY_PASSWORD }}
REF_TAG: ${{ github.event.inputs.ref_tag }}
ABI: x86_64 ABI: x86_64
OFFSET: 3 OFFSET: 3
run: | run: |
@ -328,33 +503,54 @@ jobs:
. /etc/profile.d/bsenv.sh . /etc/profile.d/bsenv.sh
fds=/opt/fdroidserver-src fds=/opt/fdroidserver-src
mkdir -p "$fds" mkdir -p "$fds"
curl -sL https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \ # Download to a file with retries: a mid-transfer vSwitch blip used to
| tar -xz -C "$fds" --strip-components=1 # 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 PATH="$fds:$PATH"
export PYTHONPATH="$fds:$fds/examples" export PYTHONPATH="$fds:$fds/examples"
git config --global --add safe.directory '*' git config --global --add safe.directory '*'
git config --global url."http://x-access-token:${TOKEN}@forgejo:3000/".insteadOf "https://git.comunes.org/" 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 work=/tmp/tane-src
mkdir -p "$work" && cd "$work" mkdir -p "$work" && cd "$work"
git init -q . git init -q .
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git" git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "${GITHUB_SHA}" git fetch -q --depth 1 origin "$BUILD_REF"
git checkout -q FETCH_HEAD git checkout -q FETCH_HEAD
base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml) base=$(sed -n -E 's/^version:.*\+([0-9]+)$/\1/p' apps/app_seeds/pubspec.yaml)
[ -n "$base" ] [ -n "$base" ]
vc=$((base * 10 + OFFSET)) vc=$((base * 10 + OFFSET))
fdd=/tmp/fdroiddata # 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" mkdir -p "$fdd/metadata" "$fdd/srclibs"
cp "$work/fdroid-ci/config.yml" "$fdd/config.yml" cp "$work/fdroid-ci/config.yml" "$fdd/config.yml"
cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/" cp "$work"/fdroid-ci/srclibs/*.yml "$fdd/srclibs/"
cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml" cp "$work/docs/fdroid/org.comunes.tane.yml" "$fdd/metadata/org.comunes.tane.yml"
cd "$fdd" cd "$fdd"
sed -i "s#^\( *commit: \).*#\1${GITHUB_SHA}#" metadata/org.comunes.tane.yml sed -i "s#^\( *commit: \).*#\1${BUILD_REF}#" metadata/org.comunes.tane.yml
sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml sed -i '/^ *binary: /d' metadata/org.comunes.tane.yml
mkdir -p build mkdir -p build
git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane git clone -q https://git.comunes.org/comunes/tane.git build/org.comunes.tane
git -C build/org.comunes.tane checkout -q "${GITHUB_SHA}" git -C build/org.comunes.tane checkout -q "${BUILD_REF}"
fdroid build --verbose --no-tarball "org.comunes.tane:${vc}" # 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" f="unsigned/org.comunes.tane_${vc}.apk"
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo "expected $f, not found" >&2 echo "expected $f, not found" >&2
@ -368,23 +564,44 @@ jobs:
fi fi
echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks echo "$TANE_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks
out="/tmp/app-${ABI}-release.apk" 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" \ "$apksigner" sign --ks /tmp/ks.jks --ks-key-alias "$TANE_KEY_ALIAS" \
--ks-pass "pass:$TANE_KEYSTORE_PASSWORD" --key-pass "pass:$TANE_KEY_PASSWORD" \ --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" --out "$out" "$f"
"$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true "$apksigner" verify --print-certs "$out" | grep -i 'SHA-256' || true
rm -f /tmp/ks.jks 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}" api="http://forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}"
tag="${GITHUB_REF_NAME}" UPLOAD_TAG=""
if [ "${GITHUB_REF_TYPE:-}" = tag ]; then 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" \ curl -sf -X POST "$api/releases" \
-H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \ -H "Authorization: token ${TOKEN}" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${tag}\",\"name\":\"${tag}\"}" >/dev/null || true -d "{\"tag_name\":\"${UPLOAD_TAG}\",\"name\":\"${UPLOAD_TAG}\"}" >/dev/null || true
rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${tag}" \ rid=$(curl -sf -H "Authorization: token ${TOKEN}" "$api/releases/tags/${UPLOAD_TAG}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') | 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" \ curl -sf -X POST "$api/releases/${rid}/assets?name=app-${ABI}-release.apk" \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-F "attachment=@${out};type=application/vnd.android.package-archive" -F "attachment=@${out};type=application/vnd.android.package-archive"
echo "uploaded app-${ABI}-release.apk (from ${f##*/})" echo "uploaded app-${ABI}-release.apk to ${UPLOAD_TAG} (from ${f##*/})"
else else
echo "built+signed app-${ABI}-release.apk (dispatch: upload skipped)" echo "built+signed app-${ABI}-release.apk (dispatch: no ref_tag, upload skipped)"
fi fi

3
.gitignore vendored
View file

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

View file

@ -9,9 +9,16 @@ The name honors the old Japanese mutual-aid traditions around rice — *yui* (sh
- Web: https://tane.comunes.org - Web: https://tane.comunes.org
- Package id: `org.comunes.tane` - 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 ## 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 ## Principles

View file

@ -97,6 +97,17 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") 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 // 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 // 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 // tell them apart; `flutter build apk --split-per-abi` alone gives every

View file

@ -6,8 +6,14 @@
# references and would strip/rename them. Keep them explicitly. Start # references and would strip/rename them. Keep them explicitly. Start
# conservative; trim only after a release smoke test proves a class is unused. # conservative; trim only after a release smoke test proves a class is unused.
# --- Flutter engine & embedding (defensive; usually handled by the plugin) --- # --- Flutter engine & embedding ---
-keep class io.flutter.** { *; } # 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.** { *; } -keep class io.flutter.plugins.** { *; }
-dontwarn io.flutter.embedding.** -dontwarn io.flutter.embedding.**
@ -38,3 +44,9 @@
# --- Core library desugaring --- # --- Core library desugaring ---
-dontwarn java.lang.invoke.** -dontwarn java.lang.invoke.**
-dontwarn build.IgnoreJava8API -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,4 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 <!-- Coarse location only, for the optional "use my location" sharing area
(reduced to a low-precision geohash; never a precise fix). --> (reduced to a low-precision geohash; never a precise fix). -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

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

@ -13,8 +13,11 @@ MANAGE YOUR BANK
• Search and filter; snap a photo now and name it later. • Search and filter; snap a photo now and name it later.
YOURS, AND ONLY YOURS 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. • 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 • Save an encrypted backup and keep a printed recovery sheet — restore your
whole bank on a new device. 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. • Mark what you have spare to give away, swap or sell.
• Print a catalog of what you share to take to a seed fair. • 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 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 — it exists to support traditional varieties and push back against the seed
monopoly. 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

@ -13,8 +13,11 @@ GESTIONA TU BANCO
• Busca y filtra; haz una foto ahora y ponle nombre después. • Busca y filtra; haz una foto ahora y ponle nombre después.
TUYO, Y SOLO TUYO 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. • 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 • Guarda una copia cifrada y ten una hoja de recuperación impresa: recupera
todo tu banco en un dispositivo nuevo. 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. • 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. • 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 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 de negocio: existe para apoyar las variedades tradicionales y plantar cara al
monopolio de las semillas. monopolio de las semillas.

View file

@ -23,6 +23,7 @@ import 'services/social_account_store.dart';
import 'services/social_connection.dart'; import 'services/social_connection.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
import 'services/social_settings.dart'; import 'services/social_settings.dart';
import 'services/sharing_switch.dart';
import 'state/inventory_cubit.dart'; import 'state/inventory_cubit.dart';
import 'state/variety_detail_cubit.dart'; import 'state/variety_detail_cubit.dart';
import 'ui/about_screen.dart'; import 'ui/about_screen.dart';
@ -70,6 +71,7 @@ class TaneApp extends StatelessWidget {
this.notifications, this.notifications,
this.showIntro = false, this.showIntro = false,
this.autoBackup, this.autoBackup,
SharingSwitch? sharing,
super.key, super.key,
}) : _router = _buildRouter( }) : _router = _buildRouter(
repository, repository,
@ -87,6 +89,17 @@ class TaneApp extends StatelessWidget {
savedSearches, savedSearches,
socialAccounts, socialAccounts,
inbox, 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 // 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, // the router only exists now; taps only happen while the app is foreground,
@ -161,14 +174,17 @@ class TaneApp extends StatelessWidget {
SavedSearchesStore? savedSearches, SavedSearchesStore? savedSearches,
SocialAccountStore? socialAccounts, SocialAccountStore? socialAccounts,
InboxService? inbox, InboxService? inbox,
SharingSwitch? sharing,
) { ) {
return GoRouter( return GoRouter(
initialLocation: showIntro ? '/intro' : '/', initialLocation: showIntro ? '/intro' : '/',
routes: [ routes: [
GoRoute( GoRoute(
path: '/', 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) => builder: (context, state) =>
HomeScreen(marketEnabled: social != null), HomeScreen(sharing: sharing, onboarding: onboarding),
), ),
if (social != null && socialSettings != null && connection != null) if (social != null && socialSettings != null && connection != null)
GoRoute( GoRoute(
@ -181,6 +197,7 @@ class TaneApp extends StatelessWidget {
outbox: outbox, outbox: outbox,
onboarding: onboarding, onboarding: onboarding,
savedSearches: savedSearches, savedSearches: savedSearches,
sharing: sharing,
), ),
), ),
if (social != null && connection != null) if (social != null && connection != null)

View file

@ -23,6 +23,7 @@ import 'services/profile_store.dart';
import 'services/saved_offers_store.dart'; import 'services/saved_offers_store.dart';
import 'services/saved_search_alert_service.dart'; import 'services/saved_search_alert_service.dart';
import 'services/saved_searches_store.dart'; import 'services/saved_searches_store.dart';
import 'services/sharing_switch.dart';
import 'services/social_account_store.dart'; import 'services/social_account_store.dart';
import 'services/social_connection.dart'; import 'services/social_connection.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
@ -81,14 +82,24 @@ class _BootstrapState extends State<Bootstrap> {
final savedSearchAlerts = getIt.isRegistered<SavedSearchAlertService>() final savedSearchAlerts = getIt.isRegistered<SavedSearchAlertService>()
? getIt<SavedSearchAlertService>() ? getIt<SavedSearchAlertService>()
: null; : 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 // Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the
// shared connection starts connecting, so the first session is caught; then // shared connection starts connecting, so the first session is caught; then
// bring the connection up. // bring the connection up. The listeners are harmless while sharing is off:
// they only ever react to a session, and none arrives.
inbox?.start(); inbox?.start();
sync?.start(); sync?.start();
plantares?.start(); plantares?.start();
savedSearchAlerts?.start(); savedSearchAlerts?.start();
connection?.start(); if (sharingOn) connection?.start();
return TaneApp( return TaneApp(
repository: getIt<VarietyRepository>(), repository: getIt<VarietyRepository>(),
@ -108,7 +119,12 @@ class _BootstrapState extends State<Bootstrap> {
socialAccounts: getIt<SocialAccountStore>(), socialAccounts: getIt<SocialAccountStore>(),
inbox: inbox, inbox: inbox,
notifications: notifications, notifications: notifications,
showIntro: !await onboarding.introSeen(), showIntro: !introSeen,
sharing: SharingSwitch(
settings: getIt<SocialSettings>(),
connection: connection,
enabled: sharingOn,
),
autoBackup: getIt.isRegistered<AutoBackupService>() autoBackup: getIt.isRegistered<AutoBackupService>()
? getIt<AutoBackupService>() ? getIt<AutoBackupService>()
: null, : null,

View file

@ -58,8 +58,7 @@
"cancel": "Encaboxar", "cancel": "Encaboxar",
"delete": "Desaniciar", "delete": "Desaniciar",
"edit": "Editar", "edit": "Editar",
"type": "Triba", "type": "Triba"
"comingSoon": "Aína"
}, },
"home": { "home": {
"tagline": "Comparte y cultiva simiente llocal", "tagline": "Comparte y cultiva simiente llocal",
@ -516,7 +515,7 @@
"contact": "Mensaxe", "contact": "Mensaxe",
"mine": "Tu", "mine": "Tu",
"configTitle": "Configuración de compartir", "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", "areaLabel": "La to zona",
"areaHelp": "Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.", "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", "areaSet": "La to zona ta puesta — averada, enxamás el to puntu esactu",

View file

@ -59,7 +59,6 @@
"delete": "Löschen", "delete": "Löschen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"type": "Typ", "type": "Typ",
"comingSoon": "Bald",
"offline": "Offline - Teilen ist unterbrochen" "offline": "Offline - Teilen ist unterbrochen"
}, },
"home": { "home": {
@ -519,7 +518,7 @@
"contact": "Nachricht", "contact": "Nachricht",
"mine": "Du", "mine": "Du",
"configTitle": "Teilen-Einrichtung", "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", "areaLabel": "Dein Gebiet",
"areaHelp": "Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.", "areaHelp": "Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.",
"areaSet": "Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt", "areaSet": "Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt",

View file

@ -72,7 +72,6 @@
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
"type": "Type", "type": "Type",
"comingSoon": "Coming soon",
"offline": "You're offline — sharing is paused" "offline": "You're offline — sharing is paused"
}, },
"home": { "home": {
@ -541,7 +540,7 @@
"contact": "Message", "contact": "Message",
"mine": "You", "mine": "You",
"configTitle": "Sharing setup", "configTitle": "Sharing setup",
"setupIntro": "Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.", "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.",
"areaLabel": "Your area", "areaLabel": "Your area",
"areaHelp": "Kept rough on purpose — your zone, never an exact spot.", "areaHelp": "Kept rough on purpose — your zone, never an exact spot.",
"areaSet": "Your area is set — kept coarse, never your exact spot", "areaSet": "Your area is set — kept coarse, never your exact spot",
@ -572,7 +571,9 @@
"noProfile": "This person hasn't shared a profile yet", "noProfile": "This person hasn't shared a profile yet",
"copyId": "Copy code", "copyId": "Copy code",
"idCopied": "Code copied", "idCopied": "Code copied",
"photo": "Photo" "photo": "Photo",
"sharingOnLabel": "Sharing is on",
"sharingOnHelp": "Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same."
}, },
"profile": { "profile": {
"title": "Your profile", "title": "Your profile",
@ -780,7 +781,8 @@
"publicNote": "What you publish here is public, and copies may remain even if you remove it later.", "publicNote": "What you publish here is public, and copies may remain even if you remove it later.",
"viewLegal": "Privacy & rules", "viewLegal": "Privacy & rules",
"accept": "I agree", "accept": "I agree",
"decline": "Not now" "decline": "Not now",
"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": { "report": {
"offer": "Report this offer", "offer": "Report this offer",
@ -806,5 +808,14 @@
"manageTitle": "Blocked people", "manageTitle": "Blocked people",
"manageEmpty": "You haven't blocked anyone", "manageEmpty": "You haven't blocked anyone",
"unblock": "Unblock" "unblock": "Unblock"
},
"sharingInvite": {
"title": "This wakes up when you start sharing",
"perkChat": "Write to whoever has seeds near you",
"perkFavorites": "Keep the offers you like",
"perkPeople": "Your circle of people you trust",
"networkNote": "For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.",
"start": "Start sharing",
"notNow": "Not now"
} }
} }

View file

@ -72,7 +72,6 @@
"delete": "Eliminar", "delete": "Eliminar",
"edit": "Editar", "edit": "Editar",
"type": "Tipo", "type": "Tipo",
"comingSoon": "Pronto",
"offline": "Sin conexión — el compartir está en pausa" "offline": "Sin conexión — el compartir está en pausa"
}, },
"home": { "home": {
@ -540,7 +539,7 @@
"contact": "Mensaje", "contact": "Mensaje",
"mine": "Tú", "mine": "Tú",
"configTitle": "Configuración de compartir", "configTitle": "Configuración de compartir",
"setupIntro": "Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.", "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.",
"areaLabel": "Tu zona", "areaLabel": "Tu zona",
"areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.", "areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.",
"areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto", "areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto",
@ -571,7 +570,9 @@
"noProfile": "Esta persona aún no ha compartido su perfil", "noProfile": "Esta persona aún no ha compartido su perfil",
"copyId": "Copiar código", "copyId": "Copiar código",
"idCopied": "Código copiado", "idCopied": "Código copiado",
"photo": "Foto" "photo": "Foto",
"sharingOnLabel": "Compartir está activado",
"sharingOnHelp": "Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual."
}, },
"profile": { "profile": {
"title": "Tu perfil", "title": "Tu perfil",
@ -779,7 +780,8 @@
"publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.", "publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.",
"viewLegal": "Privacidad y normas", "viewLegal": "Privacidad y normas",
"accept": "Acepto", "accept": "Acepto",
"decline": "Ahora no" "decline": "Ahora no",
"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": { "report": {
"offer": "Denunciar esta oferta", "offer": "Denunciar esta oferta",
@ -805,5 +807,14 @@
"manageTitle": "Personas bloqueadas", "manageTitle": "Personas bloqueadas",
"manageEmpty": "No has bloqueado a nadie", "manageEmpty": "No has bloqueado a nadie",
"unblock": "Desbloquear" "unblock": "Desbloquear"
},
"sharingInvite": {
"title": "Esto se enciende cuando empiezas a compartir",
"perkChat": "Escribirte con quien tiene semillas cerca",
"perkFavorites": "Guardar las ofertas que te gustan",
"perkPeople": "Tu círculo de gente de confianza",
"networkNote": "Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.",
"start": "Empezar a compartir",
"notNow": "Ahora no"
} }
} }

View file

@ -59,7 +59,6 @@
"delete": "Supprimer", "delete": "Supprimer",
"edit": "Modifier", "edit": "Modifier",
"type": "Type", "type": "Type",
"comingSoon": "À venir",
"offline": "Vous êtes hors ligne — le partage est en pause" "offline": "Vous êtes hors ligne — le partage est en pause"
}, },
"home": { "home": {
@ -519,7 +518,7 @@
"contact": "Message", "contact": "Message",
"mine": "Vous", "mine": "Vous",
"configTitle": "Configuration du partage", "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", "areaLabel": "Votre zone",
"areaHelp": "Gardée approximative volontairement — votre zone, jamais un point exact.", "areaHelp": "Gardée approximative volontairement — votre zone, jamais un point exact.",
"areaSet": "Votre zone est définie — approximative, jamais votre point exact", "areaSet": "Votre zone est définie — approximative, jamais votre point exact",

View file

@ -8,7 +8,6 @@
"delete": "削除", "delete": "削除",
"edit": "編集", "edit": "編集",
"type": "種類", "type": "種類",
"comingSoon": "近日公開",
"offline": "オフラインです — 共有を一時停止しています" "offline": "オフラインです — 共有を一時停止しています"
}, },
"menu": { "menu": {

View file

@ -72,7 +72,6 @@
"delete": "Eliminar", "delete": "Eliminar",
"edit": "Editar", "edit": "Editar",
"type": "Tipo", "type": "Tipo",
"comingSoon": "Em breve",
"offline": "Sem ligação — a partilha está em pausa" "offline": "Sem ligação — a partilha está em pausa"
}, },
"home": { "home": {
@ -541,7 +540,7 @@
"contact": "Mensagem", "contact": "Mensagem",
"mine": "Tu", "mine": "Tu",
"configTitle": "Configuração de partilha", "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", "areaLabel": "A tua zona",
"areaHelp": "Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.", "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", "areaSet": "A tua zona está definida — aproximada, nunca o teu ponto exato",

View file

@ -72,7 +72,6 @@
"delete": "Eliminar", "delete": "Eliminar",
"edit": "Editar", "edit": "Editar",
"type": "Tipo", "type": "Tipo",
"comingSoon": "Em breve",
"offline": "Sem ligação — a compartilha está em pausa" "offline": "Sem ligação — a compartilha está em pausa"
}, },
"home": { "home": {
@ -541,7 +540,7 @@
"contact": "Mensagem", "contact": "Mensagem",
"mine": "Você", "mine": "Você",
"configTitle": "Configuração de compartilha", "configTitle": "Configuração de compartilha",
"setupIntro": "Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — já está conectado a servidores comunitários compartilhados para que outras pessoas encontrem o que você oferece, sem nenhuma empresa no meio.", "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", "areaLabel": "A sua zona",
"areaHelp": "Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.", "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", "areaSet": "A sua zona está definida — aproximada, nunca o seu ponto exato",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 8 /// Locales: 8
/// Strings: 4287 (535 per locale) /// Strings: 4299 (537 per locale)
/// ///
/// Built on 2026-07-20 at 21:06 UTC /// Built on 2026-07-25 at 14:38 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -199,7 +199,6 @@ class _Translations$common$ast extends Translations$common$en {
@override String get delete => 'Desaniciar'; @override String get delete => 'Desaniciar';
@override String get edit => 'Editar'; @override String get edit => 'Editar';
@override String get type => 'Triba'; @override String get type => 'Triba';
@override String get comingSoon => 'Aína';
} }
// Path: home // Path: home
@ -766,7 +765,7 @@ class _Translations$market$ast extends Translations$market$en {
@override String get contact => 'Mensaxe'; @override String get contact => 'Mensaxe';
@override String get mine => 'Tu'; @override String get mine => 'Tu';
@override String get configTitle => 'Configuración de compartir'; @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 areaLabel => 'La to zona';
@override String get areaHelp => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.'; @override String get areaHelp => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.';
@override String get areaSet => 'La to zona ta puesta — averada, enxamás el to puntu esactu'; @override String get areaSet => 'La to zona ta puesta — averada, enxamás el to puntu esactu';
@ -1465,7 +1464,6 @@ extension on TranslationsAst {
'common.delete' => 'Desaniciar', 'common.delete' => 'Desaniciar',
'common.edit' => 'Editar', 'common.edit' => 'Editar',
'common.type' => 'Triba', 'common.type' => 'Triba',
'common.comingSoon' => 'Aína',
'home.tagline' => 'Comparte y cultiva simiente llocal', 'home.tagline' => 'Comparte y cultiva simiente llocal',
'home.openMarket' => 'Mercáu', 'home.openMarket' => 'Mercáu',
'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca', 'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca',
@ -1802,7 +1800,7 @@ extension on TranslationsAst {
'market.contact' => 'Mensaxe', 'market.contact' => 'Mensaxe',
'market.mine' => 'Tu', 'market.mine' => 'Tu',
'market.configTitle' => 'Configuración de compartir', '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.areaLabel' => 'La to zona',
'market.areaHelp' => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.', 'market.areaHelp' => 'Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.',
'market.areaSet' => 'La to zona ta puesta — averada, enxamás el to puntu esactu', 'market.areaSet' => 'La to zona ta puesta — averada, enxamás el to puntu esactu',
@ -1930,9 +1928,9 @@ extension on TranslationsAst {
'handover.promiseGave' => 'Van devolveme semiente', 'handover.promiseGave' => 'Van devolveme semiente',
'handover.promiseReceived' => 'Voi devolver semiente', 'handover.promiseReceived' => 'Voi devolver semiente',
'sale.title' => 'Ventes', 'sale.title' => 'Ventes',
'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.',
'sale.add' => 'Rexistrar venta', 'sale.add' => 'Rexistrar venta',
'sale.empty' => 'Entá nun hai ventes. Anota equí lo que vendes o merques.', 'sale.empty' => 'Entá nun hai ventes. Anota equí lo que vendes o merques.',
'sale.iSold' => 'Vendí', 'sale.iSold' => 'Vendí',

View file

@ -199,7 +199,6 @@ class _Translations$common$de extends Translations$common$en {
@override String get delete => 'Löschen'; @override String get delete => 'Löschen';
@override String get edit => 'Bearbeiten'; @override String get edit => 'Bearbeiten';
@override String get type => 'Typ'; @override String get type => 'Typ';
@override String get comingSoon => 'Bald';
@override String get offline => 'Offline - Teilen ist unterbrochen'; @override String get offline => 'Offline - Teilen ist unterbrochen';
} }
@ -769,7 +768,7 @@ class _Translations$market$de extends Translations$market$en {
@override String get contact => 'Nachricht'; @override String get contact => 'Nachricht';
@override String get mine => 'Du'; @override String get mine => 'Du';
@override String get configTitle => 'Teilen-Einrichtung'; @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 areaLabel => 'Dein Gebiet';
@override String get areaHelp => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.'; @override String get areaHelp => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.';
@override String get areaSet => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt'; @override String get areaSet => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt';
@ -1461,7 +1460,6 @@ extension on TranslationsDe {
'common.delete' => 'Löschen', 'common.delete' => 'Löschen',
'common.edit' => 'Bearbeiten', 'common.edit' => 'Bearbeiten',
'common.type' => 'Typ', 'common.type' => 'Typ',
'common.comingSoon' => 'Bald',
'common.offline' => 'Offline - Teilen ist unterbrochen', 'common.offline' => 'Offline - Teilen ist unterbrochen',
'home.tagline' => 'Teile und baue lokale Samen an', 'home.tagline' => 'Teile und baue lokale Samen an',
'home.openMarket' => 'Markt', 'home.openMarket' => 'Markt',
@ -1801,7 +1799,7 @@ extension on TranslationsDe {
'market.contact' => 'Nachricht', 'market.contact' => 'Nachricht',
'market.mine' => 'Du', 'market.mine' => 'Du',
'market.configTitle' => 'Teilen-Einrichtung', '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.areaLabel' => 'Dein Gebiet',
'market.areaHelp' => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.', 'market.areaHelp' => 'Absichtlich grob gehalten - deine Zone, nie ein genauer Punkt.',
'market.areaSet' => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt', 'market.areaSet' => 'Dein Gebiet ist gesetzt - grob, nie dein genauer Punkt',
@ -1926,9 +1924,9 @@ extension on TranslationsDe {
'sale.add' => 'Verkauf notieren', 'sale.add' => 'Verkauf notieren',
'sale.empty' => 'Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.', 'sale.empty' => 'Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.',
'sale.iSold' => 'Ich verkaufte', 'sale.iSold' => 'Ich verkaufte',
'sale.iBought' => 'Ich kaufte',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'sale.iBought' => 'Ich kaufte',
'sale.direction' => 'Verkauft oder gekauft?', 'sale.direction' => 'Verkauft oder gekauft?',
'sale.counterparty' => 'Mit wem?', 'sale.counterparty' => 'Mit wem?',
'sale.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)', 'sale.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)',

View file

@ -93,6 +93,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$marketGate$en marketGate = Translations$marketGate$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$report$en report = Translations$report$en.internal(_root);
late final Translations$block$en block = Translations$block$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 // Path: avatar
@ -340,9 +341,6 @@ class Translations$common$en {
/// en: 'Type' /// en: 'Type'
String get type => 'Type'; String get type => 'Type';
/// en: 'Coming soon'
String get comingSoon => 'Coming soon';
/// en: 'You're offline sharing is paused' /// en: 'You're offline sharing is paused'
String get offline => 'You\'re offline — sharing is paused'; String get offline => 'You\'re offline — sharing is paused';
} }
@ -1483,8 +1481,8 @@ class Translations$market$en {
/// en: 'Sharing setup' /// en: 'Sharing setup'
String get configTitle => '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.' /// 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 — 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 — 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' /// en: 'Your area'
String get areaLabel => 'Your area'; String get areaLabel => 'Your area';
@ -1578,6 +1576,12 @@ class Translations$market$en {
/// en: 'Photo' /// en: 'Photo'
String get photo => '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 // Path: profile
@ -2241,6 +2245,9 @@ class Translations$marketGate$en {
/// en: 'Not now' /// en: 'Not now'
String get decline => '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 // Path: report
@ -2324,6 +2331,36 @@ class Translations$block$en {
String get unblock => 'Unblock'; 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 // Path: intro.slides
class Translations$intro$slides$en { class Translations$intro$slides$en {
Translations$intro$slides$en.internal(this._root); Translations$intro$slides$en.internal(this._root);
@ -2839,7 +2876,6 @@ extension on Translations {
'common.delete' => 'Delete', 'common.delete' => 'Delete',
'common.edit' => 'Edit', 'common.edit' => 'Edit',
'common.type' => 'Type', 'common.type' => 'Type',
'common.comingSoon' => 'Coming soon',
'common.offline' => 'You\'re offline — sharing is paused', 'common.offline' => 'You\'re offline — sharing is paused',
'home.tagline' => 'Share and grow local seeds', 'home.tagline' => 'Share and grow local seeds',
'home.openMarket' => 'Market', 'home.openMarket' => 'Market',
@ -3186,7 +3222,7 @@ extension on Translations {
'market.contact' => 'Message', 'market.contact' => 'Message',
'market.mine' => 'You', 'market.mine' => 'You',
'market.configTitle' => 'Sharing setup', '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.areaLabel' => 'Your area',
'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.', 'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.',
'market.areaSet' => 'Your area is set — kept coarse, never your exact spot', 'market.areaSet' => 'Your area is set — kept coarse, never your exact spot',
@ -3218,6 +3254,8 @@ extension on Translations {
'market.copyId' => 'Copy code', 'market.copyId' => 'Copy code',
'market.idCopied' => 'Code copied', 'market.idCopied' => 'Code copied',
'market.photo' => 'Photo', '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.title' => 'Your profile',
'profile.name' => 'Display name', 'profile.name' => 'Display name',
'profile.nameHint' => 'How others see you', 'profile.nameHint' => 'How others see you',
@ -3292,9 +3330,9 @@ extension on Translations {
'plantare.delete' => 'Remove', 'plantare.delete' => 'Remove',
'plantare.statusReturned' => 'Returned', 'plantare.statusReturned' => 'Returned',
'plantare.statusForgiven' => 'Settled', 'plantare.statusForgiven' => 'Settled',
'plantare.openSection' => 'Open',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'plantare.openSection' => 'Open',
'plantare.settledSection' => 'Done', 'plantare.settledSection' => 'Done',
'plantare.removeConfirm' => 'Remove this commitment?', 'plantare.removeConfirm' => 'Remove this commitment?',
'plantare.returnBy' => ({required Object date}) => 'Return by ${date}', 'plantare.returnBy' => ({required Object date}) => 'Return by ${date}',
@ -3402,6 +3440,7 @@ extension on Translations {
'marketGate.viewLegal' => 'Privacy & rules', 'marketGate.viewLegal' => 'Privacy & rules',
'marketGate.accept' => 'I agree', 'marketGate.accept' => 'I agree',
'marketGate.decline' => 'Not now', '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.offer' => 'Report this offer',
'report.person' => 'Report this person', 'report.person' => 'Report this person',
'report.title' => 'Report', 'report.title' => 'Report',
@ -3423,6 +3462,13 @@ extension on Translations {
'block.manageTitle' => 'Blocked people', 'block.manageTitle' => 'Blocked people',
'block.manageEmpty' => 'You haven\'t blocked anyone', 'block.manageEmpty' => 'You haven\'t blocked anyone',
'block.unblock' => 'Unblock', '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, _ => null,
}; };
} }

View file

@ -92,6 +92,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$marketGate$es marketGate = _Translations$marketGate$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$report$es report = _Translations$report$es._(_root);
@override late final _Translations$block$es block = _Translations$block$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 // Path: avatar
@ -222,7 +223,6 @@ class _Translations$common$es extends Translations$common$en {
@override String get delete => 'Eliminar'; @override String get delete => 'Eliminar';
@override String get edit => 'Editar'; @override String get edit => 'Editar';
@override String get type => 'Tipo'; @override String get type => 'Tipo';
@override String get comingSoon => 'Pronto';
@override String get offline => 'Sin conexión — el compartir está en pausa'; @override String get offline => 'Sin conexión — el compartir está en pausa';
} }
@ -807,7 +807,7 @@ class _Translations$market$es extends Translations$market$en {
@override String get contact => 'Mensaje'; @override String get contact => 'Mensaje';
@override String get mine => ''; @override String get mine => '';
@override String get configTitle => 'Configuración de compartir'; @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 areaLabel => 'Tu zona';
@override String get areaHelp => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.'; @override String get areaHelp => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.';
@override String get areaSet => 'Tu zona está puesta — aproximada, nunca tu punto exacto'; @override String get areaSet => 'Tu zona está puesta — aproximada, nunca tu punto exacto';
@ -839,6 +839,8 @@ class _Translations$market$es extends Translations$market$en {
@override String get copyId => 'Copiar código'; @override String get copyId => 'Copiar código';
@override String get idCopied => 'Código copiado'; @override String get idCopied => 'Código copiado';
@override String get photo => 'Foto'; @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 // Path: profile
@ -1138,6 +1140,7 @@ class _Translations$marketGate$es extends Translations$marketGate$en {
@override String get viewLegal => 'Privacidad y normas'; @override String get viewLegal => 'Privacidad y normas';
@override String get accept => 'Acepto'; @override String get accept => 'Acepto';
@override String get decline => 'Ahora no'; @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 // Path: report
@ -1179,6 +1182,22 @@ class _Translations$block$es extends Translations$block$en {
@override String get unblock => 'Desbloquear'; @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 // Path: intro.slides
class _Translations$intro$slides$es extends Translations$intro$slides$en { class _Translations$intro$slides$es extends Translations$intro$slides$en {
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1578,7 +1597,6 @@ extension on TranslationsEs {
'common.delete' => 'Eliminar', 'common.delete' => 'Eliminar',
'common.edit' => 'Editar', 'common.edit' => 'Editar',
'common.type' => 'Tipo', 'common.type' => 'Tipo',
'common.comingSoon' => 'Pronto',
'common.offline' => 'Sin conexión — el compartir está en pausa', 'common.offline' => 'Sin conexión — el compartir está en pausa',
'home.tagline' => 'Comparte y cultiva semillas locales', 'home.tagline' => 'Comparte y cultiva semillas locales',
'home.openMarket' => 'Mercado', 'home.openMarket' => 'Mercado',
@ -1924,7 +1942,7 @@ extension on TranslationsEs {
'market.contact' => 'Mensaje', 'market.contact' => 'Mensaje',
'market.mine' => '', 'market.mine' => '',
'market.configTitle' => 'Configuración de compartir', '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.areaLabel' => 'Tu zona',
'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.', 'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.',
'market.areaSet' => 'Tu zona está puesta — aproximada, nunca tu punto exacto', 'market.areaSet' => 'Tu zona está puesta — aproximada, nunca tu punto exacto',
@ -1956,6 +1974,8 @@ extension on TranslationsEs {
'market.copyId' => 'Copiar código', 'market.copyId' => 'Copiar código',
'market.idCopied' => 'Código copiado', 'market.idCopied' => 'Código copiado',
'market.photo' => 'Foto', '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.title' => 'Tu perfil',
'profile.name' => 'Nombre', 'profile.name' => 'Nombre',
'profile.nameHint' => 'Cómo te ven los demás', 'profile.nameHint' => 'Cómo te ven los demás',
@ -2031,9 +2051,9 @@ extension on TranslationsEs {
'plantare.statusReturned' => 'Devuelto', 'plantare.statusReturned' => 'Devuelto',
'plantare.statusForgiven' => 'Saldado', 'plantare.statusForgiven' => 'Saldado',
'plantare.openSection' => 'Pendientes', 'plantare.openSection' => 'Pendientes',
'plantare.settledSection' => 'Hechos',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'plantare.settledSection' => 'Hechos',
'plantare.removeConfirm' => '¿Quitar este compromiso?', 'plantare.removeConfirm' => '¿Quitar este compromiso?',
'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}', 'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}',
'plantare.overdue' => 'vencido', 'plantare.overdue' => 'vencido',
@ -2140,6 +2160,7 @@ extension on TranslationsEs {
'marketGate.viewLegal' => 'Privacidad y normas', 'marketGate.viewLegal' => 'Privacidad y normas',
'marketGate.accept' => 'Acepto', 'marketGate.accept' => 'Acepto',
'marketGate.decline' => 'Ahora no', '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.offer' => 'Denunciar esta oferta',
'report.person' => 'Denunciar a esta persona', 'report.person' => 'Denunciar a esta persona',
'report.title' => 'Denunciar', 'report.title' => 'Denunciar',
@ -2161,6 +2182,13 @@ extension on TranslationsEs {
'block.manageTitle' => 'Personas bloqueadas', 'block.manageTitle' => 'Personas bloqueadas',
'block.manageEmpty' => 'No has bloqueado a nadie', 'block.manageEmpty' => 'No has bloqueado a nadie',
'block.unblock' => 'Desbloquear', '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, _ => null,
}; };
} }

View file

@ -199,7 +199,6 @@ class _Translations$common$fr extends Translations$common$en {
@override String get delete => 'Supprimer'; @override String get delete => 'Supprimer';
@override String get edit => 'Modifier'; @override String get edit => 'Modifier';
@override String get type => 'Type'; @override String get type => 'Type';
@override String get comingSoon => 'À venir';
@override String get offline => 'Vous êtes hors ligne — le partage est en pause'; @override String get offline => 'Vous êtes hors ligne — le partage est en pause';
} }
@ -769,7 +768,7 @@ class _Translations$market$fr extends Translations$market$en {
@override String get contact => 'Message'; @override String get contact => 'Message';
@override String get mine => 'Vous'; @override String get mine => 'Vous';
@override String get configTitle => 'Configuration du partage'; @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 areaLabel => 'Votre zone';
@override String get areaHelp => 'Gardée approximative volontairement — votre zone, jamais un point exact.'; @override String get areaHelp => 'Gardée approximative volontairement — votre zone, jamais un point exact.';
@override String get areaSet => 'Votre zone est définie — approximative, jamais votre point exact'; @override String get areaSet => 'Votre zone est définie — approximative, jamais votre point exact';
@ -1461,7 +1460,6 @@ extension on TranslationsFr {
'common.delete' => 'Supprimer', 'common.delete' => 'Supprimer',
'common.edit' => 'Modifier', 'common.edit' => 'Modifier',
'common.type' => 'Type', 'common.type' => 'Type',
'common.comingSoon' => 'À venir',
'common.offline' => 'Vous êtes hors ligne — le partage est en pause', 'common.offline' => 'Vous êtes hors ligne — le partage est en pause',
'home.tagline' => 'Partagez et cultivez des graines locales', 'home.tagline' => 'Partagez et cultivez des graines locales',
'home.openMarket' => 'Marché', 'home.openMarket' => 'Marché',
@ -1801,7 +1799,7 @@ extension on TranslationsFr {
'market.contact' => 'Message', 'market.contact' => 'Message',
'market.mine' => 'Vous', 'market.mine' => 'Vous',
'market.configTitle' => 'Configuration du partage', '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.areaLabel' => 'Votre zone',
'market.areaHelp' => 'Gardée approximative volontairement — votre zone, jamais un point exact.', 'market.areaHelp' => 'Gardée approximative volontairement — votre zone, jamais un point exact.',
'market.areaSet' => 'Votre zone est définie — approximative, jamais votre point exact', 'market.areaSet' => 'Votre zone est définie — approximative, jamais votre point exact',
@ -1926,9 +1924,9 @@ extension on TranslationsFr {
'sale.add' => 'Enregistrer une vente', 'sale.add' => 'Enregistrer une vente',
'sale.empty' => 'Pas encore de ventes. Notez ici ce que vous vendez ou achetez.', 'sale.empty' => 'Pas encore de ventes. Notez ici ce que vous vendez ou achetez.',
'sale.iSold' => 'J\'ai vendu', 'sale.iSold' => 'J\'ai vendu',
'sale.iBought' => 'J\'ai acheté',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'sale.iBought' => 'J\'ai acheté',
'sale.direction' => 'Vendu ou acheté ?', 'sale.direction' => 'Vendu ou acheté ?',
'sale.counterparty' => 'Avec qui ?', 'sale.counterparty' => 'Avec qui ?',
'sale.counterpartyHint' => 'Une personne ou un collectif (optionnel)', 'sale.counterpartyHint' => 'Une personne ou un collectif (optionnel)',

View file

@ -68,7 +68,6 @@ class _Translations$common$ja extends Translations$common$en {
@override String get delete => '削除'; @override String get delete => '削除';
@override String get edit => '編集'; @override String get edit => '編集';
@override String get type => '種類'; @override String get type => '種類';
@override String get comingSoon => '近日公開';
@override String get offline => 'オフラインです — 共有を一時停止しています'; @override String get offline => 'オフラインです — 共有を一時停止しています';
} }
@ -140,7 +139,6 @@ extension on TranslationsJa {
'common.delete' => '削除', 'common.delete' => '削除',
'common.edit' => '編集', 'common.edit' => '編集',
'common.type' => '種類', 'common.type' => '種類',
'common.comingSoon' => '近日公開',
'common.offline' => 'オフラインです — 共有を一時停止しています', 'common.offline' => 'オフラインです — 共有を一時停止しています',
'menu.tagline' => 'あなたの種子バンク', 'menu.tagline' => 'あなたの種子バンク',
'menu.inventory' => '在庫', 'menu.inventory' => '在庫',

View file

@ -222,7 +222,6 @@ class Translations$common$pt extends Translations$common$en {
@override String get delete => 'Eliminar'; @override String get delete => 'Eliminar';
@override String get edit => 'Editar'; @override String get edit => 'Editar';
@override String get type => 'Tipo'; @override String get type => 'Tipo';
@override String get comingSoon => 'Em breve';
@override String get offline => 'Sem ligação — a partilha está em pausa'; @override String get offline => 'Sem ligação — a partilha está em pausa';
} }
@ -808,7 +807,7 @@ class Translations$market$pt extends Translations$market$en {
@override String get contact => 'Mensagem'; @override String get contact => 'Mensagem';
@override String get mine => 'Tu'; @override String get mine => 'Tu';
@override String get configTitle => 'Configuração de partilha'; @override String get configTitle => 'Configuração de partilha';
@override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.'; @override String get 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.';
@override String get areaLabel => 'A tua zona'; @override String get areaLabel => 'A tua zona';
@override String get areaHelp => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.'; @override String get areaHelp => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.';
@override String get areaSet => 'A tua zona está definida — aproximada, nunca o teu ponto exato'; @override String get areaSet => 'A tua zona está definida — aproximada, nunca o teu ponto exato';
@ -1579,7 +1578,6 @@ extension on TranslationsPt {
'common.delete' => 'Eliminar', 'common.delete' => 'Eliminar',
'common.edit' => 'Editar', 'common.edit' => 'Editar',
'common.type' => 'Tipo', 'common.type' => 'Tipo',
'common.comingSoon' => 'Em breve',
'common.offline' => 'Sem ligação — a partilha está em pausa', 'common.offline' => 'Sem ligação — a partilha está em pausa',
'home.tagline' => 'Partilha e cultiva sementes locais', 'home.tagline' => 'Partilha e cultiva sementes locais',
'home.openMarket' => 'Mercado', 'home.openMarket' => 'Mercado',
@ -1926,7 +1924,7 @@ extension on TranslationsPt {
'market.contact' => 'Mensagem', 'market.contact' => 'Mensagem',
'market.mine' => 'Tu', 'market.mine' => 'Tu',
'market.configTitle' => 'Configuração de partilha', 'market.configTitle' => 'Configuração de partilha',
'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.', 'market.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.',
'market.areaLabel' => 'A tua zona', 'market.areaLabel' => 'A tua zona',
'market.areaHelp' => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.', 'market.areaHelp' => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.',
'market.areaSet' => 'A tua zona está definida — aproximada, nunca o teu ponto exato', 'market.areaSet' => 'A tua zona está definida — aproximada, nunca o teu ponto exato',
@ -2033,9 +2031,9 @@ extension on TranslationsPt {
'plantare.statusReturned' => 'Devolvido', 'plantare.statusReturned' => 'Devolvido',
'plantare.statusForgiven' => 'Saldado', 'plantare.statusForgiven' => 'Saldado',
'plantare.openSection' => 'Pendentes', 'plantare.openSection' => 'Pendentes',
'plantare.settledSection' => 'Feitos',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'plantare.settledSection' => 'Feitos',
'plantare.removeConfirm' => 'Remover este compromisso?', 'plantare.removeConfirm' => 'Remover este compromisso?',
'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}',
'plantare.overdue' => 'vencido', 'plantare.overdue' => 'vencido',

View file

@ -223,7 +223,6 @@ class _Translations$common$pt_BR extends Translations$common$pt {
@override String get delete => 'Eliminar'; @override String get delete => 'Eliminar';
@override String get edit => 'Editar'; @override String get edit => 'Editar';
@override String get type => 'Tipo'; @override String get type => 'Tipo';
@override String get comingSoon => 'Em breve';
@override String get offline => 'Sem ligação — a compartilha está em pausa'; @override String get offline => 'Sem ligação — a compartilha está em pausa';
} }
@ -809,7 +808,7 @@ class _Translations$market$pt_BR extends Translations$market$pt {
@override String get contact => 'Mensagem'; @override String get contact => 'Mensagem';
@override String get mine => 'Você'; @override String get mine => 'Você';
@override String get configTitle => 'Configuração de compartilha'; @override String get configTitle => 'Configuração de compartilha';
@override String get setupIntro => 'Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — já está conectado a servidores comunitários compartilhados para que outras pessoas encontrem o que você oferece, sem nenhuma empresa no meio.'; @override String get 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.';
@override String get areaLabel => 'A sua zona'; @override String get areaLabel => 'A sua zona';
@override String get areaHelp => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.'; @override String get areaHelp => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.';
@override String get areaSet => 'A sua zona está definida — aproximada, nunca o seu ponto exato'; @override String get areaSet => 'A sua zona está definida — aproximada, nunca o seu ponto exato';
@ -1580,7 +1579,6 @@ extension on TranslationsPtBr {
'common.delete' => 'Eliminar', 'common.delete' => 'Eliminar',
'common.edit' => 'Editar', 'common.edit' => 'Editar',
'common.type' => 'Tipo', 'common.type' => 'Tipo',
'common.comingSoon' => 'Em breve',
'common.offline' => 'Sem ligação — a compartilha está em pausa', 'common.offline' => 'Sem ligação — a compartilha está em pausa',
'home.tagline' => 'Compartilha e cultiva sementes locais', 'home.tagline' => 'Compartilha e cultiva sementes locais',
'home.openMarket' => 'Mercado', 'home.openMarket' => 'Mercado',
@ -1927,7 +1925,7 @@ extension on TranslationsPtBr {
'market.contact' => 'Mensagem', 'market.contact' => 'Mensagem',
'market.mine' => 'Você', 'market.mine' => 'Você',
'market.configTitle' => 'Configuração de compartilha', 'market.configTitle' => 'Configuração de compartilha',
'market.setupIntro' => 'Compartilhar com pessoas por perto é opcional. Basta indicar a sua zona aproximada — já está conectado a servidores comunitários compartilhados para que outras pessoas encontrem o que você oferece, sem nenhuma empresa no meio.', 'market.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.',
'market.areaLabel' => 'A sua zona', 'market.areaLabel' => 'A sua zona',
'market.areaHelp' => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.', 'market.areaHelp' => 'Mantém-se aproximado de propósito — a sua zona, nunca um ponto exato.',
'market.areaSet' => 'A sua zona está definida — aproximada, nunca o seu ponto exato', 'market.areaSet' => 'A sua zona está definida — aproximada, nunca o seu ponto exato',
@ -2034,9 +2032,9 @@ extension on TranslationsPtBr {
'plantare.statusReturned' => 'Devolvido', 'plantare.statusReturned' => 'Devolvido',
'plantare.statusForgiven' => 'Saldado', 'plantare.statusForgiven' => 'Saldado',
'plantare.openSection' => 'Pendentes', 'plantare.openSection' => 'Pendentes',
'plantare.settledSection' => 'Feitos',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'plantare.settledSection' => 'Feitos',
'plantare.removeConfirm' => 'Remover este compromisso?', 'plantare.removeConfirm' => 'Remover este compromisso?',
'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}',
'plantare.overdue' => 'vencido', 'plantare.overdue' => 'vencido',

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, required SocialSettings settings,
SessionOpener? open, SessionOpener? open,
Stream<bool>? online, Stream<bool>? online,
List<Duration>? retrySchedule,
}) : _settings = settings, }) : _settings = settings,
_open = open ?? social.openSession, _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 SocialSettings _settings;
final SessionOpener _open; final SessionOpener _open;
final Stream<bool>? _online; final Stream<bool>? _online;
final List<Duration> _retrySchedule;
final _sessions = StreamController<SocialSession?>.broadcast(); final _sessions = StreamController<SocialSession?>.broadcast();
SocialSession? _current; SocialSession? _current;
Future<SocialSession?>? _pending; Future<SocialSession?>? _pending;
StreamSubscription<bool>? _onlineSub; StreamSubscription<bool>? _onlineSub;
bool _disposed = false; 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. /// Emits the live session on each (re)connect, and null when it drops.
Stream<SocialSession?> get sessions => _sessions.stream; Stream<SocialSession?> get sessions => _sessions.stream;
@ -46,12 +63,19 @@ class SocialConnection {
SocialSession? get current => _current; SocialSession? get current => _current;
/// Begins watching connectivity (reconnect on regain, drop when offline) and /// 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() { void start() {
if (_started || _disposed) return;
_started = true;
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) { _onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
_knownOffline = !isOnline;
if (!isOnline) { if (!isOnline) {
_cancelRetry();
_drop(); _drop();
} else if (_current == null) { } else if (_current == null) {
_retryIndex = 0; // fresh network start the backoff over
unawaited(session()); unawaited(session());
} }
}); });
@ -75,15 +99,40 @@ class SocialConnection {
return null; return null;
} }
_current = s; _current = s;
_retryIndex = 0;
_cancelRetry();
_sessions.add(s); _sessions.add(s);
return s; return s;
} catch (_) { } catch (_) {
return null; // unreachable a later connectivity change retries _scheduleRetry(); // unreachable retry with backoff (see below)
return null;
} finally { } finally {
_pending = null; _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() { void _drop() {
final s = _current; final s = _current;
_current = null; _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 { Future<void> dispose() async {
_disposed = true; _disposed = true;
_cancelRetry();
await _onlineSub?.cancel(); await _onlineSub?.cancel();
_onlineSub = null; _onlineSub = null;
_drop(); _drop();

View file

@ -5,9 +5,11 @@ import '../security/secret_store.dart';
/// keystore (via [SecretStore]) to honour "no plaintext at rest" no /// keystore (via [SecretStore]) to honour "no plaintext at rest" no
/// shared_preferences. /// shared_preferences.
/// ///
/// Relays default to a small set of well-known public servers so the market /// Sharing is off until the person joins it ([sharingEnabled]); until then the
/// works out of the box; the exposure is minimal (offers are opt-in and carry /// app opens no connection at all. Once they do, relays default to a small set
/// only a coarse geohash) and the user can swap them for a community server. /// 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). /// The area stays unset until the user picks one (it's inherently personal).
class SocialSettings { class SocialSettings {
SocialSettings(this._store); SocialSettings(this._store);
@ -16,6 +18,7 @@ class SocialSettings {
static const _areaKey = 'tane.social.area_geohash'; static const _areaKey = 'tane.social.area_geohash';
static const _relaysKey = 'tane.social.relays'; static const _relaysKey = 'tane.social.relays';
static const _sharingKey = 'tane.social.sharing_enabled';
static const _searchPrecisionKey = 'tane.social.search_precision'; static const _searchPrecisionKey = 'tane.social.search_precision';
static const _blockedKey = 'tane.social.blocked_pubkeys'; static const _blockedKey = 'tane.social.blocked_pubkeys';
static const _hiddenOffersKey = 'tane.social.hidden_offers'; static const _hiddenOffersKey = 'tane.social.hidden_offers';
@ -29,11 +32,11 @@ class SocialSettings {
static const int maxSearchPrecision = 5; static const int maxSearchPrecision = 5;
static const int defaultSearchPrecision = 4; static const int defaultSearchPrecision = 4;
/// Community servers used automatically so sharing works from the first /// Community servers used automatically once the person joins the sharing
/// launch. The relay pool skips any that are unreachable, so a dead one never /// side, so the market works without any setup. The relay pool skips any that
/// breaks the market; the user never has to know these exist. The Comunes /// are unreachable, so a dead one never breaks the market; the user never has
/// relay comes first as the reliable, non-commercial home; the public ones /// to know these exist. The Comunes relay comes first as the reliable,
/// are backup. /// non-commercial home; the public ones are backup.
static const List<String> defaultRelays = [ static const List<String> defaultRelays = [
'wss://relay.comunes.org', 'wss://relay.comunes.org',
'wss://nos.lol', 'wss://nos.lol',
@ -62,6 +65,38 @@ class SocialSettings {
urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'), 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, /// How wide to search a geohash prefix length in [minSearchPrecision,
/// maxSearchPrecision]. Defaults (and falls back on any garbage) to /// maxSearchPrecision]. Defaults (and falls back on any garbage) to
/// [defaultSearchPrecision]. /// [defaultSearchPrecision].

View file

@ -10,6 +10,7 @@ import '../services/offer_mapper.dart';
import '../services/offer_outbox.dart'; import '../services/offer_outbox.dart';
import '../services/offer_thumbnail.dart'; import '../services/offer_thumbnail.dart';
import '../services/social_connection.dart'; import '../services/social_connection.dart';
import '../services/social_service.dart';
/// State of the offer discovery/publish screen. Transport-agnostic it holds /// State of the offer discovery/publish screen. Transport-agnostic it holds
/// only what the UI shows, never a relay handle. /// only what the UI shows, never a relay handle.
@ -28,6 +29,7 @@ class OffersState extends Equatable {
this.nextPageCursor, this.nextPageCursor,
this.publishing = false, this.publishing = false,
this.hasSearched = false, this.hasSearched = false,
this.connectionEpoch = 0,
this.error, this.error,
}); });
@ -80,6 +82,11 @@ class OffersState extends Equatable {
/// from "searched, found nothing". /// from "searched, found nothing".
final bool hasSearched; 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). /// Last error, in human terms for the UI (null when fine).
final String? error; final String? error;
@ -136,6 +143,7 @@ class OffersState extends Equatable {
int? Function()? nextPageCursor, int? Function()? nextPageCursor,
bool? publishing, bool? publishing,
bool? hasSearched, bool? hasSearched,
int? connectionEpoch,
String? Function()? error, String? Function()? error,
}) { }) {
return OffersState( return OffersState(
@ -153,6 +161,7 @@ class OffersState extends Equatable {
nextPageCursor != null ? nextPageCursor() : this.nextPageCursor, nextPageCursor != null ? nextPageCursor() : this.nextPageCursor,
publishing: publishing ?? this.publishing, publishing: publishing ?? this.publishing,
hasSearched: hasSearched ?? this.hasSearched, hasSearched: hasSearched ?? this.hasSearched,
connectionEpoch: connectionEpoch ?? this.connectionEpoch,
error: error != null ? error() : this.error, error: error != null ? error() : this.error,
); );
} }
@ -172,6 +181,7 @@ class OffersState extends Equatable {
nextPageCursor, nextPageCursor,
publishing, publishing,
hasSearched, hasSearched,
connectionEpoch,
error, error,
]; ];
} }
@ -183,15 +193,40 @@ class OffersState extends Equatable {
class OffersCubit extends Cubit<OffersState> { class OffersCubit extends Cubit<OffersState> {
OffersCubit( OffersCubit(
this._transport, { this._transport, {
SocialConnection? connection,
Future<Uint8List?> Function(String varietyId)? coverPhoto, Future<Uint8List?> Function(String varietyId)? coverPhoto,
String? Function(Uint8List bytes)? thumbnail, String? Function(Uint8List bytes)? thumbnail,
Future<void> Function()? onDispose, Future<void> Function()? onDispose,
}) : _coverPhoto = coverPhoto, }) : _coverPhoto = coverPhoto,
_thumbnail = thumbnail, _thumbnail = thumbnail,
_onDispose = onDispose, _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 /// Fetches a variety's cover photo bytes; null in tests or when no inventory
/// repo is wired. /// repo is wired.
@ -225,6 +260,7 @@ class OffersCubit extends Cubit<OffersState> {
/// from now on older results arrive via [loadNextPage], not by draining the /// from now on older results arrive via [loadNextPage], not by draining the
/// whole area into memory. /// whole area into memory.
Future<void> discover(String geohashPrefix) async { Future<void> discover(String geohashPrefix) async {
_lastPrefix = geohashPrefix;
final transport = _transport; final transport = _transport;
if (transport == null) { if (transport == null) {
emit(state.copyWith(error: () => 'offline', hasSearched: true)); emit(state.copyWith(error: () => 'offline', hasSearched: true));
@ -467,6 +503,7 @@ class OffersCubit extends Cubit<OffersState> {
@override @override
Future<void> close() async { Future<void> close() async {
_searchTimeout?.cancel(); _searchTimeout?.cancel();
await _connSub?.cancel();
await _sub?.cancel(); await _sub?.cancel();
await _onDispose?.call(); await _onDispose?.call();
return super.close(); return super.close();
@ -484,6 +521,7 @@ Future<OffersCubit> createOffersCubit(
final session = await connection.session(); final session = await connection.session();
return OffersCubit( return OffersCubit(
session?.offers, session?.offers,
connection: connection, // keeps following (re)connects see _onSession
coverPhoto: repository?.coverPhotoFor, coverPhoto: repository?.coverPhotoFor,
thumbnail: offerThumbnailDataUri, thumbnail: offerThumbnailDataUri,
); );

View file

@ -3,24 +3,66 @@ import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/onboarding_store.dart';
import '../services/sharing_switch.dart';
import 'seed_glyph.dart'; import 'seed_glyph.dart';
import 'sharing_invite_sheet.dart';
import 'theme.dart'; import 'theme.dart';
import 'unread_badge.dart'; import 'unread_badge.dart';
/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is /// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is
/// the live destination (green seed glyph), the social items (market, profile, /// the live destination (green seed glyph), the social items sit below a divider,
/// chat) belong to Block 2 and are greyed with a "soon" tag, and Settings sits /// and Settings is pinned at the bottom.
/// 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 { 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 /// The sharing on/off switch. Null when the social layer isn't there at all
/// destination; other social items stay "soon" until they're built. /// (identity derivation failed) then the social items are not drawn, since
final bool marketEnabled; /// 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 @override
Widget build(BuildContext context) { 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; 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( return Drawer(
child: SafeArea( child: SafeArea(
child: Column( child: Column(
@ -69,59 +111,68 @@ class AppDrawer extends StatelessWidget {
context.push('/calendar'); context.push('/calendar');
}, },
), ),
_DrawerItem( // The market is always live when the social layer exists: it
icon: const Icon(Symbols.storefront), // is where people join sharing, so locking it would lock the
label: t.menu.market, // only door.
divider: true, if (social)
onTap: marketEnabled _DrawerItem(
? () { icon: const Icon(Symbols.storefront),
Navigator.of(context).pop(); label: t.menu.market,
context.push('/market'); divider: true,
} onTap: () {
: null, Navigator.of(context).pop();
), context.push('/market');
_DrawerItem( },
icon: const Icon(Icons.person), ),
label: t.menu.profile, if (social)
onTap: marketEnabled _DrawerItem(
? () { icon: const Icon(Icons.person),
Navigator.of(context).pop(); label: t.menu.profile,
context.push('/profile'); locked: !sharingOn,
} onTap: sharingOn
: null, ? () {
), Navigator.of(context).pop();
_DrawerItem( context.push('/profile');
icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), }
label: t.menu.chat, : invite,
onTap: marketEnabled ),
? () { if (social)
Navigator.of(context).pop(); _DrawerItem(
context.push('/messages'); icon: const UnreadBadge(child: Icon(Icons.chat_bubble)),
} label: t.menu.chat,
: null, locked: !sharingOn,
), onTap: sharingOn
_DrawerItem( ? () {
icon: const Icon(Icons.favorite), Navigator.of(context).pop();
label: t.menu.wishlist, context.push('/messages');
onTap: marketEnabled }
? () { : invite,
Navigator.of(context).pop(); ),
context.push('/favorites'); if (social)
} _DrawerItem(
: null, icon: const Icon(Icons.favorite),
), label: t.menu.wishlist,
// The ego-centric web of trust ("your people") live once the locked: !sharingOn,
// social layer is on. onTap: sharingOn
_DrawerItem( ? () {
icon: const Icon(Icons.group), Navigator.of(context).pop();
label: t.menu.following, context.push('/favorites');
onTap: marketEnabled }
? () { : invite,
Navigator.of(context).pop(); ),
context.push('/your-people'); // The ego-centric web of trust ("your people").
} if (social)
: null, _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, required this.label,
this.onTap, this.onTap,
this.divider = false, this.divider = false,
this.locked = false,
}); });
final Widget icon; final Widget icon;
@ -210,9 +262,13 @@ class _DrawerItem extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final bool divider; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final enabled = onTap != null; final enabled = onTap != null && !locked;
final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F); final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F);
final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F); final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F);
final row = InkWell( final row = InkWell(
@ -247,15 +303,11 @@ class _DrawerItem extends StatelessWidget {
), ),
), ),
), ),
if (!enabled) if (locked)
Text( const Icon(
context.t.common.comingSoon.toUpperCase(), Icons.lock_outline,
style: const TextStyle( size: 16,
color: Color(0xFFB3BDA8), color: Color(0xFFB3BDA8),
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
), ),
], ],
), ),

View file

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

View file

@ -3,17 +3,29 @@ import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/onboarding_store.dart'; import '../services/onboarding_store.dart';
import '../services/sharing_switch.dart';
import 'theme.dart'; import 'theme.dart';
/// Makes sure the community rules have been accepted once before the user /// Makes sure the community rules have been accepted once before the user
/// joins the market or publishes anything. Returns true when the rules are /// joins the market or publishes anything. Returns true when the rules are
/// (or become) accepted; false when the user declines. Play/App Store UGC /// (or become) accepted; false when the user declines. Play/App Store UGC
/// policies require this acceptance before content can be created. /// 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( Future<bool> ensureMarketRulesAccepted(
BuildContext context, BuildContext context,
OnboardingStore store, OnboardingStore store, {
) async { SharingSwitch? sharing,
if (await store.marketRulesAccepted()) return true; }) 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; if (!context.mounted) return false;
final accepted = await showModalBottomSheet<bool>( final accepted = await showModalBottomSheet<bool>(
context: context, context: context,
@ -24,6 +36,7 @@ Future<bool> ensureMarketRulesAccepted(
); );
if (accepted == true) { if (accepted == true) {
await store.markMarketRulesAccepted(); await store.markMarketRulesAccepted();
await sharing?.enable();
return true; return true;
} }
return false; return false;
@ -77,6 +90,14 @@ class MarketGateSheet extends StatelessWidget {
height: 1.4, height: 1.4,
), ),
), ),
const SizedBox(height: 8),
Text(
t.marketGate.networkNote,
style: theme.textTheme.bodySmall?.copyWith(
color: seedMuted,
height: 1.4,
),
),
TextButton.icon( TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,

View file

@ -11,6 +11,7 @@ import '../services/offer_outbox.dart';
import '../services/saved_searches_store.dart'; import '../services/saved_searches_store.dart';
import '../services/social_connection.dart'; import '../services/social_connection.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/sharing_switch.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
import '../services/onboarding_store.dart'; import '../services/onboarding_store.dart';
import '../state/offers_cubit.dart'; import '../state/offers_cubit.dart';
@ -35,6 +36,7 @@ class MarketScreen extends StatefulWidget {
this.onboarding, this.onboarding,
this.savedSearches, this.savedSearches,
this.initialSearch, this.initialSearch,
this.sharing,
super.key, super.key,
}); });
@ -54,6 +56,10 @@ class MarketScreen extends StatefulWidget {
/// created). Null in tests no gate. /// created). Null in tests no gate.
final OnboardingStore? onboarding; 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. /// The shared relay connection (one per identity), reused for discovery.
final SocialConnection connection; final SocialConnection connection;
@ -84,11 +90,18 @@ class _MarketScreenState extends State<MarketScreen> {
/// network; declining leaves the market. /// network; declining leaves the market.
Future<void> _start() async { Future<void> _start() async {
final store = widget.onboarding; 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. // Wait for the first frame so the sheet has a surface to attach to.
await WidgetsBinding.instance.endOfFrame; await WidgetsBinding.instance.endOfFrame;
if (!mounted) return; if (!mounted) return;
final ok = await ensureMarketRulesAccepted(context, store); final ok = await ensureMarketRulesAccepted(context, store,
sharing: sharing);
if (!ok) { if (!ok) {
if (mounted) context.pop(); if (mounted) context.pop();
return; return;
@ -100,11 +113,11 @@ class _MarketScreenState extends State<MarketScreen> {
Future<void> _init() async { Future<void> _init() async {
// Only needed to flush the outbox; read here (while mounted) to avoid using // 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). // context across the awaits below. Null when there's no outbox (e.g. tests).
final repo = final repo = widget.outbox != null
widget.outbox != null ? context.read<VarietyRepository>() : null; ? context.read<VarietyRepository>()
: null;
setState(() => _loading = true); setState(() => _loading = true);
final cubit = final cubit = await createOffersCubit(widget.connection, repository: repo);
await createOffersCubit(widget.connection, repository: repo);
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys()); cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
final area = await widget.settings.areaGeohash(); final area = await widget.settings.areaGeohash();
@ -122,10 +135,10 @@ class _MarketScreenState extends State<MarketScreen> {
_loading = false; _loading = false;
}); });
await previous?.close(); await previous?.close();
if (area.isNotEmpty && cubit.isOnline) { if (area.isNotEmpty) {
// Flush anything parked while offline, then show what's out there. // Flush anything parked while offline, then show what's out there.
final outbox = widget.outbox; final outbox = widget.outbox;
if (outbox != null && repo != null) { if (outbox != null && repo != null && cubit.isOnline) {
await flushOutbox( await flushOutbox(
outbox: outbox, outbox: outbox,
cubit: cubit, cubit: cubit,
@ -136,6 +149,8 @@ class _MarketScreenState extends State<MarketScreen> {
} }
if (mounted) { if (mounted) {
final precision = await widget.settings.searchPrecision(); 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)); if (mounted) await cubit.discover(searchPrefix(area, precision));
} }
} }
@ -154,8 +169,11 @@ class _MarketScreenState extends State<MarketScreen> {
final changed = await showModalBottomSheet<bool>( final changed = await showModalBottomSheet<bool>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => builder: (_) => _ConfigSheet(
_ConfigSheet(settings: widget.settings, location: widget.location), settings: widget.settings,
location: widget.location,
sharing: widget.sharing,
),
); );
if (changed == true) await _init(); if (changed == true) await _init();
} }
@ -206,12 +224,18 @@ class _MarketScreenState extends State<MarketScreen> {
await widget.outbox?.remove(ids); // published now, so unpark any duplicates await widget.outbox?.remove(ids); // published now, so unpark any duplicates
// count == 0 with lots to share means every relay refused say so plainly // count == 0 with lots to share means every relay refused say so plainly
// rather than "shared 0", which reads like success. // rather than "shared 0", which reads like success.
messenger.showSnackBar(SnackBar( messenger.showSnackBar(
content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)), SnackBar(
)); content: Text(
count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count),
),
),
);
final precision = await widget.settings.searchPrecision(); final precision = await widget.settings.searchPrecision();
if (!mounted) return; if (!mounted) return;
await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared await cubit.discover(
searchPrefix(area, precision),
); // refresh incl. just-shared
} }
@override @override
@ -240,13 +264,13 @@ class _MarketScreenState extends State<MarketScreen> {
), ),
floatingActionButton: floatingActionButton:
cubit != null && (cubit.isOnline || widget.outbox != null) cubit != null && (cubit.isOnline || widget.outbox != null)
? FloatingActionButton.extended( ? FloatingActionButton.extended(
key: const Key('market.shareMine'), key: const Key('market.shareMine'),
onPressed: _shareMine, onPressed: _shareMine,
icon: const Icon(Icons.campaign_outlined), icon: const Icon(Icons.campaign_outlined),
label: Text(t.market.shareMine), label: Text(t.market.shareMine),
) )
: null, : null,
body: _loading || cubit == null body: _loading || cubit == null
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: BlocProvider.value( : BlocProvider.value(
@ -298,6 +322,17 @@ class MarketBody extends StatelessWidget {
final t = context.t; final t = context.t;
return BlocBuilder<OffersCubit, OffersState>( return BlocBuilder<OffersCubit, OffersState>(
builder: (context, state) { 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) { if (!context.read<OffersCubit>().isOnline) {
// Default community servers exist, so "offline" means unreachable // Default community servers exist, so "offline" means unreachable
// offer a retry rather than "set up sharing". // offer a retry rather than "set up sharing".
@ -309,15 +344,6 @@ class MarketBody extends StatelessWidget {
onAction: onRetry, 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) { if (state.searching && state.offers.isEmpty) {
return Center( return Center(
child: Column( child: Column(
@ -325,8 +351,10 @@ class MarketBody extends StatelessWidget {
children: [ children: [
const CircularProgressIndicator(), const CircularProgressIndicator(),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(t.market.searching, Text(
style: const TextStyle(color: seedMuted)), t.market.searching,
style: const TextStyle(color: seedMuted),
),
], ],
), ),
); );
@ -370,16 +398,22 @@ class MarketBody extends StatelessWidget {
prefixIcon: const Icon(Icons.search, color: seedMuted), prefixIcon: const Icon(Icons.search, color: seedMuted),
// Offer to save the current search once it's worth alerting on // Offer to save the current search once it's worth alerting on
// (some text typed or a chip active), never for the bare list. // (some text typed or a chip active), never for the bare list.
suffixIcon: savedSearches != null && suffixIcon:
savedSearches != null &&
(state.query.trim().isNotEmpty || (state.query.trim().isNotEmpty ||
state.hasActiveFilter) state.hasActiveFilter)
? IconButton( ? IconButton(
key: const Key('market.saveSearch'), key: const Key('market.saveSearch'),
icon: const Icon(Icons.bookmark_add_outlined, icon: const Icon(
color: seedGreen), Icons.bookmark_add_outlined,
color: seedGreen,
),
tooltip: t.savedSearches.save, tooltip: t.savedSearches.save,
onPressed: () => onPressed: () => _saveCurrentSearch(
_saveCurrentSearch(context, savedSearches!, state), context,
savedSearches!,
state,
),
) )
: null, : null,
hintStyle: const TextStyle(color: seedMuted), hintStyle: const TextStyle(color: seedMuted),
@ -431,13 +465,17 @@ class MarketBody extends StatelessWidget {
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
// A trailing spinner row while the next page loads. // A trailing spinner row while the next page loads.
itemCount: visible.length + (state.loadingMore ? 1 : 0), itemCount:
separatorBuilder: (_, _) => const SizedBox(height: 12), visible.length + (state.loadingMore ? 1 : 0),
separatorBuilder: (_, _) =>
const SizedBox(height: 12),
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i >= visible.length) { if (i >= visible.length) {
return const Padding( return const Padding(
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()), child: Center(
child: CircularProgressIndicator(),
),
); );
} }
final o = visible[i]; final o = visible[i];
@ -574,7 +612,9 @@ class _OfferCard extends StatelessWidget {
if (mine) ...[ if (mine) ...[
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3), horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: seedGreen, color: seedGreen,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -598,8 +638,10 @@ class _OfferCard extends StatelessWidget {
children: [ children: [
const Icon(Icons.place_outlined, size: 15, color: seedMuted), const Icon(Icons.place_outlined, size: 15, color: seedMuted),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(t.market.near, Text(
style: const TextStyle(color: seedMuted, fontSize: 13)), t.market.near,
style: const TextStyle(color: seedMuted, fontSize: 13),
),
if (offer.isOrganic) ...[ if (offer.isOrganic) ...[
const SizedBox(width: 10), const SizedBox(width: 10),
const Icon(Icons.eco, size: 15, color: seedGreen), const Icon(Icons.eco, size: 15, color: seedGreen),
@ -607,7 +649,8 @@ class _OfferCard extends StatelessWidget {
const Spacer(), const Spacer(),
if (offer.type == OfferType.sale && offer.priceAmount != null) if (offer.type == OfferType.sale && offer.priceAmount != null)
Text( Text(
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(), '${offer.priceAmount} ${offer.priceCurrency ?? ''}'
.trim(),
style: const TextStyle( style: const TextStyle(
color: seedOnSurface, color: seedOnSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -759,10 +802,11 @@ class _EmptyState extends StatelessWidget {
/// Coarse-area + community-server setup. Kept behind progressive disclosure a /// Coarse-area + community-server setup. Kept behind progressive disclosure a
/// power-user surface with human-worded labels. /// power-user surface with human-worded labels.
class _ConfigSheet extends StatefulWidget { class _ConfigSheet extends StatefulWidget {
const _ConfigSheet({required this.settings, this.location}); const _ConfigSheet({required this.settings, this.location, this.sharing});
final SocialSettings settings; final SocialSettings settings;
final CoarseLocationProvider? location; final CoarseLocationProvider? location;
final SharingSwitch? sharing;
@override @override
State<_ConfigSheet> createState() => _ConfigSheetState(); State<_ConfigSheet> createState() => _ConfigSheetState();
@ -906,194 +950,250 @@ class _ConfigSheetState extends State<_ConfigSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = context.t; final t = context.t;
final hasArea = _area.text.trim().isNotEmpty; final hasArea = _area.text.trim().isNotEmpty;
return Padding( // SafeArea keeps the Save button above the system nav bar on edge-to-edge
padding: EdgeInsets.only( // devices; the viewInsets padding still lifts it above the keyboard.
left: 20, return SafeArea(
right: 20, top: false,
top: 20, child: Padding(
bottom: MediaQuery.of(context).viewInsets.bottom + 20, padding: EdgeInsets.only(
), left: 20,
child: _loading right: 20,
? const SizedBox( top: 20,
height: 120, child: Center(child: CircularProgressIndicator())) bottom: MediaQuery.of(context).viewInsets.bottom + 20,
: SingleChildScrollView( ),
child: Column( child: _loading
mainAxisSize: MainAxisSize.min, ? const SizedBox(
crossAxisAlignment: CrossAxisAlignment.stretch, height: 120,
children: [ child: Center(child: CircularProgressIndicator()),
Text( )
t.market.configTitle, : SingleChildScrollView(
style: const TextStyle( child: Column(
fontSize: 18, mainAxisSize: MainAxisSize.min,
fontWeight: FontWeight.w600, crossAxisAlignment: CrossAxisAlignment.stretch,
color: seedTitle, children: [
), Text(
), t.market.configTitle,
const SizedBox(height: 8), style: const TextStyle(
Text( fontSize: 18,
t.market.setupIntro, fontWeight: FontWeight.w600,
style: const TextStyle( color: seedTitle,
color: seedMuted, fontSize: 13, height: 1.4),
),
const SizedBox(height: 18),
// Setting your area from device location is the human path;
// typing a code is the advanced fallback.
if (widget.location != null)
FilledButton.tonalIcon(
key: const Key('market.useLocation'),
onPressed: _locationBusy ? null : _useLocation,
icon: _locationBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location, size: 18),
label: Text(t.market.useLocation),
),
if (_locationError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
_locationError!,
style: const TextStyle(
color: Color(0xFFB3261E), fontSize: 12),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
Row( Text(
children: [ t.market.setupIntro,
Icon( style: const TextStyle(
hasArea color: seedMuted,
? Icons.check_circle fontSize: 13,
: Icons.pending_outlined, height: 1.4,
size: 18,
color: hasArea ? seedGreen : seedMuted,
), ),
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( child: Text(
hasArea ? t.market.areaSet : t.market.areaNotSet, _locationError!,
style: TextStyle( style: const TextStyle(
color: hasArea ? seedOnSurface : seedMuted, color: Color(0xFFB3261E),
fontSize: 13, fontSize: 12,
), ),
), ),
), ),
], const SizedBox(height: 12),
), Row(
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: [ children: [
TextField( Icon(
key: const Key('market.area'), hasArea ? Icons.check_circle : Icons.pending_outlined,
controller: _area, size: 18,
onChanged: (_) => setState(() {}), color: hasArea ? seedGreen : seedMuted,
decoration: InputDecoration(
labelText: t.market.areaCodeLabel,
helperText: t.market.areaCodeHint,
helperMaxLines: 2,
),
), ),
const SizedBox(height: 14), const SizedBox(width: 8),
Align( Expanded(
alignment: AlignmentDirectional.centerStart,
child: Text( child: Text(
t.market.serversLabel, hasArea ? t.market.areaSet : t.market.areaNotSet,
style: const TextStyle( style: TextStyle(
fontSize: 13, color: seedMuted), color: hasArea ? seedOnSurface : seedMuted,
), fontSize: 13,
), ),
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: 16),
const SizedBox(height: 20), Align(
FilledButton( alignment: AlignmentDirectional.centerStart,
key: const Key('market.save'), child: Text(
onPressed: _save, t.market.rangeLabel,
child: Text(t.market.save), 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

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

@ -1,7 +1,7 @@
name: tane name: tane
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market." description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
publish_to: 'none' publish_to: 'none'
version: 0.1.10+12 version: 0.1.16+18
environment: environment:
sdk: ^3.11.5 sdk: ^3.11.5

View file

@ -105,7 +105,7 @@ void main() {
seeded = await seedShowcase(db, repo, locale); seeded = await seedShowcase(db, repo, locale);
}); });
final child = switch (name) { final child = switch (name) {
'home' => const HomeScreen(marketEnabled: true), 'home' => HomeScreen(sharing: newTestSharingSwitch()),
'inventory' => const InventoryListScreen(), 'inventory' => const InventoryListScreen(),
'market' => marketWidget(locale), 'market' => marketWidget(locale),
'calendar' => const CalendarScreen(initialMonth: 4), 'calendar' => const CalendarScreen(initialMonth: 4),

View file

@ -45,11 +45,13 @@ void main() {
required List<FakeChannel> opened, required List<FakeChannel> opened,
Stream<bool>? online, Stream<bool>? online,
bool Function()? fail, bool Function()? fail,
List<Duration>? retrySchedule,
}) => }) =>
SocialConnection( SocialConnection(
social: social, social: social,
settings: settings, settings: settings,
online: online, online: online,
retrySchedule: retrySchedule,
open: (_) async { open: (_) async {
if (fail?.call() ?? false) throw StateError('unreachable'); if (fail?.call() ?? false) throw StateError('unreachable');
final ch = FakeChannel(); final ch = FakeChannel();
@ -106,6 +108,69 @@ void main() {
await online.close(); 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', test('returns null when the relay is unreachable, and retries later',
() async { () async {
final opened = <FakeChannel>[]; final opened = <FakeChannel>[];
@ -116,4 +181,70 @@ void main() {
expect(await conn.session(), isNotNull); // succeeds on retry expect(await conn.session(), isNotNull); // succeeds on retry
await conn.dispose(); 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')}, {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/db/enums.dart';
import 'package:tane/services/discovery_area.dart'; import 'package:tane/services/discovery_area.dart';
import 'package:tane/services/offer_mapper.dart'; import 'package:tane/services/offer_mapper.dart';
import 'package:nostr/nostr.dart';
import 'package:tane/services/offer_outbox.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 'package:tane/state/offers_cubit.dart';
import '../support/test_support.dart'; import '../support/test_support.dart';
@ -835,4 +839,111 @@ void main() {
await cubit.close(); 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/i18n/strings.g.dart';
import 'package:tane/security/secret_store.dart'; import 'package:tane/security/secret_store.dart';
import 'package:tane/services/onboarding_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/app.dart' show materialLocaleFor;
import 'package:tane/state/inventory_cubit.dart'; import 'package:tane/state/inventory_cubit.dart';
import 'package:tane/state/variety_detail_cubit.dart'; import 'package:tane/state/variety_detail_cubit.dart';
@ -59,6 +61,14 @@ OnboardingStore newTestOnboardingStore({bool introSeen = true}) {
return OnboardingStore(store); 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 /// Wraps [child] with the providers a screen expects (repository, inventory
/// cubit) plus i18n and Material localizations, pinned to [locale]. /// cubit) plus i18n and Material localizations, pinned to [locale].
Widget wrapScreen({ Widget wrapScreen({

View file

@ -2,15 +2,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:tane/app.dart'; import 'package:tane/app.dart';
import 'package:tane/i18n/strings.g.dart'; import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/sharing_switch.dart';
import '../support/test_support.dart'; import '../support/test_support.dart';
void main() { 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( child: TaneApp(
repository: newTestRepository(db), repository: newTestRepository(db),
species: newTestSpeciesRepository(db), species: newTestSpeciesRepository(db),
onboarding: newTestOnboardingStore(), onboarding: newTestOnboardingStore(),
sharing: sharing ?? newTestSharingSwitch(),
), ),
); );
@ -24,7 +27,8 @@ void main() {
expect(find.text('Your inventory'), findsOneWidget); expect(find.text('Your inventory'), findsOneWidget);
expect(find.text('Market'), 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. // Redesign copy: tagline + the two destination subtitles.
expect(find.text('Share and grow local seeds'), findsOneWidget); 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.tap(find.byIcon(Icons.menu)); // hamburger
await tester.pumpAndSettle(); 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.text('Your profile'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsNothing);
await tester.tap(find.text('Inventory')); // active drawer item await tester.tap(find.text('Inventory')); // active drawer item
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -84,6 +89,62 @@ void main() {
await disposeTree(tester); 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 { testWidgets('drawer Settings opens the settings screen', (tester) async {

View file

@ -3,12 +3,17 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart'; import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/onboarding_store.dart'; import 'package:tane/services/onboarding_store.dart';
import 'package:tane/services/sharing_switch.dart';
import 'package:tane/ui/market_gate.dart'; import 'package:tane/ui/market_gate.dart';
import '../support/test_support.dart'; import '../support/test_support.dart';
void main() { void main() {
Widget host(OnboardingStore store, void Function(bool) onResult) => Widget host(
OnboardingStore store,
void Function(bool) onResult, {
SharingSwitch? sharing,
}) =>
TranslationProvider( TranslationProvider(
child: MaterialApp( child: MaterialApp(
localizationsDelegates: GlobalMaterialLocalizations.delegates, localizationsDelegates: GlobalMaterialLocalizations.delegates,
@ -16,7 +21,13 @@ void main() {
builder: (context) => Center( builder: (context) => Center(
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
onResult(await ensureMarketRulesAccepted(context, store)); onResult(
await ensureMarketRulesAccepted(
context,
store,
sharing: sharing,
),
);
}, },
child: const Text('enter'), child: const Text('enter'),
), ),
@ -87,5 +98,52 @@ void main() {
expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget); expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget);
expect(find.textContaining('public'), findsWidgets); expect(find.textContaining('public'), findsWidgets);
expect(find.text('Privacy & rules'), findsOneWidget); 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); findsOneWidget);
}); });
testWidgets('MarketScreen with no relays configured degrades to offline', testWidgets(
(tester) async { 'a fresh install (no area, unreachable) asks for the area first, '
'not the connection', (tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore()); final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network await settings.setRelayUrls(const []); // offline: don't hit the network
@ -94,7 +95,23 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Seeds near you'), findsOneWidget); // app bar title 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', testWidgets('the range selector persists the chosen search precision',
@ -148,6 +165,36 @@ void main() {
expect(tester.widget<CheckboxListTile>(find.byKey(firstKey)).value, isTrue); 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', testWidgets('"use my location" fills the area with a coarse geohash',
(tester) async { (tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);

View file

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/onboarding_store.dart';
import 'package:tane/services/sharing_switch.dart';
import 'package:tane/ui/sharing_invite_sheet.dart';
import '../support/test_support.dart';
void main() {
Widget host(OnboardingStore store, SharingSwitch sharing) =>
TranslationProvider(
child: MaterialApp(
localizationsDelegates: GlobalMaterialLocalizations.delegates,
home: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => showSharingInvite(
context,
onboarding: store,
sharing: sharing,
),
child: const Text('tap a locked entry'),
),
),
),
),
);
testWidgets('the invite says what lights up and that it needs a connection',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await tester.pumpWidget(
TranslationProvider(
child: const MaterialApp(
localizationsDelegates: GlobalMaterialLocalizations.delegates,
home: Scaffold(body: SharingInviteSheet()),
),
),
);
await tester.pump();
expect(find.text('This wakes up when you start sharing'), findsOneWidget);
expect(find.text('Write to whoever has seeds near you'), findsOneWidget);
expect(find.text('Keep the offers you like'), findsOneWidget);
expect(find.text('Your circle of people you trust'), findsOneWidget);
// The honest part: it is not free of consequences.
expect(find.textContaining('go online'), findsOneWidget);
});
testWidgets('"Not now" leaves the app exactly as offline as it was',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final store = OnboardingStore(InMemorySecretStore());
final sharing = newTestSharingSwitch(enabled: false);
await tester.pumpWidget(host(store, sharing));
await tester.tap(find.text('tap a locked entry'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('sharingInvite.notNow')));
await tester.pumpAndSettle();
expect(sharing.on.value, isFalse);
expect(await store.marketRulesAccepted(), isFalse);
});
testWidgets('accepting the invite goes through the community rules once',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final store = OnboardingStore(InMemorySecretStore());
final sharing = newTestSharingSwitch(enabled: false);
await tester.pumpWidget(host(store, sharing));
await tester.tap(find.text('tap a locked entry'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('sharingInvite.start')));
await tester.pumpAndSettle();
// Same single consent surface the market uses not a second one.
expect(find.text('Before you join the market'), findsOneWidget);
expect(sharing.on.value, isFalse, reason: 'not online until they agree');
await tester.tap(find.text('I agree'));
await tester.pumpAndSettle();
expect(sharing.on.value, isTrue);
expect(await store.marketRulesAccepted(), isTrue);
});
}

View file

@ -93,7 +93,7 @@ void main() {
wrapScreen( wrapScreen(
repository: newTestRepository(db), repository: newTestRepository(db),
locale: locale, locale: locale,
child: const HomeScreen(marketEnabled: true), child: HomeScreen(sharing: newTestSharingSwitch()),
), ),
); );
await disposeTree(tester); await disposeTree(tester);

View file

@ -34,6 +34,8 @@ Estas fijan `schemaVersion = 1` y el arranque técnico:
- **Paquete legal + moderación mínima — DECIDIDO/IMPLEMENTADO (2026-07-13).** Se escribe la capa legal completa en [`docs/legal/`](../legal/README.md): política de privacidad, condiciones de uso, normas de la comunidad y aviso sobre legalidad de semillas (masters en inglés + espejo es), más docs internos de cumplimiento (Play Data Safety/UGC, notas Apple, memoria jurídica con calendario de revisión — el PRM europeo sigue en trílogos). Decisiones que fija: **(a)** consentimiento único **al entrar al mercado por primera vez** (hoja modal con las normas; el inventario local no pide nada — progressive disclosure), con la misma bandera guardando también publicar-oferta y primer DM; **(b)** **bloqueo local** de claves (keystore, sin migración de esquema): filtra ofertas, chats y DMs entrantes; **(c)** **denuncias estándar NIP-56** (kind 1984) publicadas a los relays + ocultación local; en `relay.comunes.org` (nuestro) se actúa sobre ellas — esa es la historia de "moderación con respuesta" para la revisión de Play; **(d)** se reafirma **cero comisiones** sobre semillas y **Ğ1 solo como etiqueta de precio** (sin flujos de pago in-app, precedente Damus/Apple); **(e)** borrado honesto: NIP-09 es best-effort y así se cuenta al usuario. Metadata de tienda en `fastlane/metadata/android/` (la reutiliza F-Droid). - **Paquete legal + moderación mínima — DECIDIDO/IMPLEMENTADO (2026-07-13).** Se escribe la capa legal completa en [`docs/legal/`](../legal/README.md): política de privacidad, condiciones de uso, normas de la comunidad y aviso sobre legalidad de semillas (masters en inglés + espejo es), más docs internos de cumplimiento (Play Data Safety/UGC, notas Apple, memoria jurídica con calendario de revisión — el PRM europeo sigue en trílogos). Decisiones que fija: **(a)** consentimiento único **al entrar al mercado por primera vez** (hoja modal con las normas; el inventario local no pide nada — progressive disclosure), con la misma bandera guardando también publicar-oferta y primer DM; **(b)** **bloqueo local** de claves (keystore, sin migración de esquema): filtra ofertas, chats y DMs entrantes; **(c)** **denuncias estándar NIP-56** (kind 1984) publicadas a los relays + ocultación local; en `relay.comunes.org` (nuestro) se actúa sobre ellas — esa es la historia de "moderación con respuesta" para la revisión de Play; **(d)** se reafirma **cero comisiones** sobre semillas y **Ğ1 solo como etiqueta de precio** (sin flujos de pago in-app, precedente Damus/Apple); **(e)** borrado honesto: NIP-09 es best-effort y así se cuenta al usuario. Metadata de tienda en `fastlane/metadata/android/` (la reutiliza F-Droid).
- **Distribución en F-Droid — HECHO (2026-07-28).** Tane está publicada en el repo oficial de F-Droid (<https://f-droid.org/packages/org.comunes.tane/>, v0.1.16) **sin antifeatures**: la MR de inclusión ([43144](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/43144)) se aceptó el 2026-07-25. Lo que lo desbloqueó: **(a)** build **reproducible y firmada por nosotros** (`AllowedAPKSigningKeys` + `binary:` por ABI ⇒ misma firma que Play, se puede cambiar de tienda sin reinstalar); **(b)** APK libre de Google (sin GMS) con splits por ABI; **(c)** **red opt-in** desde v0.1.16 — la app no abre ningún socket hasta que la persona activa compartir, lo que sostuvo el rechazo de `NonFreeNet`/`TetheredNet` (protocolo abierto, relays configurables). Consecuencia operativa: cada versión nueva solo requiere subir los `versionCode` por ABI en la receta de `fdroiddata`. → [release.md](../release.md)
## C) Puede esperar (no bloquea nada ahora) ## C) Puede esperar (no bloquea nada ahora)
- Negación plausible / bóveda señuelo; modo discreto. → security-privacy - Negación plausible / bóveda señuelo; modo discreto. → security-privacy

View file

@ -15,19 +15,14 @@ AutoName: Tane
RepoType: git RepoType: git
Repo: https://git.comunes.org/comunes/tane.git Repo: https://git.comunes.org/comunes/tane.git
# Reproducible, developer-signed: F-Droid rebuilds each split and, if it matches
# our own signed APK on the git.comunes.org release byte-for-byte, publishes OUR
# APK (same signature as the Play build). Value is the SHA-256 of the tane-upload
# signing certificate (public).
AllowedAPKSigningKeys: ddfae432091b8248a8a4a1b353487fa626301f4357ef835e94ec312f69418e38
Builds: Builds:
- versionName: 0.1.10 - versionName: 0.1.16
versionCode: 121 versionCode: 181
commit: v0.1.10 commit: 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598
subdir: apps/app_seeds subdir: apps/app_seeds
output: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk output: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-armeabi-v7a-release.apk binary:
https://git.comunes.org/comunes/tane/releases/download/v%v/app-armeabi-v7a-release.apk
srclibs: srclibs:
- flutter@stable - flutter@stable
- tesseract4android@4.9.0 - tesseract4android@4.9.0
@ -40,9 +35,9 @@ Builds:
- export PUB_CACHE=$(pwd)/.pub-cache - export PUB_CACHE=$(pwd)/.pub-cache
- $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter config --no-analytics
- $$flutter$$/bin/flutter pub get --enforce-lockfile - $$flutter$$/bin/flutter pub get --enforce-lockfile
- "sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle" - sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle
- 'echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties' - echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties
- 'echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties' - echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties
- cd apps/app_seeds - cd apps/app_seeds
- $$flutter$$/bin/dart run slang - $$flutter$$/bin/dart run slang
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
@ -53,17 +48,18 @@ Builds:
- pushd $$tesseract4android$$/tesseract4android - pushd $$tesseract4android$$/tesseract4android
- gradle --no-daemon assembleStandardRelease - gradle --no-daemon assembleStandardRelease
- popd - popd
- cp - cp
$$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar $$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm
- versionName: 0.1.10 - versionName: 0.1.16
versionCode: 122 versionCode: 182
commit: v0.1.10 commit: 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598
subdir: apps/app_seeds subdir: apps/app_seeds
output: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk output: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-arm64-v8a-release.apk binary:
https://git.comunes.org/comunes/tane/releases/download/v%v/app-arm64-v8a-release.apk
srclibs: srclibs:
- flutter@stable - flutter@stable
- tesseract4android@4.9.0 - tesseract4android@4.9.0
@ -76,9 +72,9 @@ Builds:
- export PUB_CACHE=$(pwd)/.pub-cache - export PUB_CACHE=$(pwd)/.pub-cache
- $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter config --no-analytics
- $$flutter$$/bin/flutter pub get --enforce-lockfile - $$flutter$$/bin/flutter pub get --enforce-lockfile
- "sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle" - sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle
- 'echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties' - echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties
- 'echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties' - echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties
- cd apps/app_seeds - cd apps/app_seeds
- $$flutter$$/bin/dart run slang - $$flutter$$/bin/dart run slang
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
@ -89,17 +85,18 @@ Builds:
- pushd $$tesseract4android$$/tesseract4android - pushd $$tesseract4android$$/tesseract4android
- gradle --no-daemon assembleStandardRelease - gradle --no-daemon assembleStandardRelease
- popd - popd
- cp - cp
$$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar $$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm64 - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm64
- versionName: 0.1.10 - versionName: 0.1.16
versionCode: 123 versionCode: 183
commit: v0.1.10 commit: 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598
subdir: apps/app_seeds subdir: apps/app_seeds
output: build/app/outputs/flutter-apk/app-x86_64-release.apk output: build/app/outputs/flutter-apk/app-x86_64-release.apk
binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-x86_64-release.apk binary:
https://git.comunes.org/comunes/tane/releases/download/v%v/app-x86_64-release.apk
srclibs: srclibs:
- flutter@stable - flutter@stable
- tesseract4android@4.9.0 - tesseract4android@4.9.0
@ -112,9 +109,9 @@ Builds:
- export PUB_CACHE=$(pwd)/.pub-cache - export PUB_CACHE=$(pwd)/.pub-cache
- $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter config --no-analytics
- $$flutter$$/bin/flutter pub get --enforce-lockfile - $$flutter$$/bin/flutter pub get --enforce-lockfile
- "sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle" - sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle
- 'echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties' - echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties
- 'echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties' - echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties
- cd apps/app_seeds - cd apps/app_seeds
- $$flutter$$/bin/dart run slang - $$flutter$$/bin/dart run slang
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
@ -125,18 +122,28 @@ Builds:
- pushd $$tesseract4android$$/tesseract4android - pushd $$tesseract4android$$/tesseract4android
- gradle --no-daemon assembleStandardRelease - gradle --no-daemon assembleStandardRelease
- popd - popd
- cp - cp
$$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar $$tesseract4android$$/tesseract4android/build/outputs/aar/tesseract4android-standard-release.aar
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-x64 - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-x64
# Reproducible, developer-signed: F-Droid rebuilds each split and, if it matches
# our own signed APK on the git.comunes.org release byte-for-byte, publishes OUR
# APK (same signature as the Play build). Value is the SHA-256 of the tane-upload
# signing certificate (public).
# Note: `commit:` must be a full commit hash (not a tag) per F-Droid review, so
# it can only be filled in once the tag exists; it is synced here and in the
# fdroiddata fork right after tagging, before the fork's pipeline runs.
# 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598 is the commit tag v0.1.16 points to.
AllowedAPKSigningKeys: ddfae432091b8248a8a4a1b353487fa626301f4357ef835e94ec312f69418e38
AutoUpdateMode: Version AutoUpdateMode: Version
UpdateCheckMode: Tags v[\d.]+ UpdateCheckMode: Tags v[\d.]+
VercodeOperation: VercodeOperation:
- '%c * 10 + 1' - '%c * 10 + 1'
- '%c * 10 + 2' - '%c * 10 + 2'
- '%c * 10 + 3' - '%c * 10 + 3'
UpdateCheckData: UpdateCheckData:
apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+ apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+
CurrentVersion: 0.1.10 CurrentVersion: 0.1.16
CurrentVersionCode: 123 CurrentVersionCode: 183

View file

@ -1,139 +0,0 @@
# Ecorred: Encuentro Estatal (Piloña) y financiación RCF
> Nota interna (sin trackear). Preparada el 18-07-2026. **El formulario cierra el lunes 21 de julio.**
## 1. El encuentro
**Encuentro Estatal de Comunidades Regenerativas** — [ecorred.es/encuentro-comunidades-regenerativas](https://www.ecorred.es/encuentro-comunidades-regenerativas/)
- **Cuándo**: 2427 de septiembre de 2026.
- **Dónde**: La Benéfica de Piloña, L'Infiestu (Asturias).
- **Qué incluye**: alojamiento compartido (3 noches), comidas de los 3 días, ayuda de viaje de hasta 100 €/persona.
- **Programa**: jueves llegada y "pasillo de la fama" (espacio expositivo de iniciativas); viernes sesión estratégica entre redes; sábado jornada pública del Día Europeo de las Comunidades Sostenibles, con diálogo institucional; domingo visitas opcionales a proyectos locales.
- **Reuniones preparatorias online**: 1 y 8 de septiembre.
- **Plazos**: formulario de interés hasta el **21 de julio**; selección comunicada **antes del 31 de julio**. Plazas limitadas; seleccionan buscando diversidad de territorios, enfoques y perfiles.
- **Formulario**: https://forms.gle/bcizx2eXHPFigZZp7 (lo gestiona Altekio S.Coop.Mad.)
**Por qué encaja Tane**: priorizan iniciativas de alimentación y agroecología, y el formulario pregunta literalmente *"¿Qué traéis al encuentro? Un aprendizaje, una herramienta, una experiencia concreta que otras iniciativas podrían necesitar escuchar"*. Tane es exactamente eso: una herramienta libre y ya disponible. Además, el encuentro reúne al público ideal (redes de semillas, grupos agroecológicos, bancos comunitarios) para pilotos y difusión.
## 2. Financiación en Ecorred más allá del encuentro
Ecorred es el nodo español del **Regenerative Communities Fund (RCF)** de ECOLISE (20252027), financiado por la UE dentro de "Funding Fairer Futures" (programa DEAR). Nodos hermanos en Croacia, Finlandia, Francia y Portugal. Dos líneas:
### Pathfinder / Comunidades Emergentes — la oportunidad real
- **4.000 €** por iniciativa + formación, acompañamiento, visitas a iniciativas demostrativas, apoyo en captación de fondos y comunicación. Presupuesto total del proyecto entre 1.000 y 10.000 € (cofinanciación no obligatoria).
- La **3ª convocatoria cerró el 26-01-2026** (42 solicitudes en España; resolución hacia finales de septiembre de 2026, ~10 seleccionadas por país).
- **Habrá una 4ª convocatoria a mediados de 2026** para otras 50 comunidades. **Esta es la ventana para Tane** — y el encuentro de Piloña, el sitio donde enterarse de las fechas y conocer al equipo de Ecorredes (que forma parte del comité de selección).
**Elegibilidad** (según las [bases de la 3ª convocatoria](https://ecolise.eu/wp-content/uploads/2025/11/CfP-Regenerative-Communities-Fund-%E2%80%93-Cycle-III-2025-Pathfinder-Communities.pdf), previsiblemente similares en la 4ª):
- Puede solicitar una **entidad sin ánimo de lucro registrada** (CSO) **o un grupo no registrado** con entidad fiscal de acogida. La asociación serviría como vehículo legal, sin protagonismo.
- Establecida y trabajando en España ✓; no haber recibido fondos DEAR ✓.
- **Punto de fricción**: piden iniciativas *"pequeñas, arraigadas localmente"* que *"trabajen con comunidades locales de un territorio concreto"* (escala barrio/pueblo). Una app, por sí sola, encaja regular.
- **Encaje recomendado**: presentar un **proyecto local y concreto** — un piloto con una red de intercambio de semillas o banco comunitario de un territorio (talleres, inventariado colectivo del banco local, trueques con la app, formación intergeneracional) — donde Tane es la herramienta, no el fin. Ahí el encaje es bueno: soberanía alimentaria, empoderamiento comunitario, participación de mayores y jóvenes (criterio de relevancia, 30 % de la nota, prima colectivos infrarrepresentados).
- Solicitud en inglés (la propuesta narrativa puede ir en castellano + copia en inglés). Informes narrativos, sin informes financieros.
- Obligaciones si seleccionan: ejecutar el proyecto (13 meses), participar en formaciones, organizar un **diálogo local con la administración** durante el Día Europeo de las Comunidades Sostenibles, y compartir la historia en los canales de ECOLISE.
- Contacto: rcf@ecolise.eu (indicar SPAIN en el asunto).
**Conclusión**: buscar antes del verano/otoño una **comunidad local aliada** dispuesta a co-presentar el piloto. El encuentro de Piloña es el mejor sitio para encontrarla.
### Demonstrator / Iniciativas Demostrativas
Para iniciativas consolidadas que sirven de modelo a otras (15 ya seleccionadas en las dos primeras convocatorias). Requisitos y cuantías no publicados en la web de Ecorred. **Preguntar en el encuentro** si habrá nuevas convocatorias y si una herramienta con comunidad de uso podría entrar más adelante.
### Fuentes
- [Fondo en ecorred.es](https://www.ecorred.es/fondo-de-comunidades-regenerativas/)
- [Bases 3ª convocatoria Pathfinder (PDF, ECOLISE)](https://ecolise.eu/wp-content/uploads/2025/11/CfP-Regenerative-Communities-Fund-%E2%80%93-Cycle-III-2025-Pathfinder-Communities.pdf)
- [Anuncio de la convocatoria (ECOLISE)](https://ecolise.eu/regenerative-communities-fund-apply-call-for-pathfinder-communities/)
- [Nota en economiasolidaria.org](https://www.economiasolidaria.org/noticias/convocatoria-de-comunidades-emergentes-pathfinder-del-fondo-para-comunidades-regenerativas/) · [Red de Transición](https://www.reddetransicion.org/abierta-la-convocatoria-de-comunidades-emergentes-pathfinder-del-fondo-para-comunidades-regenerativas/)
## 3. Borrador de respuestas al formulario de interés
Listo para copiar y pegar. Los campos `[RELLENAR]` son personales. Límites de palabras respetados con margen.
### Datos personales
| Campo | Respuesta |
|---|---|
| Nombre y apellidos | `[RELLENAR]` |
| Correo electrónico | `[RELLENAR]` (sugerencia: vjrj@comunes.org) |
| Teléfono de contacto | `[RELLENAR]` |
| ¿Cómo has conocido este encuentro? | `[RELLENAR]` (opciones: RCF y Ecorredes / Red o entidad en la que participo / Redes sociales / Otro) |
### Red, organización o iniciativa
| Campo | Respuesta |
|---|---|
| Nombre | **Tane** |
| Territorio donde opera | `[CONFIRMAR]` (p. ej. tu municipio/comunidad como base, aclarando que la herramienta se usa en todo el estado) |
| Año de inicio | `[CONFIRMAR: 2025 o 2026]` |
| Web o redes sociales | https://tane.comunes.org |
| Ámbito principal de trabajo | **alimentación y agroecología** |
**Describe brevemente qué hacéis, cómo y para quién** *(máx. 200 palabras; el borrador tiene ~170)*:
> Tane es una aplicación libre y gratuita para guardar, organizar e intercambiar semillas tradicionales. Cada persona lleva su banco de semillas en el bolsillo: qué variedades tiene, de dónde vienen, cuándo se sembraron, si siguen germinando bien y a quién se han dado, para que las variedades locales no se pierdan.
>
> Funciona sin internet, sin cuentas y sin servidores centrales: los datos se quedan cifrados en el dispositivo de cada persona. Cuando hay conexión, se puede publicar lo que se ofrece y conversar con otras personas para intercambiar. Nadie hace negocio con los datos ni con las semillas.
>
> Está pensada para hortelanas y hortelanos de cualquier edad, redes de intercambio y bancos comunitarios de semillas: es muy simple de entrada (basta apuntar el nombre de una variedad) y ofrece profundidad a quien la busca: pruebas de germinación, calendario de siembra, consejos para producir semilla de cada cultivo, avisos cuando una semilla envejece.
>
> Es software libre, multilingüe (cualquiera puede ayudar a traducirla) y está disponible gratis en Google Play y F-Droid.
### Impacto y representatividad
**¿Qué representa vuestra iniciativa en el ecosistema regenerativo?** *(máx. 150 palabras; ~130)*:
> Tane es una herramienta común de reciente creación: infraestructura digital al servicio de las redes de semillas, los bancos comunitarios y los grupos agroecológicos, construida como bien colectivo y no como plataforma comercial.
>
> Es descentralizada por diseño, y eso se nota en algo poco habitual: no sabemos quién la usa ni cuántas personas somos, porque no existe ningún registro central ni recopilamos dato alguno. Igual que las semillas pasan de mano en mano, la aplicación funciona directamente entre las personas, sin pasar por ningún servidor nuestro. Esa renuncia deliberada a controlar es nuestra manera de entender la tecnología para el procomún.
>
> Venimos del mundo del software libre y estamos en contacto con comunidades que ya se intercambian semillas de mano en mano. Queremos hacer de puente entre quienes guardan semillas y quienes construyen tecnología comunitaria, y tejer en este encuentro las alianzas que un proyecto joven necesita.
**¿Qué traéis al encuentro?** *(máx. 150 palabras; ~100)*:
> Una herramienta concreta, libre y gratuita que cualquier iniciativa puede empezar a usar hoy mismo: sirve para inventariar un banco comunitario de semillas, organizar trueques y no perder la memoria de cada variedad (de dónde viene, quién la ha cuidado, cómo germina).
>
> Y una experiencia que creemos útil compartir: cómo construir tecnología al servicio de las comunidades y no al revés — sin recopilar datos, sin publicidad, funcionando incluso sin internet, traducida por su propia comunidad. Podemos enseñarla en directo, escuchar qué le falta y adaptarla a lo que las redes de semillas necesiten de verdad.
**¿Qué buscáis?** *(máx. 150 palabras; ~90)*:
> Alianzas con redes de intercambio de semillas, bancos comunitarios y grupos agroecológicos que quieran probar la herramienta en su día a día y decirnos qué mejorar: pilotos reales con personas reales, especialmente con quienes llevan décadas guardando semillas y con gente joven que empieza.
>
> También queremos entender cómo sostener un proyecto así sin traicionarlo (sin vender datos ni cobrar por lo básico), y conocer de cerca el Fondo de Comunidades Regenerativas y la próxima convocatoria de comunidades emergentes, idealmente de la mano de una comunidad local con la que presentar un proyecto conjunto.
**¿Qué conversación queréis tener con las administraciones?** *(máx. 150 palabras; ~105)*:
> Con humildad: somos un proyecto joven y no venimos a pedir nada para nosotros, sino a escuchar y a acompañar lo que las redes de semillas llevan años planteando — que conservar e intercambiar variedades tradicionales siga siendo posible para personas y colectivos, no solo para empresas, también en la revisión europea de la normativa de semillas que está en curso.
>
> Y una idea sencilla que sí es nuestra: cuando las administraciones apoyen la digitalización del mundo rural y comunitario, que cuenten con herramientas libres y respetuosas con los datos, que funcionen incluso con mala conexión, en lugar de atar a las comunidades a plataformas comerciales.
### Diversidad e inclusión
| Campo | Respuesta |
|---|---|
| Género | `[RELLENAR]` |
| Edad | `[RELLENAR]` |
| ¿Trabajáis prioritariamente con jóvenes, migrantes o colectivos vulnerables? | Sugerencia: **Parcialmente** (la app está pensada para cualquier edad, 1080 años, con especial cuidado de mayores y gente sin soltura digital) — `[DECIDIR]` |
| ¿Participáis en el Consejo Estratégico de Ecorredes o en el RCF? | **No** |
### Logística
| Campo | Respuesta |
|---|---|
| ¿Puedes asistir a las reuniones preparatorias online? (1 y/o 8 sept) | `[RELLENAR]` (opciones: Ambas / Solo la primera / Solo la segunda / Ninguna) |
| ¿Necesitas apoyo económico para el desplazamiento? | `[RELLENAR]` (Sí / Sería de ayuda, pero podría cubrirlo / No es necesario) |
| ¿Participarías igualmente costeando viaje y alojamiento? | `[RELLENAR]` (Sí, probablemente / No me sería posible) |
| ¿Necesidad especial para no compartir habitación? | `[RELLENAR]` |
| ¿Restricción alimentaria o accesibilidad? | `[RELLENAR]` |
| ¿Comentario adicional? | Sugerencia: ofrecer una demostración en vivo de Tane en el "pasillo de la fama" del jueves. |
## 4. Lista de tareas
- [ ] **Antes del lunes 21-07**: rellenar y enviar el formulario (respuestas de arriba + campos personales).
- [ ] Antes del 31-07: llegará la respuesta de selección.
- [ ] Si seleccionan: apuntar reuniones preparatorias del **1 y 8 de septiembre**; preparar material para el pasillo de la fama (cartel con QR de descarga, capturas localizadas ya existentes de los golden tests, móvil/tablet con la app, sobres de semillas de muestra).
- [ ] **Vigilar la 4ª convocatoria Pathfinder** (mediados de 2026, 4.000 € + acompañamiento): suscribirse a la [lista de FFF](https://ecolise.eu/) o preguntar en el encuentro; buscar comunidad local aliada para co-presentar un piloto.
- [ ] Preguntar en el encuentro por la línea Demonstrator (requisitos, próximas convocatorias).

View file

@ -1,292 +0,0 @@
# Tane — NGI Fediversity (NLnet) application draft
> **Working document, NOT tracked in git.** Ready-to-submit draft for the NLnet
> NGI Fediversity call (nlnet.nl/propose). Deadline **1 Aug 2026, 12:00 CEST** (confirmed on the site).
> Derived from `VISION.md`, `PLAN.md`, `docs/design/*` and `docs/notes/modelos-financiacion.md`.
> Written in English (the proposal must be in English; VISION.md is the Spanish source).
> **Updated 2026-07-17** to reflect the shipped state: app live on Google Play (v0.1.1), site at
> tane.comunes.org, most of the social layer already built. The ask now funds the *remaining*
> federated-infrastructure work, not a from-scratch build.
---
## Part A — Strategy & framing (for us, NOT for the form)
### The new, stronger story: we shipped, now we complete the federated infrastructure
Since the first draft, the project advanced a lot. This is now a **track-record application**, not a
promise:
- **Live on Google Play** (v0.1.1, `org.comunes.tane`), **GMS-free build** ready for F-Droid.
- **Public site** at **tane.comunes.org**, translated via Weblate (translate.comunes.org).
- **Block 1 (inventory) shipped**, and **most of Block 2 (the social layer) already shipped**: offers
(NIP-99), private metadata-hiding messaging (NIP-17), a Duniter-style egocentric web of trust
(kind 30777), ratings (kind 30778), profiles + multi-account identity, Plantaré pledges, saved-search
alerts. All transports live in the reusable `commons_core` engine, with ~131 test files and CI.
NLnet weighs **technical feasibility (30%)** and **value for money (30%)**: a shipped, tested,
in-store app de-risks both. Lead with that.
### Fit with Fediversity — now genuine, not a stretch
The Fediversity fund leans to the *hosting stack / self-hosting* side. The **work that remains is
exactly that side**, which is why the fit is now real:
1. **Self-hostable community relay**`relay.comunes.org` is **already deployed and running**. The
remaining Fediversity-shaped work is turning it into a **reproducible, documented recipe any seed
network can self-host**, plus moderation tooling (NIP-56 reports, key-blocking) and operational
hardening — so the network is owned by communities, not a single instance.
2. **Multi-device sync** — the encrypted `SyncTransport` landed (NIP-78, relay sees only ciphertext);
the app-level `SyncService` that replicates your inventory across your own devices is **pending**.
This is data portability + self-custody, a Fediversity theme.
3. **Interoperability & portability** — open, versioned export/import already exists; add opt-in
**Darwin Core / GBIF** export so agrobiodiversity data can flow to open biodiversity atlases.
4. **`commons_core` as reusable federated infrastructure** — Tane is a seed app wrapped around a generic
Nostr + CRDT + web-of-trust + messaging engine; seeds are the first app, others can follow.
Framing in one line: **bringing federation beyond social media, into the physical commons — and we
already have a working app to prove it.**
### Plan B — don't bet everything on this call
Honest odds: the fit is now decent (the remaining work IS hosting/portability-shaped) but Fediversity
reviewers may still read "mobile app on Nostr" as adjacent to their ActivityPub/NixOS hosting focus.
Mitigation, at near-zero extra cost:
- **Submit to Fediversity anyway** (short form, strong track record, real infra milestones) — worst case
is a rejection with feedback.
- **Reuse this same dossier for NGI Zero Commons / Core when regular calls reopen after summer 2026**
Tane as commons infrastructure is arguably a *better* fit there (`commons_core`, open protocols,
community-run relays). Keep this file as the master; only the fund-specific framing paragraph changes.
- Meanwhile, keep the **Goteo matchfunding** (Red de Semillas) track as the community-round complement.
### NLnet requirements checklist
- [x] **FOSS licence** — code AGPL-3.0; docs/assets CC-BY-SA.
- [x] **Open standards** — Nostr NIPs (99/17/44/78/56), Duniter-style WoT, open data formats, Darwin Core.
- [x] **European dimension** — EC-LLD (23 European seed networks), Red de Semillas, EU seed-law (PRM) context.
- [x] **Milestone-based, verifiable deliverables** — see Part C.
- [x] **Sustainability after funding** — local-first, open data/code, cheap community relays; never per-transaction fees.
- [ ] **Generative-AI disclosure** — declared honestly and minimally (see §13 + the log template below).
- [ ] **Privacy statement** — tick the acknowledgement of NLnet's privacy statement on the form.
### The AI concern (read before submitting)
NLnet has a formal **Generative AI policy** (nlnet.nl/foundation/policies/generativeAI/). It does **not**
forbid AI, but imposes real obligations, and reviewers value **substance over marketing**. Three things,
don't conflate them:
1. **Prompt provenance log (required):** if GenAI is used in the application process, you must keep a
log of prompts/outputs. → the Appendix table below.
2. **Public disclosure (required for substantive use):** any GenAI use that materially affects the
output must be disclosed so evaluators understand how the proposal was produced. → §13.
3. **Purely-AI outcomes are NOT payable, and must be FLOSS-clean:** "Outcomes purely generated by AI are
not allowed to be submitted as work eligible for payment." → this hits the **milestones (M1M5)**, not
just the application: the delivered code/docs must be **human-authored** (AI-assisted is fine; you must
also verify outputs don't reproduce copyrighted/licence-incompatible material). **Non-compliance can
mean rejection of the proposal or termination of a running grant.**
Plus the softer risk: an application that *reads* as AI slop scores worse. Fix for all of it = make the
text genuinely yours.
**Action for vjrj — rewrite §3, §4, §5 in your own voice** (that's what a reviewer reads first; the rest
is technical scaffolding). Add first-person specifics only you can write: Kokopelli, the 2009 paper
Plantare you originated, why the whole seed sector refuses per-transaction fees, your Ğ1nkgo/Duniter
background, and the fact the app is already in people's hands.
**De-"slop" pass:** cut the tells — em-dashes everywhere, rule-of-three lists, heavy bold,
"not X but Y" constructions, brochure cadence. Rougher, more technical, more personal = better here.
### Process notes
- Submit several days early — "deadlines are hard". Draft offline (this file), paste at the end.
- Plain text is preferred; keep formatting light. Attachments ≤ 50 MB total.
- You may submit multiple independent proposals in one round; do not double-submit via FundingBox + NLnet.
---
## Part B — Form answers (the actual draft, English)
### Current status (facts to weave in — all verifiable)
- **Live:** Google Play v0.1.1 (`org.comunes.tane`); site **tane.comunes.org**; community Nostr relay
**relay.comunes.org running**; source public (AGPL-3.0).
- **F-Droid:** GMS-free build + recipe ready (awaiting registry upload).
- **Languages:** 7 (en, es, pt, fr, ast, de, ja); RTL + CJK fonts bundled; translated on Weblate.
- **Engine:** `commons_core` (pure Dart) — identity derivation, HLC, CRDT, geohash, all Nostr transports.
- **Tests/CI:** ~131 test files; Forgejo Actions runs analyze + test on both packages.
### 1. Contact / Organisation / Country
- **Applicant organisation:** Comunes (registered non-profit association), Spain / EU.
- **Project lead:** Vicente J. Ruiz Jurado (vjrj) — technical lead.
- **Package id / namespace:** `org.comunes.tane`.
- **Contact:** vjrj@comunes.org.
- **Project page:** https://tane.comunes.org · source repository public (AGPL-3.0) · live on Google Play.
### 2. Project name
**Tane — self-hostable, local-first infrastructure for seed-sharing communities.** (種, "seed"; from
*tanemaki*, "to sow".)
### 3. Abstract (a few sentences)
> ✍️ **vjrj: rewrite in your own voice.** Scaffolding below — keep the meaning, drop the polished cadence.
Tane lets communities run their own seed-sharing network end to end: a local-first mobile app, a
self-hostable relay, and identity and data the user fully owns and can take anywhere — no account, no
central server, no company in the middle. The app is already live on Google Play in seven languages;
offers, private messaging, reputation and encrypted device sync ride on open federated protocols
(Nostr NIPs) and a Duniter-style web of trust, implemented in a reusable engine (`commons_core`) where
seeds are only the first application. What remains — and what this grant funds — is the hosting and
portability side: a **reproducible relay recipe any collective can self-host** (our reference instance
already runs), **completion of encrypted multi-device sync**, **open-standards data portability**
(Darwin Core / GBIF export), and production hardening — so a seed network can operate the whole stack
without depending on us, or on anyone.
### 4. Can you explain the whole project and its expected outcome?
> ✍️ **vjrj: rewrite in your own voice.** Add first-person specifics (Kokopelli, the 2009 paper Plantare,
> Ğ1nkgo). Scaffolding below.
A handful of corporations control most of the world's seed supply, and traditional varieties — and the
knowledge to grow them — are being lost. Sharing seeds has even become legally suspect (e.g. Kokopelli
fined in France). Seed networks resist with notebooks and spreadsheets; there was no friendly, free,
decentralised tool for them.
Tane is built in **layers, each useful alone**, and most are **already shipped**:
1. **Inventory (local-first) — shipped.** Encrypted seed bank on your phone (SQLite + SQLCipher), fully
offline, no account; backup + printed recovery; 7 languages.
2. **Offer status — shipped.** Per item: private / gift / exchange / sale.
3. **Local sharing (federated) — shipped.** Offers as signed Nostr events (NIP-99), discovered by
**coarse geohash** (~2.4 km, never your address); private DMs (NIP-17); ratings; Plantaré pledges;
saved-search alerts. No central operator can censor, sell data, or charge a fee.
4. **Trust — shipped.** Egocentric Duniter-style web of trust (you vouch for people you've met); spam
from unknown keys is filtered with no central moderator.
A de-risking spike proved the whole social happy-path on vetted pure-Dart libraries, and it is now
**production code in people's hands**. **What this grant funds is the hosting and portability layer that
makes the network genuinely community-owned** (see Part C): turning our running relay into a
**reproducible, self-hostable recipe with moderation tooling**; completing **encrypted multi-device
sync** (the transport already landed; the relay only ever sees ciphertext); **open-standards data
portability** (opt-in Darwin Core / GBIF export toward public biodiversity atlases); plus trust
cold-start for real seed fairs, optional Ğ1 price tags, and messaging/offers hardening.
**Expected outcome:** any seed collective in Europe can **self-host the entire stack from a documented
recipe** — relay, moderation, app, data — with identity and data fully portable and **zero dependency on
Comunes or any single operator**. And because the hard parts live in the generic `commons_core` engine,
the same infrastructure is reusable for other physical-commons networks (tool libraries are the next
candidate).
### 5. Compare your own project with existing or historical efforts
> ✍️ **vjrj: rewrite in your own voice.** You know this sector first-hand — say why it refuses
> per-transaction fees, and tell the Plantare story as its originator. Scaffolding below.
- **Seed Savers Exchange, Graines de Troc, Arche Noah, Red de Semillas** — valuable, but each is a
**centralised web platform**: single operator, an account, a server that can go dark, no local-first
offline app, not federated, not self-hostable.
- **Garden/inventory apps** — mostly **proprietary and extractive**; none do decentralised sharing or trust.
- **Marketplaces (Wallapop-style)** — a central intermediary that takes a commission and can censor.
Tellingly, **no seed-exchange platform anywhere charges per-transaction fees** — the whole sector uses
gift/exchange/membership. We follow that, by design.
- **Historical precedent — the Plantare (2009).** A paper "seed promissory note" already solved
decentralised trust and reciprocity *socially* (a bearer instrument, both parties hold a copy, the
ledger distributed across drawers). We digitise it without a central register.
**Why it's needed / why existing solutions don't answer it:** none combines local-first, federation over
open standards, self-hostability, identity/data portability, and a spam-resistant web of trust — while
staying free and non-extractive. Tane already does, in production; the engine is reusable for other
physical-commons networks.
### 6. Significant technical challenges (the remaining work)
- **Self-hostable relay + moderation**`relay.comunes.org` already runs; the challenge is making it a
reproducible recipe others can run, with NIP-56 report handling and key-blocking, so moderation is
"with a response" but not centralised.
- **Multi-device sync at the app level** — wire `SyncService` onto the landed encrypted `SyncTransport`:
serialise inventory, push on mutation, idempotent LWW import on receipt, stable per-install device id,
rare-conflict UI. Relay only ever sees ciphertext.
- **Interoperability / portability** — opt-in Darwin Core / GBIF export of agrobiodiversity (blurred
location, collective-bank level, one-way) — exactly the data those atlases lack.
- **Messaging/offers hardening** — NIP-44 interop-exact test vectors, offline DM delivery/retry, offer
expiry & revocation on already-replicated relays.
- **Trust cold-start** — bootstrap the web of trust at real fairs / Ğ1 seed groups (QR, no network).
- **Portable derived identity** — one root seed, one-way HKDF secp256k1 subkey for Nostr, one printable
QR backs up everything (already implemented; polish + docs).
### 7. Project ecosystem & stakeholder engagement
- **Seed networks:** Red de Semillas (Spain), **EC-LLD / Let's Liberate Diversity** (23 European seed
networks) for European reach; Arche Noah as a policy ally.
- **Free-money communities (Ğ1/Duniter):** first pilots — existing identity, existing web of trust,
aligned values.
- **Seed fairs and markets:** where trust bootstraps face to face.
- **Translators & contributors:** already active via Weblate; code and docs public.
### 8. Relevant prior experience & track record
- **Shipped, in production:** Tane is **live on Google Play** (v0.1.1), GMS-free build ready for F-Droid,
site at tane.comunes.org, 7 languages via Weblate, RTL+CJK support, a full legal package, automated
store publishing (Fastlane) and CI (Forgejo Actions, ~131 test files). Block 1 + most of Block 2 done.
- **vjrj** authored **Ğ1nkgo**, a Duniter/Ğ1 wallet in Flutter/Dart; Tane reuses its cryptographic
primitives (secp256k1, HKDF, Duniter identity) — no reinvention.
- **vjrj originated the Plantare concept** (BAH-Semillero, 2009, CC-BY-SA) and wrote *"Las semillas del
conocimiento libre"* (2005) — a decade of domain authority in seed commons.
- **Comunes** — registered association, stable legal counterpart for milestone accountability.
### 9. Requested amount
**€32,000.**
### 10. Budget usage / task breakdown
See Part C. Milestone-based; each milestone is an independent, verifiable deliverable with a CI-gated
test suite where applicable. NLnet pays on verification of each milestone.
### 11. Other funding sources (past and present)
- Built so far with **volunteer work**; **no prior grant funding**; no overlapping funding for this scope.
- Complementary community funding (e.g. Goteo matchfunding with Red de Semillas) is a *future*
sustainability avenue, not a duplicate of this request.
### 12. Sustainability after the grant
- **Local-first:** useful even with no servers and no maintainer — utility does not expire.
- **Open code & data (AGPL-3.0, open export):** anyone can continue, fork, or **self-host the relay**.
- **Cheap, community-run relays** (collectives / Comunes), plus opportunistic app-as-relay and
physical-proximity exchange at fairs.
- **Non-extractive revenue only, never on the seed:** voluntary association membership (Seed Savers
model), optional managed services for institutional seed banks. No per-transaction commission — legally
prudent (EU PRM in trilogue) and coherent with the commons.
### 13. Compliance & disclosure
- **Licence:** AGPL-3.0 (code); CC-BY-SA (docs/assets). Compatible with the reused Duniter/Ğ1nkgo stack.
- **Privacy statement:** acknowledged.
- **Generative-AI disclosure (honest & minimal — paste into the form):**
> An AI assistant was used only as a drafting aid for this application text: to translate and structure
> an initial English outline from our pre-existing Spanish design documents (VISION.md, PLAN.md,
> docs/design/*), which predate and are independent of this application. All technical claims,
> architecture, milestones and budget are the applicant's own; the project lead rewrote and verified
> the text, and no AI output was submitted unedited. A prompt provenance log is available on request.
> The funded deliverables (M1M5) will be human-authored (AI-assisted at most), released under
> AGPL-3.0, with no purely-AI-generated outcomes submitted for payment.
*Adjust to match what you actually did before sending. If you rewrite §3§5 yourself (recommended),
this statement stays true and strong.*
---
## Part C — Milestones & budget (€32,000)
Milestones reflect the **real remaining work** (Block 1 + most of Block 2 are already shipped). They are
ordered by theme: **M1M3 are the federated-hosting core** (self-hosting, sync, portability); **M4M5
harden and bootstrap it**. Each is an independent, verifiable deliverable; NLnet pays on verification.
| # | Milestone | Verifiable deliverable | € |
|---|-----------|------------------------|---|
| **M1** | **Self-hostable relay recipe + moderation** | `relay.comunes.org` already runs; deliver a **reproducible, documented recipe** any seed network can self-host, plus NIP-56 report handling + key-blocking moderation tooling and an operator manual | 6,000 |
| **M2** | **Multi-device sync (app `SyncService`)** | Inventory replication across a user's own devices over the landed encrypted `SyncTransport` (NIP-78): serialise + push on mutation, idempotent LWW import, stable per-install device id, conflict UI; tests (relay sees only ciphertext) | 8,000 |
| **M3** | **Interoperability & data portability** | Opt-in Darwin Core / GBIF export of agrobiodiversity (blurred location, collective-bank level, one-way); polished open export/import + identity portability docs | 5,000 |
| **M4** | **Messaging & offers hardening** | NIP-44 interop-exact test vectors, offline DM delivery/retry queue, offer expiry + revocation on replicated relays, spam-filter polish; CI-gated | 7,000 |
| **M5** | **Trust cold-start + Ğ1 price integration + docs/release** | WoT cold-start flows for fairs / Ğ1 groups (QR, offline); Ğ1 price tag + deep-link to wallets (no in-app payment); contributor architecture guide + multilingual user guide; F-Droid release | 6,000 |
| | **Total** | | **32,000** |
---
## Submission checklist (before nlnet.nl/propose)
1. Cross-check every field above against the live form + Guide for Applicants.
2. Fit test: does the abstract + §4 opening read "federation / portability / self-hosting" *before* "seeds"?
3. Budget: milestones independent & verifiable, effort realistic, sum = €32,000.
4. Compliance: FOSS licence stated, privacy statement acknowledged, **AI-disclosure + provenance log ready**.
5. Confirm the 1 Aug 2026 deadline on the site; submit with several days' margin.
6. Language pass: rewrite §3§5 in vjrj's voice; clear, technical English throughout.
---
## Appendix — AI-use log (keep, attach or provide on request)
Fill as you go so the disclosure is accurate and verifiable.
| Date | Tool / model | Prompt (verbatim) | What the output was used for | Kept unedited? |
|------|--------------|-------------------|------------------------------|----------------|
| 2026-07-10 | Claude Code (Opus 4.8) | "Me ayudas a presentar tane a esta convocatoria? qué pedirías…" | Initial English draft/outline from repo docs | No — to be rewritten & verified by lead |
| 2026-07-17 | Claude Code (Opus 4.8) | "He avanzado mucho en el desarrollo, puedes actualizar la application?…" | Updated draft to reflect shipped status + new milestones | No — to be rewritten & verified by lead |
> Keep the raw session transcript alongside this table. If §3§5 are rewritten by hand, note that here too.

View file

@ -39,9 +39,10 @@ com cadernos e folhas de cálculo.
- **Os teus dados são teus.** A tua informação fica guardada no **teu próprio telemóvel**, protegida com uma - **Os teus dados são teus.** A tua informação fica guardada no **teu próprio telemóvel**, protegida com uma
chave, não nos computadores de nenhuma empresa. Não a vendemos nem a colocamos em lado nenhum. Sem publicidade. chave, não nos computadores de nenhuma empresa. Não a vendemos nem a colocamos em lado nenhum. Sem publicidade.
- **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa: - **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa:
as pessoas comunicam **diretamente entre si**, como quem passa sementes de mão em mão. Assim, o que partilhas viaja por **servidores da comunidade**, mantidos por pessoas e coletivos —
ninguém o pode censurar nem cobrar para o usares (é isso que significa ser há vários e qualquer pessoa pode acrescentar o seu — pelo que nenhum deles é um centro
**descentralizado**). a partir do qual censurar ou cobrar para o usares, como quem passa sementes de mão em mão
(é isso que significa ser **descentralizado**).
- **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas - **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas
por pessoas em quem confias — como sempre funcionou numa aldeia ou numa feira — sem por pessoas em quem confias — como sempre funcionou numa aldeia ou numa feira — sem
teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras. teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras.

View file

@ -41,9 +41,10 @@ con libretas y hojas de cálculo.
clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio. clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio.
Sin publicidad. Sin publicidad.
- **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo: - **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo:
las personas se comunican **directamente entre sí**, como quien se pasa unas semillas de lo que compartes viaja por **servidores de la comunidad**, mantenidos por personas y
mano en mano. Por eso nadie puede censurarla ni cobrarte por usarla (eso es que sea colectivos — hay varios y cualquiera puede añadir el suyo — así que ninguno es un centro
**descentralizada**). desde el que censurarla o cobrarte por usarla, como quien se pasa unas semillas de mano
en mano (eso es que sea **descentralizada**).
- **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te - **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te
recomienda gente de confianza —como siempre se ha hecho en un pueblo o en una feria— sin recomienda gente de confianza —como siempre se ha hecho en un pueblo o en una feria— sin
tener que dar tu nombre real ni tu teléfono si no quieres. tener que dar tu nombre real ni tu teléfono si no quieres.

View file

@ -147,11 +147,22 @@ page but does not by itself make the app available in a new country.
## Publish to F-Droid (official repo) ## Publish to F-Droid (official repo)
**Tane is live in F-Droid** since 2026-07-28 (v0.1.16, no antifeatures):
<https://f-droid.org/packages/org.comunes.tane/>. The inclusion MR
([43144](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/43144)) was merged
on 2026-07-25; from now on each release only needs the recipe in `fdroiddata`
master bumped, not a new inclusion request. Note that publication lags the green
build by a day or two — check `repo/status/build.json` before assuming breakage.
F-Droid builds from source after a merge request to `fdroiddata`. The build recipe F-Droid builds from source after a merge request to `fdroiddata`. The build recipe
is kept in-repo at [`fdroid/org.comunes.tane.yml`](fdroid/org.comunes.tane.yml). is kept in-repo at [`fdroid/org.comunes.tane.yml`](fdroid/org.comunes.tane.yml).
Copy it to `metadata/org.comunes.tane.yml` in a fork of fdroiddata, validate with Copy it to `metadata/org.comunes.tane.yml` in a fork of fdroiddata, validate with
`fdroid lint` / `fdroid build -l org.comunes.tane`, then open the MR `fdroid lint` / `fdroid build -l org.comunes.tane`, then open the MR.
([43144](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/43144)).
Every version bump **must** advance the recipe's per-ABI `versionCode`s
(`build × 10 + ABI offset`) together with the `pubspec.yaml` bump — otherwise the
`fdroid_reference` job fails, and a bad tag cannot be fixed by re-tagging (Play
rejects a reused versionCode): cut the next version instead.
**Reproducible, developer-signed.** The recipe pins `AllowedAPKSigningKeys` (the **Reproducible, developer-signed.** The recipe pins `AllowedAPKSigningKeys` (the
SHA-256 of the tane-upload certificate) and a `binary:` URL per ABI. F-Droid SHA-256 of the tane-upload certificate) and a `binary:` URL per ABI. F-Droid

View file

@ -39,9 +39,10 @@ still do it with notebooks and spreadsheets.
- **Your data is yours.** Your information is kept on **your own phone**, protected with a - **Your data is yours.** Your information is kept on **your own phone**, protected with a
key, not on some company's computers. We don't sell it or upload it anywhere. No ads. key, not on some company's computers. We don't sell it or upload it anywhere. No ads.
- **No one can shut it down or control it.** There's no central computer that everything - **No one can shut it down or control it.** There's no central computer that everything
passes through: people communicate **directly with each other**, like passing seeds from passes through: what you share travels over **community servers**, run by people and
hand to hand. So no one can censor it or charge you to use it (that's what being collectives — there are many, and anyone can add their own — so none of them is a center
**decentralized** means). that could censor it or charge you to use it, like passing seeds from hand to hand
(that's what being **decentralized** means).
- **You trust by word of mouth.** You deal with people you know and with those vouched for - **You trust by word of mouth.** You deal with people you know and with those vouched for
by people you trust — the way it's always worked in a village or at a fair — without by people you trust — the way it's always worked in a village or at a fair — without
having to give your real name or phone number unless you want to. having to give your real name or phone number unless you want to.

View file

@ -46,9 +46,10 @@ class NostrOfferTransport implements OfferTransport {
@override @override
Future<OfferPage> discoverPage(DiscoveryQuery query) async { Future<OfferPage> discoverPage(DiscoveryQuery query) async {
final events = await _conn.reqOnce(_filter(query)) // Newest first; the relays dedup within themselves and reqOnce dedups
// Newest first; the relays dedup within themselves and reqOnce dedups // across them, but order is not guaranteed, so sort here on a copy, as
// across them, but order is not guaranteed, so sort here. // the channel may hand out an unmodifiable list.
final events = [...await _conn.reqOnce(_filter(query))]
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); ..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final offers = <Offer>[]; final offers = <Offer>[];
for (final e in events) { for (final e in events) {

View file

@ -26,19 +26,32 @@ class RelayPool implements NostrChannel {
/// Number of relays currently connected (for diagnostics/tests). /// Number of relays currently connected (for diagnostics/tests).
int get relayCount => _connections.length; int get relayCount => _connections.length;
/// Connects to each of [relayUrls], skipping any that fail. Throws /// Connects to each of [relayUrls], skipping any that fail or take longer
/// [StateError] only when NONE are reachable, so the caller can treat that as /// than [connectTimeout] a relay on a silently-filtering network would
/// "offline". /// otherwise hang the whole pool for minutes. Throws [StateError] only when
/// NONE are reachable, so the caller can treat that as "offline".
static Future<RelayPool> connect( static Future<RelayPool> connect(
List<String> relayUrls, { List<String> relayUrls, {
required NostrIdentity identity, required NostrIdentity identity,
Duration connectTimeout = const Duration(seconds: 10),
}) async { }) async {
final connections = <NostrConnection>[]; final connections = <NostrConnection>[];
await Future.wait( await Future.wait(
relayUrls.map((url) async { relayUrls.map((url) async {
try { try {
final attempt = NostrConnection.connect(url, identity: identity);
connections.add( connections.add(
await NostrConnection.connect(url, identity: identity), await attempt.timeout(
connectTimeout,
onTimeout: () {
// Abandon the hung attempt; if it ever completes, close it so
// the socket doesn't leak.
unawaited(
attempt.then((c) => c.close()).catchError((_) {}),
);
throw TimeoutException('relay connect timed out', connectTimeout);
},
),
); );
} catch (_) { } catch (_) {
// Unreachable relay skip it, keep the rest. // Unreachable relay skip it, keep the rest.

View file

@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:commons_core/commons_core.dart'; import 'package:commons_core/commons_core.dart';
@ -58,6 +59,38 @@ void main() {
await r1.stop(); await r1.stop();
}); });
test('a hung relay cannot stall the pool past the connect timeout', () async {
// A server that accepts TCP but never answers the WebSocket handshake
// the shape of a silently-filtered network, where connect hangs for minutes.
final hang = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final held = <Socket>[];
hang.listen(held.add);
final r1 = await MiniRelay.start();
final alice = await idFor(5);
final sw = Stopwatch()..start();
final pool = await RelayPool.connect(
[r1.url, 'ws://127.0.0.1:${hang.port}'],
identity: alice,
connectTimeout: const Duration(milliseconds: 500),
);
sw.stop();
expect(pool.relayCount, 1, reason: 'the live relay is kept');
expect(
sw.elapsed,
lessThan(const Duration(seconds: 5)),
reason: 'the hung relay must not stall the whole pool',
);
await pool.close();
for (final s in held) {
s.destroy();
}
await hang.close();
await r1.stop();
});
test('throws when no relay is reachable (caller treats as offline)', () async { test('throws when no relay is reachable (caller treats as offline)', () async {
final alice = await idFor(4); final alice = await idFor(4);
expect( expect(

View file

@ -121,11 +121,15 @@ a { color: var(--green); }
.lang-menu a.lang:hover { background: var(--container); } .lang-menu a.lang:hover { background: var(--container); }
.lang-menu span.current { color: var(--muted); font-weight: 600; } .lang-menu span.current { color: var(--muted); font-weight: 600; }
/* Narrow phones: brand on its own row, nav wraps beneath it, left-aligned. */ /* Narrow phones: shrink a bit, but only wrap the nav below the brand if it
actually stops fitting the language menu is a compact pill now, not
three spelled-out language names, so it fits alongside "About Legal" down
to fairly small widths. flex-wrap on .site-header handles the overflow
case on its own; no need to force it. */
@media (max-width: 560px) { @media (max-width: 560px) {
.site-header { gap: .5rem; } .site-header { gap: .5rem; }
.brand { font-size: 1.15rem; } .brand { font-size: 1.15rem; }
.site-nav { width: 100%; gap: .4rem 1rem; font-size: .95rem; } .site-nav { gap: .4rem 1rem; font-size: .95rem; }
} }
main { max-width: var(--maxw); margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2rem); } main { max-width: var(--maxw); margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2rem); }
@ -215,7 +219,14 @@ h2 { font-size: clamp(1.4rem, 3vw, 1.9rem); color: var(--green-dark); }
.values li { margin: .45rem 0; } .values li { margin: .45rem 0; }
.get-it { text-align: center; } .get-it { text-align: center; }
.store-badges { margin-top: 1.2rem; } .store-badges {
margin-top: 1.2rem;
display: flex;
flex-wrap: wrap;
gap: .6rem;
justify-content: inherit;
}
.get-it .store-badges { justify-content: center; }
.coming-soon { color: var(--muted); font-style: italic; } .coming-soon { color: var(--muted); font-style: italic; }
.badge { .badge {
display: inline-block; display: inline-block;
@ -227,6 +238,14 @@ h2 { font-size: clamp(1.4rem, 3vw, 1.9rem); color: var(--green-dark); }
margin: .3rem; margin: .3rem;
font-weight: 600; font-weight: 600;
} }
/* Store links: same pill, one-colour icon so Play and F-Droid read apart. */
.badge-store {
display: inline-flex;
align-items: center;
gap: .55rem;
margin: 0;
}
.badge-store .store-icon { flex: none; }
/* Legal docs */ /* Legal docs */
.doc { padding: 2rem 0 3rem; max-width: 760px; } .doc { padding: 2rem 0 3rem; max-width: 760px; }

View file

@ -16,7 +16,7 @@ enableGitInfo = false
license = "AGPL-3.0" license = "AGPL-3.0"
# Links rendered only when set, so nothing broken shows before they exist: # Links rendered only when set, so nothing broken shows before they exist:
playStoreURL = "https://play.google.com/store/apps/details?id=org.comunes.tane" # Google Play playStoreURL = "https://play.google.com/store/apps/details?id=org.comunes.tane" # Google Play
fdroidURL = "" # F-Droid, once published fdroidURL = "https://f-droid.org/packages/org.comunes.tane/" # F-Droid, published 2026-07-28
sourceURL = "https://git.comunes.org/comunes/tane" # public source repository (AGPL) sourceURL = "https://git.comunes.org/comunes/tane" # public source repository (AGPL)
weblateURL = "https://translate.comunes.org/projects/tane/" # translation platform weblateURL = "https://translate.comunes.org/projects/tane/" # translation platform

View file

@ -16,7 +16,7 @@ pillars:
body: "Add a seed or seedling with just a photo and a name — no Latin required. Note the year, how much you have, where it came from, germination tests. Search, filter, snap now and name later." body: "Add a seed or seedling with just a photo and a name — no Latin required. Note the year, how much you have, where it came from, germination tests. Search, filter, snap now and name later."
- icon: "🔒" - icon: "🔒"
title: "Yours, and only yours" title: "Yours, and only yours"
body: "Works fully offline. No account, no server, no trackers. Everything is encrypted on your device. Keep an encrypted backup and a printed recovery sheet to restore your whole bank on a new device." body: "Your seed book works with no internet at all, and Tane doesn't connect to anything until you join the sharing part. No account, no sign-up, no trackers. Everything is encrypted on your device. Keep an encrypted backup and a printed recovery sheet to restore your whole bank on a new device."
- icon: "🤝" - icon: "🤝"
title: "Share, as it's always been done" title: "Share, as it's always been done"
body: "Mark what you have spare to give away, swap or sell. Someone nearby finds it and contacts you — no middleman. Print a catalog of what you share to take to a seed fair." body: "Mark what you have spare to give away, swap or sell. Someone nearby finds it and contacts you — no middleman. Print a catalog of what you share to take to a seed fair."
@ -57,7 +57,7 @@ values:
points: points:
- "No center: no company can shut it down, censor it, or be fined for it." - "No center: no company can shut it down, censor it, or be fined for it."
- "Anti-monopoly by design — sharing seeds multiplies the commons instead of depleting it." - "Anti-monopoly by design — sharing seeds multiplies the commons instead of depleting it."
- "Spreads like a seed, person to person, with no server in the middle." - "Spreads like a seed, person to person; what you share travels through community servers anyone can run — not a company's central server."
- "Free software (AGPL-3.0). No ads, no commissions, no business model." - "Free software (AGPL-3.0). No ads, no commissions, no business model."
--- ---

View file

@ -16,7 +16,7 @@ pillars:
body: "Añade una semilla o plantón con solo una foto y un nombre; sin latín. Anota el año, cuánta tienes, de dónde viene, pruebas de germinación. Busca, filtra, haz la foto ahora y ponle nombre después." body: "Añade una semilla o plantón con solo una foto y un nombre; sin latín. Anota el año, cuánta tienes, de dónde viene, pruebas de germinación. Busca, filtra, haz la foto ahora y ponle nombre después."
- icon: "🔒" - icon: "🔒"
title: "Tuyo, y solo tuyo" title: "Tuyo, y solo tuyo"
body: "Funciona totalmente sin conexión. Sin cuenta, sin servidor, sin rastreadores. Todo va cifrado en tu dispositivo. Guarda una copia cifrada y una hoja de recuperación impresa para restaurar todo tu banco en un dispositivo nuevo." body: "Tu cuaderno de semillas funciona sin nada de internet, y Tane no se conecta a nada hasta que entras en la parte de compartir. Sin cuenta, sin registro, sin rastreadores. Todo va cifrado en tu dispositivo. Guarda una copia cifrada y una hoja de recuperación impresa para restaurar todo tu banco en un dispositivo nuevo."
- icon: "🤝" - icon: "🤝"
title: "Compartir, como se ha hecho siempre" title: "Compartir, como se ha hecho siempre"
body: "Marca lo que te sobra para regalar, intercambiar o vender. Alguien cerca lo encuentra y te contacta, sin intermediarios. Imprime un catálogo de lo que compartes para llevar a una feria de semillas." body: "Marca lo que te sobra para regalar, intercambiar o vender. Alguien cerca lo encuentra y te contacta, sin intermediarios. Imprime un catálogo de lo que compartes para llevar a una feria de semillas."
@ -57,7 +57,7 @@ values:
points: points:
- "Sin centro: ninguna empresa puede apagarlo, censurarlo ni ser multada por ello." - "Sin centro: ninguna empresa puede apagarlo, censurarlo ni ser multada por ello."
- "Anti-monopolio por diseño: compartir semillas multiplica el común en lugar de agotarlo." - "Anti-monopolio por diseño: compartir semillas multiplica el común en lugar de agotarlo."
- "Se propaga como una semilla, de persona a persona, sin servidor en medio." - "Se propaga como una semilla, de persona a persona; lo que compartes viaja por servidores comunitarios que cualquiera puede montar — no por el servidor central de una empresa."
- "Software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio." - "Software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio."
--- ---

View file

@ -57,7 +57,7 @@ values:
points: points:
- "Sem centro: nenhuma empresa o pode desligar, censurar ou multar por isso." - "Sem centro: nenhuma empresa o pode desligar, censurar ou multar por isso."
- "Anti-monopólio por desenho — partilhar sementes multiplica os comuns em vez de os esgotar." - "Anti-monopólio por desenho — partilhar sementes multiplica os comuns em vez de os esgotar."
- "Espalha-se como uma semente, de pessoa a pessoa, sem servidor pelo meio." - "Espalha-se como uma semente, de pessoa a pessoa; o que partilhas viaja por servidores comunitários que qualquer pessoa pode montar — não pelo servidor central de uma empresa."
- "Software livre (AGPL-3.0). Sem publicidade, sem comissões, sem modelo de negócio." - "Software livre (AGPL-3.0). Sem publicidade, sem comissões, sem modelo de negócio."
--- ---

View file

@ -45,9 +45,10 @@ still do it with notebooks and spreadsheets.
- **Your data is yours.** Your information is kept on **your own phone**, protected with a - **Your data is yours.** Your information is kept on **your own phone**, protected with a
key, not on some company's computers. We don't sell it or upload it anywhere. No ads. key, not on some company's computers. We don't sell it or upload it anywhere. No ads.
- **No one can shut it down or control it.** There's no central computer that everything - **No one can shut it down or control it.** There's no central computer that everything
passes through: people communicate **directly with each other**, like passing seeds from passes through: what you share travels over **community servers**, run by people and
hand to hand. So no one can censor it or charge you to use it (that's what being collectives — there are many, and anyone can add their own — so none of them is a center
**decentralized** means). that could censor it or charge you to use it, like passing seeds from hand to hand
(that's what being **decentralized** means).
- **You trust by word of mouth.** You deal with people you know and with those vouched for - **You trust by word of mouth.** You deal with people you know and with those vouched for
by people you trust — the way it's always worked in a village or at a fair — without by people you trust — the way it's always worked in a village or at a fair — without
having to give your real name or phone number unless you want to. having to give your real name or phone number unless you want to.

View file

@ -47,9 +47,10 @@ con libretas y hojas de cálculo.
clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio. clave, no en los ordenadores de una empresa. No la vendemos ni la subimos a ningún sitio.
Sin publicidad. Sin publicidad.
- **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo: - **Nadie la puede apagar ni controlar.** No hay un ordenador central por el que pase todo:
las personas se comunican **directamente entre sí**, como quien se pasa unas semillas de lo que compartes viaja por **servidores de la comunidad**, mantenidos por personas y
mano en mano. Por eso nadie puede censurarla ni cobrarte por usarla (eso es que sea colectivos — hay varios y cualquiera puede añadir el suyo — así que ninguno es un centro
**descentralizada**). desde el que censurarla o cobrarte por usarla, como quien se pasa unas semillas de mano
en mano (eso es que sea **descentralizada**).
- **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te - **Te fías por el boca a boca.** Te relacionas con gente que conoces y con quien te
recomienda gente de confianza —como siempre se ha hecho en un pueblo o en una feria— sin recomienda gente de confianza —como siempre se ha hecho en un pueblo o en una feria— sin
tener que dar tu nombre real ni tu teléfono si no quieres. tener que dar tu nombre real ni tu teléfono si no quieres.

View file

@ -45,9 +45,10 @@ com cadernos e folhas de cálculo.
- **Os teus dados são teus.** A tua informação fica guardada no **teu próprio telemóvel**, protegida com uma - **Os teus dados são teus.** A tua informação fica guardada no **teu próprio telemóvel**, protegida com uma
chave, não nos computadores de nenhuma empresa. Não a vendemos nem a colocamos em lado nenhum. Sem publicidade. chave, não nos computadores de nenhuma empresa. Não a vendemos nem a colocamos em lado nenhum. Sem publicidade.
- **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa: - **Ninguém o pode desligar ou controlar.** Não há nenhum computador central por onde tudo passa:
as pessoas comunicam **diretamente entre si**, como quem passa sementes de mão em mão. Assim, o que partilhas viaja por **servidores da comunidade**, mantidos por pessoas e coletivos —
ninguém o pode censurar nem cobrar para o usares (é isso que significa ser há vários e qualquer pessoa pode acrescentar o seu — pelo que nenhum deles é um centro
**descentralizado**). a partir do qual censurar ou cobrar para o usares, como quem passa sementes de mão em mão
(é isso que significa ser **descentralizado**).
- **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas - **Confias de boca em boca.** Lidas com pessoas que conheces e com as que são avalizadas
por pessoas em quem confias — como sempre funcionou numa aldeia ou numa feira — sem por pessoas em quem confias — como sempre funcionou numa aldeia ou numa feira — sem
teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras. teres de dar o teu nome verdadeiro ou o teu telefone a não ser que queiras.

View file

@ -45,7 +45,7 @@
"other": "Where Tane is right now" "other": "Where Tane is right now"
}, },
"statusBody": { "statusBody": {
"other": "The inventory works today: keep your seeds offline, encrypted, with no account. The neighbourly sharing layer — offers, messaging and trust — is now arriving, in beta. Everything already works with zero network." "other": "The inventory works today: keep your seeds offline, encrypted, with no account — the app doesn't connect to anything until you join the sharing part. The neighbourly sharing layer — offers, messaging and trust — is now arriving, in beta."
}, },
"screenCapHome": { "screenCapHome": {
"other": "Home — your bank and the market, one tap away" "other": "Home — your bank and the market, one tap away"
@ -114,7 +114,7 @@
"other": "Do I need an account?" "other": "Do I need an account?"
}, },
"faqAccountA": { "faqAccountA": {
"other": "No account, no sign-up, no server. Your data stays on your device." "other": "No account and no sign-up. Your seeds stay on your device, and Tane doesn't connect to anything until you join the sharing part — then it uses community servers you can change or switch off."
}, },
"faqOfflineQ": { "faqOfflineQ": {
"other": "Does it work offline?" "other": "Does it work offline?"

View file

@ -45,7 +45,7 @@
"other": "En qué punto está Tane" "other": "En qué punto está Tane"
}, },
"statusBody": { "statusBody": {
"other": "El inventario ya funciona: guarda tus semillas sin conexión, cifradas y sin cuenta. La capa de intercambio vecinal —ofertas, mensajería y confianza— está llegando ya, en beta. Todo funciona sin ninguna red." "other": "El inventario ya funciona: guarda tus semillas sin conexión, cifradas y sin cuenta; la app no se conecta a nada hasta que entras en la parte de compartir. La capa de intercambio vecinal —ofertas, mensajería y confianza— está llegando ya, en beta."
}, },
"screenCapHome": { "screenCapHome": {
"other": "Inicio — tu banco y el mercado a un toque" "other": "Inicio — tu banco y el mercado a un toque"
@ -114,7 +114,7 @@
"other": "¿Necesito cuenta?" "other": "¿Necesito cuenta?"
}, },
"faqAccountA": { "faqAccountA": {
"other": "Sin cuenta, sin registro, sin servidor. Tus datos se quedan en tu dispositivo." "other": "Sin cuenta y sin registro. Tus semillas se quedan en tu dispositivo, y Tane no se conecta a nada hasta que entras en la parte de compartir; entonces usa servidores comunitarios que puedes cambiar o desactivar."
}, },
"faqOfflineQ": { "faqOfflineQ": {
"other": "¿Funciona sin conexión?" "other": "¿Funciona sin conexión?"

View file

@ -1,10 +1,26 @@
{{- /* Renders store links only when configured, so nothing broken shows pre-launch. */ -}} {{- /* Renders store links only when configured, so nothing broken shows pre-launch.
Icons are single-colour (currentColor) so the two stores are told apart by
shape, not by brand colour: a play triangle vs. the free-software robot. */ -}}
<div class="store-badges"> <div class="store-badges">
{{- with .Site.Params.playStoreURL -}} {{- with .Site.Params.playStoreURL -}}
<a class="badge" href="{{ . }}">Google Play</a> <a class="badge badge-store" href="{{ . }}">
<svg class="store-icon" aria-hidden="true" viewBox="0 0 24 24" width="22" height="22">
<path fill="currentColor" d="M4.6 2.1c-.3.2-.5.6-.5 1v17.8c0 .4.2.8.5 1l9.5-9.9-9.5-9.9zm10.9 8.5L6.2 1.4l11.4 6.6-2.1 2.6zM6.2 22.6l9.3-9.2 2.1 2.6-11.4 6.6zm12.6-7.3-2.4-2.9 2.4-2.9 2.7 1.6c.7.4.7 1.4 0 1.8l-2.7 1.6z"/>
</svg>
<span>Google Play</span>
</a>
{{- end -}} {{- end -}}
{{- with .Site.Params.fdroidURL -}} {{- with .Site.Params.fdroidURL -}}
<a class="badge" href="{{ . }}">F-Droid</a> <a class="badge badge-store" href="{{ . }}">
<svg class="store-icon" aria-hidden="true" viewBox="0 0 24 24" width="22" height="22">
<g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<path d="M8.1 2.6 9.8 5.7"/>
<path d="M15.9 2.6 14.2 5.7"/>
</g>
<path fill="currentColor" fill-rule="evenodd" d="M7.3 6.4h9.4c1.8 0 3.3 1.5 3.3 3.3v8.6c0 1.8-1.5 3.3-3.3 3.3H7.3A3.3 3.3 0 0 1 4 18.3V9.7c0-1.8 1.5-3.3 3.3-3.3zm1.8 4.8a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8zm5.8 0a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8z"/>
</svg>
<span>F-Droid</span>
</a>
{{- end -}} {{- end -}}
{{- if and (not .Site.Params.playStoreURL) (not .Site.Params.fdroidURL) -}} {{- if and (not .Site.Params.playStoreURL) (not .Site.Params.fdroidURL) -}}
<p class="coming-soon">{{ T "getItComingSoon" }}</p> <p class="coming-soon">{{ T "getItComingSoon" }}</p>