Compare commits

...

4 commits

Author SHA1 Message Date
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
46 changed files with 1100 additions and 220 deletions

View file

@ -47,7 +47,75 @@ on:
default: '' 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:
@ -121,11 +189,16 @@ jobs:
fdroid_reference_armeabi_v7a: fdroid_reference_armeabi_v7a:
# Serialize after play so two heavy Android builds never run concurrently on # Serialize after play so two heavy Android builds never run concurrently on
# the shared host (capacity:2) — that contention OOM-killed play's Gradle # the shared host (capacity:2) — that contention OOM-killed play's Gradle
# daemon. `if: always()` keeps the fdroid chain independent of play's result # daemon. Gated on the test jobs, but kept INDEPENDENT of play's result:
# (runs after play whether it succeeded, failed, or — on workflow_dispatch — # `!cancelled()` is a status function, which drops the implicit success() on
# was skipped). # `needs`, so play failing/skipping (e.g. on workflow_dispatch, where play is
needs: play # tag-only) does not block the fdroid chain — while the explicit result checks
if: ${{ always() }} # 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

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

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": {

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": {
@ -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": {
@ -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": {

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": {

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": {

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-22 at 11:02 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
@ -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',
@ -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';
} }
@ -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',
@ -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';
} }
@ -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',
@ -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';
} }
@ -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',
@ -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';
} }
@ -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é',
@ -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';
} }
@ -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',
@ -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';
} }
@ -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',
@ -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

@ -63,8 +63,11 @@ 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; _started = true;
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) { _onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
_knownOffline = !isOnline; _knownOffline = !isOnline;
@ -139,6 +142,19 @@ 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(); _cancelRetry();

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

@ -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;
@ -156,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();
} }
@ -786,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();
@ -1067,6 +1084,30 @@ class _ConfigSheetState extends State<_ConfigSheet> {
), ),
), ),
children: [ 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( TextField(
key: const Key('market.area'), key: const Key('market.area'),
controller: _area, controller: _area,

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.15+17 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

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

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

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

@ -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.15 - versionName: 0.1.16
versionCode: 171 versionCode: 181
commit: v0.1.15 commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d
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.15 - versionName: 0.1.16
versionCode: 172 versionCode: 182
commit: v0.1.15 commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d
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.15 - versionName: 0.1.16
versionCode: 173 versionCode: 183
commit: v0.1.15 commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d
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. The hash below still points at
# v0.1.15; it is synced (here and in the fdroiddata fork) right after v0.1.16 is
# tagged, before the fork's pipeline runs.
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.15 CurrentVersion: 0.1.16
CurrentVersionCode: 173 CurrentVersionCode: 183

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."

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."

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?"