From 7ae35920f8062dc60a951e98fa4bac9cf4f5e88f Mon Sep 17 00:00:00 2001 From: vjrj Date: Wed, 22 Jul 2026 18:38:06 +0200 Subject: [PATCH 1/4] ci(release): gate Play + F-Droid deploy on the test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .forgejo/workflows/release.yml | 83 ++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index f956269..d598b06 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -47,7 +47,75 @@ on: default: '' 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: + # Gate on the tests: with no status-check function in this `if`, the implicit + # success() on `needs` still applies — play runs only on a tag AND green tests. + needs: [analyze, test-commons-core, test-app-seeds] if: ${{ startsWith(github.ref, 'refs/tags/') }} runs-on: docker container: @@ -121,11 +189,16 @@ jobs: fdroid_reference_armeabi_v7a: # Serialize after play so two heavy Android builds never run concurrently on # the shared host (capacity:2) — that contention OOM-killed play's Gradle - # daemon. `if: always()` keeps the fdroid chain independent of play's result - # (runs after play whether it succeeded, failed, or — on workflow_dispatch — - # was skipped). - needs: play - if: ${{ always() }} + # daemon. Gated on the test jobs, but kept INDEPENDENT of play's result: + # `!cancelled()` is a status function, which drops the implicit success() on + # `needs`, so play failing/skipping (e.g. on workflow_dispatch, where play is + # tag-only) does not block the fdroid chain — while the explicit result checks + # still hard-gate on green tests. Nothing gets built/uploaded on a red suite. + needs: [analyze, test-commons-core, test-app-seeds, play] + if: ${{ !cancelled() + && needs.analyze.result == 'success' + && needs.test-commons-core.result == 'success' + && needs.test-app-seeds.result == 'success' }} runs-on: docker container: image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie From 62123582f583e72f1ff7ad792f7014240a495b42 Mon Sep 17 00:00:00 2001 From: vjrj Date: Thu, 23 Jul 2026 22:52:17 +0200 Subject: [PATCH 2/4] docs(fdroid): sync metadata mirror to v0.1.15 with full commit hash --- docs/fdroid/org.comunes.tane.yml | 55 +++++++++++++++++--------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/docs/fdroid/org.comunes.tane.yml b/docs/fdroid/org.comunes.tane.yml index c3891e8..2046fca 100644 --- a/docs/fdroid/org.comunes.tane.yml +++ b/docs/fdroid/org.comunes.tane.yml @@ -15,19 +15,14 @@ AutoName: Tane RepoType: 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: - versionName: 0.1.15 versionCode: 171 - commit: v0.1.15 + commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d subdir: apps/app_seeds 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: - flutter@stable - tesseract4android@4.9.0 @@ -40,9 +35,9 @@ Builds: - export PUB_CACHE=$(pwd)/.pub-cache - $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter pub get --enforce-lockfile - - "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.daemon=false" >> apps/app_seeds/android/gradle.properties' + - 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.daemon=false" >> apps/app_seeds/android/gradle.properties - cd apps/app_seeds - $$flutter$$/bin/dart run slang - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs @@ -53,17 +48,18 @@ Builds: - pushd $$tesseract4android$$/tesseract4android - gradle --no-daemon assembleStandardRelease - popd - - cp + - cp $$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 - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm - versionName: 0.1.15 versionCode: 172 - commit: v0.1.15 + commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d subdir: apps/app_seeds 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: - flutter@stable - tesseract4android@4.9.0 @@ -76,9 +72,9 @@ Builds: - export PUB_CACHE=$(pwd)/.pub-cache - $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter pub get --enforce-lockfile - - "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.daemon=false" >> apps/app_seeds/android/gradle.properties' + - 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.daemon=false" >> apps/app_seeds/android/gradle.properties - cd apps/app_seeds - $$flutter$$/bin/dart run slang - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs @@ -89,17 +85,18 @@ Builds: - pushd $$tesseract4android$$/tesseract4android - gradle --no-daemon assembleStandardRelease - popd - - cp + - cp $$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 - $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm64 - versionName: 0.1.15 versionCode: 173 - commit: v0.1.15 + commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d subdir: apps/app_seeds 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: - flutter@stable - tesseract4android@4.9.0 @@ -112,9 +109,9 @@ Builds: - export PUB_CACHE=$(pwd)/.pub-cache - $$flutter$$/bin/flutter config --no-analytics - $$flutter$$/bin/flutter pub get --enforce-lockfile - - "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.daemon=false" >> apps/app_seeds/android/gradle.properties' + - 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.daemon=false" >> apps/app_seeds/android/gradle.properties - cd apps/app_seeds - $$flutter$$/bin/dart run slang - $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs @@ -125,18 +122,26 @@ Builds: - pushd $$tesseract4android$$/tesseract4android - gradle --no-daemon assembleStandardRelease - popd - - cp + - cp $$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 - $$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; +# f04dd3da1a3993c5a8f3222544f75b979d2c7f9d is the commit tag v0.1.15 points to. +AllowedAPKSigningKeys: ddfae432091b8248a8a4a1b353487fa626301f4357ef835e94ec312f69418e38 + AutoUpdateMode: Version UpdateCheckMode: Tags v[\d.]+ VercodeOperation: - '%c * 10 + 1' - '%c * 10 + 2' - '%c * 10 + 3' -UpdateCheckData: +UpdateCheckData: apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+ CurrentVersion: 0.1.15 CurrentVersionCode: 173 From fed0e8200e5f49392df4b75df5bb0e00e06b48be Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 25 Jul 2026 16:47:56 +0200 Subject: [PATCH 3/4] feat(sharing): make going online opt-in, and show what it unlocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/app_seeds/lib/app.dart | 19 +- apps/app_seeds/lib/bootstrap.dart | 22 +- apps/app_seeds/lib/i18n/ast.i18n.json | 3 +- apps/app_seeds/lib/i18n/de.i18n.json | 1 - apps/app_seeds/lib/i18n/en.i18n.json | 17 +- apps/app_seeds/lib/i18n/es.i18n.json | 17 +- apps/app_seeds/lib/i18n/fr.i18n.json | 1 - apps/app_seeds/lib/i18n/ja.i18n.json | 1 - apps/app_seeds/lib/i18n/pt.i18n.json | 1 - apps/app_seeds/lib/i18n/pt_BR.i18n.json | 1 - apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_de.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 56 ++++- apps/app_seeds/lib/i18n/strings_es.g.dart | 34 +++- apps/app_seeds/lib/i18n/strings_fr.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ja.g.dart | 2 - apps/app_seeds/lib/i18n/strings_pt.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_pt_BR.g.dart | 4 +- .../lib/services/sharing_switch.dart | 46 +++++ .../lib/services/social_connection.dart | 18 +- .../lib/services/social_settings.dart | 51 ++++- apps/app_seeds/lib/ui/app_drawer.dart | 192 +++++++++++------- apps/app_seeds/lib/ui/home_screen.dart | 61 +++--- apps/app_seeds/lib/ui/market_gate.dart | 27 ++- apps/app_seeds/lib/ui/market_screen.dart | 51 ++++- .../lib/ui/sharing_invite_sheet.dart | 123 +++++++++++ .../test/screenshots/screenshots_test.dart | 2 +- .../test/services/social_connection_test.dart | 66 ++++++ .../test/services/social_settings_test.dart | 30 +++ apps/app_seeds/test/support/test_support.dart | 10 + apps/app_seeds/test/ui/home_screen_test.dart | 67 +++++- apps/app_seeds/test/ui/market_gate_test.dart | 62 +++++- .../test/ui/sharing_invite_sheet_test.dart | 92 +++++++++ .../test/ui/small_screen_overflow_test.dart | 2 +- 35 files changed, 926 insertions(+), 173 deletions(-) create mode 100644 apps/app_seeds/lib/services/sharing_switch.dart create mode 100644 apps/app_seeds/lib/ui/sharing_invite_sheet.dart create mode 100644 apps/app_seeds/test/ui/sharing_invite_sheet_test.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index a6aec7b..d02af0a 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -23,6 +23,7 @@ import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; +import 'services/sharing_switch.dart'; import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; import 'ui/about_screen.dart'; @@ -70,6 +71,7 @@ class TaneApp extends StatelessWidget { this.notifications, this.showIntro = false, this.autoBackup, + SharingSwitch? sharing, super.key, }) : _router = _buildRouter( repository, @@ -87,6 +89,17 @@ class TaneApp extends StatelessWidget { savedSearches, socialAccounts, inbox, + // `bootstrap` passes the real switch, holding the person's stored + // answer. A widget test that doesn't care gets one already on, so + // screens behave as they did before sharing became opt-in. + sharing ?? + (socialSettings == null + ? null + : SharingSwitch( + settings: socialSettings, + connection: connection, + enabled: true, + )), ) { // A tapped message notification opens that peer's chat. Wired here because // the router only exists now; taps only happen while the app is foreground, @@ -161,14 +174,17 @@ class TaneApp extends StatelessWidget { SavedSearchesStore? savedSearches, SocialAccountStore? socialAccounts, InboxService? inbox, + SharingSwitch? sharing, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', routes: [ GoRoute( path: '/', + // A null switch means there is no social layer at all: the market card + // and the social drawer entries aren't drawn. builder: (context, state) => - HomeScreen(marketEnabled: social != null), + HomeScreen(sharing: sharing, onboarding: onboarding), ), if (social != null && socialSettings != null && connection != null) GoRoute( @@ -181,6 +197,7 @@ class TaneApp extends StatelessWidget { outbox: outbox, onboarding: onboarding, savedSearches: savedSearches, + sharing: sharing, ), ), if (social != null && connection != null) diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index e0ecc26..62a4c93 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -23,6 +23,7 @@ import 'services/profile_store.dart'; import 'services/saved_offers_store.dart'; import 'services/saved_search_alert_service.dart'; import 'services/saved_searches_store.dart'; +import 'services/sharing_switch.dart'; import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; @@ -81,14 +82,24 @@ class _BootstrapState extends State { final savedSearchAlerts = getIt.isRegistered() ? getIt() : 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().migrateSharingEnabled( + introSeen: introSeen, + ); + // Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the // shared connection starts connecting, so the first session is caught; then - // bring the connection up. + // bring the connection up. The listeners are harmless while sharing is off: + // they only ever react to a session, and none arrives. inbox?.start(); sync?.start(); plantares?.start(); savedSearchAlerts?.start(); - connection?.start(); + if (sharingOn) connection?.start(); return TaneApp( repository: getIt(), @@ -108,7 +119,12 @@ class _BootstrapState extends State { socialAccounts: getIt(), inbox: inbox, notifications: notifications, - showIntro: !await onboarding.introSeen(), + showIntro: !introSeen, + sharing: SharingSwitch( + settings: getIt(), + connection: connection, + enabled: sharingOn, + ), autoBackup: getIt.isRegistered() ? getIt() : null, diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 7b1a054..c7d0454 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -58,8 +58,7 @@ "cancel": "Encaboxar", "delete": "Desaniciar", "edit": "Editar", - "type": "Triba", - "comingSoon": "Aína" + "type": "Triba" }, "home": { "tagline": "Comparte y cultiva simiente llocal", diff --git a/apps/app_seeds/lib/i18n/de.i18n.json b/apps/app_seeds/lib/i18n/de.i18n.json index d6230c7..45889cc 100644 --- a/apps/app_seeds/lib/i18n/de.i18n.json +++ b/apps/app_seeds/lib/i18n/de.i18n.json @@ -59,7 +59,6 @@ "delete": "Löschen", "edit": "Bearbeiten", "type": "Typ", - "comingSoon": "Bald", "offline": "Offline - Teilen ist unterbrochen" }, "home": { diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index e1caf31..4247fd4 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -72,7 +72,6 @@ "delete": "Delete", "edit": "Edit", "type": "Type", - "comingSoon": "Coming soon", "offline": "You're offline — sharing is paused" }, "home": { @@ -572,7 +571,9 @@ "noProfile": "This person hasn't shared a profile yet", "copyId": "Copy code", "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": { "title": "Your profile", @@ -780,7 +781,8 @@ "publicNote": "What you publish here is public, and copies may remain even if you remove it later.", "viewLegal": "Privacy & rules", "accept": "I agree", - "decline": "Not now" + "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": { "offer": "Report this offer", @@ -806,5 +808,14 @@ "manageTitle": "Blocked people", "manageEmpty": "You haven't blocked anyone", "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" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 5b1a570..a3a9214 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -72,7 +72,6 @@ "delete": "Eliminar", "edit": "Editar", "type": "Tipo", - "comingSoon": "Pronto", "offline": "Sin conexión — el compartir está en pausa" }, "home": { @@ -571,7 +570,9 @@ "noProfile": "Esta persona aún no ha compartido su perfil", "copyId": "Copiar código", "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": { "title": "Tu perfil", @@ -779,7 +780,8 @@ "publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.", "viewLegal": "Privacidad y normas", "accept": "Acepto", - "decline": "Ahora no" + "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": { "offer": "Denunciar esta oferta", @@ -805,5 +807,14 @@ "manageTitle": "Personas bloqueadas", "manageEmpty": "No has bloqueado a nadie", "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" } } diff --git a/apps/app_seeds/lib/i18n/fr.i18n.json b/apps/app_seeds/lib/i18n/fr.i18n.json index 8b5b4b8..c285006 100644 --- a/apps/app_seeds/lib/i18n/fr.i18n.json +++ b/apps/app_seeds/lib/i18n/fr.i18n.json @@ -59,7 +59,6 @@ "delete": "Supprimer", "edit": "Modifier", "type": "Type", - "comingSoon": "À venir", "offline": "Vous êtes hors ligne — le partage est en pause" }, "home": { diff --git a/apps/app_seeds/lib/i18n/ja.i18n.json b/apps/app_seeds/lib/i18n/ja.i18n.json index 6f07653..42fbff1 100644 --- a/apps/app_seeds/lib/i18n/ja.i18n.json +++ b/apps/app_seeds/lib/i18n/ja.i18n.json @@ -8,7 +8,6 @@ "delete": "削除", "edit": "編集", "type": "種類", - "comingSoon": "近日公開", "offline": "オフラインです — 共有を一時停止しています" }, "menu": { diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 810b0d4..5c93403 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -72,7 +72,6 @@ "delete": "Eliminar", "edit": "Editar", "type": "Tipo", - "comingSoon": "Em breve", "offline": "Sem ligação — a partilha está em pausa" }, "home": { diff --git a/apps/app_seeds/lib/i18n/pt_BR.i18n.json b/apps/app_seeds/lib/i18n/pt_BR.i18n.json index bc4edba..94443df 100644 --- a/apps/app_seeds/lib/i18n/pt_BR.i18n.json +++ b/apps/app_seeds/lib/i18n/pt_BR.i18n.json @@ -72,7 +72,6 @@ "delete": "Eliminar", "edit": "Editar", "type": "Tipo", - "comingSoon": "Em breve", "offline": "Sem ligação — a compartilha está em pausa" }, "home": { diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 0132a97..b28d25d 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// 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 // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index c3ee9bd..c2f5033 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -199,7 +199,6 @@ class _Translations$common$ast extends Translations$common$en { @override String get delete => 'Desaniciar'; @override String get edit => 'Editar'; @override String get type => 'Triba'; - @override String get comingSoon => 'Aína'; } // Path: home @@ -1465,7 +1464,6 @@ extension on TranslationsAst { 'common.delete' => 'Desaniciar', 'common.edit' => 'Editar', 'common.type' => 'Triba', - 'common.comingSoon' => 'Aína', 'home.tagline' => 'Comparte y cultiva simiente llocal', 'home.openMarket' => 'Mercáu', 'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca', @@ -1930,9 +1928,9 @@ extension on TranslationsAst { 'handover.promiseGave' => 'Van devolveme semiente', 'handover.promiseReceived' => 'Voi devolver semiente', 'sale.title' => 'Ventes', + 'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.', _ => null, } ?? switch (path) { - 'sale.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.empty' => 'Entá nun hai ventes. Anota equí lo que vendes o merques.', 'sale.iSold' => 'Vendí', diff --git a/apps/app_seeds/lib/i18n/strings_de.g.dart b/apps/app_seeds/lib/i18n/strings_de.g.dart index 20e6a89..22e497e 100644 --- a/apps/app_seeds/lib/i18n/strings_de.g.dart +++ b/apps/app_seeds/lib/i18n/strings_de.g.dart @@ -199,7 +199,6 @@ class _Translations$common$de extends Translations$common$en { @override String get delete => 'Löschen'; @override String get edit => 'Bearbeiten'; @override String get type => 'Typ'; - @override String get comingSoon => 'Bald'; @override String get offline => 'Offline - Teilen ist unterbrochen'; } @@ -1461,7 +1460,6 @@ extension on TranslationsDe { 'common.delete' => 'Löschen', 'common.edit' => 'Bearbeiten', 'common.type' => 'Typ', - 'common.comingSoon' => 'Bald', 'common.offline' => 'Offline - Teilen ist unterbrochen', 'home.tagline' => 'Teile und baue lokale Samen an', 'home.openMarket' => 'Markt', @@ -1926,9 +1924,9 @@ extension on TranslationsDe { 'sale.add' => 'Verkauf notieren', 'sale.empty' => 'Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.', 'sale.iSold' => 'Ich verkaufte', + 'sale.iBought' => 'Ich kaufte', _ => null, } ?? switch (path) { - 'sale.iBought' => 'Ich kaufte', 'sale.direction' => 'Verkauft oder gekauft?', 'sale.counterparty' => 'Mit wem?', 'sale.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index c1d1e8a..f934138 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -93,6 +93,7 @@ class Translations with BaseTranslations { late final Translations$marketGate$en marketGate = Translations$marketGate$en.internal(_root); late final Translations$report$en report = Translations$report$en.internal(_root); late final Translations$block$en block = Translations$block$en.internal(_root); + late final Translations$sharingInvite$en sharingInvite = Translations$sharingInvite$en.internal(_root); } // Path: avatar @@ -340,9 +341,6 @@ class Translations$common$en { /// en: 'Type' String get type => 'Type'; - /// en: 'Coming soon' - String get comingSoon => 'Coming soon'; - /// en: 'You're offline — sharing is paused' String get offline => 'You\'re offline — sharing is paused'; } @@ -1578,6 +1576,12 @@ class Translations$market$en { /// en: 'Photo' String get photo => 'Photo'; + + /// en: 'Sharing is on' + String get sharingOnLabel => 'Sharing is on'; + + /// en: 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.' + String get sharingOnHelp => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.'; } // Path: profile @@ -2241,6 +2245,9 @@ class Translations$marketGate$en { /// en: 'Not now' String get decline => 'Not now'; + + /// en: 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn't connect to anything.' + String get networkNote => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.'; } // Path: report @@ -2324,6 +2331,36 @@ class Translations$block$en { String get unblock => 'Unblock'; } +// Path: sharingInvite +class Translations$sharingInvite$en { + Translations$sharingInvite$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'This wakes up when you start sharing' + String get title => 'This wakes up when you start sharing'; + + /// en: 'Write to whoever has seeds near you' + String get perkChat => 'Write to whoever has seeds near you'; + + /// en: 'Keep the offers you like' + String get perkFavorites => 'Keep the offers you like'; + + /// en: 'Your circle of people you trust' + String get perkPeople => 'Your circle of people you trust'; + + /// en: 'For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.' + String get networkNote => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.'; + + /// en: 'Start sharing' + String get start => 'Start sharing'; + + /// en: 'Not now' + String get notNow => 'Not now'; +} + // Path: intro.slides class Translations$intro$slides$en { Translations$intro$slides$en.internal(this._root); @@ -2839,7 +2876,6 @@ extension on Translations { 'common.delete' => 'Delete', 'common.edit' => 'Edit', 'common.type' => 'Type', - 'common.comingSoon' => 'Coming soon', 'common.offline' => 'You\'re offline — sharing is paused', 'home.tagline' => 'Share and grow local seeds', 'home.openMarket' => 'Market', @@ -3218,6 +3254,8 @@ extension on Translations { 'market.copyId' => 'Copy code', 'market.idCopied' => 'Code copied', 'market.photo' => 'Photo', + 'market.sharingOnLabel' => 'Sharing is on', + 'market.sharingOnHelp' => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.', 'profile.title' => 'Your profile', 'profile.name' => 'Display name', 'profile.nameHint' => 'How others see you', @@ -3292,9 +3330,9 @@ extension on Translations { 'plantare.delete' => 'Remove', 'plantare.statusReturned' => 'Returned', 'plantare.statusForgiven' => 'Settled', - 'plantare.openSection' => 'Open', _ => null, } ?? switch (path) { + 'plantare.openSection' => 'Open', 'plantare.settledSection' => 'Done', 'plantare.removeConfirm' => 'Remove this commitment?', 'plantare.returnBy' => ({required Object date}) => 'Return by ${date}', @@ -3402,6 +3440,7 @@ extension on Translations { 'marketGate.viewLegal' => 'Privacy & rules', 'marketGate.accept' => 'I agree', 'marketGate.decline' => 'Not now', + 'marketGate.networkNote' => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.', 'report.offer' => 'Report this offer', 'report.person' => 'Report this person', 'report.title' => 'Report', @@ -3423,6 +3462,13 @@ extension on Translations { 'block.manageTitle' => 'Blocked people', 'block.manageEmpty' => 'You haven\'t blocked anyone', 'block.unblock' => 'Unblock', + 'sharingInvite.title' => 'This wakes up when you start sharing', + 'sharingInvite.perkChat' => 'Write to whoever has seeds near you', + 'sharingInvite.perkFavorites' => 'Keep the offers you like', + 'sharingInvite.perkPeople' => 'Your circle of people you trust', + 'sharingInvite.networkNote' => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.', + 'sharingInvite.start' => 'Start sharing', + 'sharingInvite.notNow' => 'Not now', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 3790e39..1d6a9f5 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -92,6 +92,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Eliminar'; @override String get edit => 'Editar'; @override String get type => 'Tipo'; - @override String get comingSoon => 'Pronto'; @override String get offline => 'Sin conexión — el compartir está en pausa'; } @@ -839,6 +839,8 @@ class _Translations$market$es extends Translations$market$en { @override String get copyId => 'Copiar código'; @override String get idCopied => 'Código copiado'; @override String get photo => 'Foto'; + @override String get sharingOnLabel => 'Compartir está activado'; + @override String get sharingOnHelp => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.'; } // Path: profile @@ -1138,6 +1140,7 @@ class _Translations$marketGate$es extends Translations$marketGate$en { @override String get viewLegal => 'Privacidad y normas'; @override String get accept => 'Acepto'; @override String get decline => 'Ahora no'; + @override String get networkNote => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.'; } // Path: report @@ -1179,6 +1182,22 @@ class _Translations$block$es extends Translations$block$en { @override String get unblock => 'Desbloquear'; } +// Path: sharingInvite +class _Translations$sharingInvite$es extends Translations$sharingInvite$en { + _Translations$sharingInvite$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Esto se enciende cuando empiezas a compartir'; + @override String get perkChat => 'Escribirte con quien tiene semillas cerca'; + @override String get perkFavorites => 'Guardar las ofertas que te gustan'; + @override String get perkPeople => 'Tu círculo de gente de confianza'; + @override String get networkNote => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.'; + @override String get start => 'Empezar a compartir'; + @override String get notNow => 'Ahora no'; +} + // Path: intro.slides class _Translations$intro$slides$es extends Translations$intro$slides$en { _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1578,7 +1597,6 @@ extension on TranslationsEs { 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', - 'common.comingSoon' => 'Pronto', 'common.offline' => 'Sin conexión — el compartir está en pausa', 'home.tagline' => 'Comparte y cultiva semillas locales', 'home.openMarket' => 'Mercado', @@ -1956,6 +1974,8 @@ extension on TranslationsEs { 'market.copyId' => 'Copiar código', 'market.idCopied' => 'Código copiado', 'market.photo' => 'Foto', + 'market.sharingOnLabel' => 'Compartir está activado', + 'market.sharingOnHelp' => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.', 'profile.title' => 'Tu perfil', 'profile.name' => 'Nombre', 'profile.nameHint' => 'Cómo te ven los demás', @@ -2031,9 +2051,9 @@ extension on TranslationsEs { 'plantare.statusReturned' => 'Devuelto', 'plantare.statusForgiven' => 'Saldado', 'plantare.openSection' => 'Pendientes', - 'plantare.settledSection' => 'Hechos', _ => null, } ?? switch (path) { + 'plantare.settledSection' => 'Hechos', 'plantare.removeConfirm' => '¿Quitar este compromiso?', 'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}', 'plantare.overdue' => 'vencido', @@ -2140,6 +2160,7 @@ extension on TranslationsEs { 'marketGate.viewLegal' => 'Privacidad y normas', 'marketGate.accept' => 'Acepto', 'marketGate.decline' => 'Ahora no', + 'marketGate.networkNote' => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.', 'report.offer' => 'Denunciar esta oferta', 'report.person' => 'Denunciar a esta persona', 'report.title' => 'Denunciar', @@ -2161,6 +2182,13 @@ extension on TranslationsEs { 'block.manageTitle' => 'Personas bloqueadas', 'block.manageEmpty' => 'No has bloqueado a nadie', 'block.unblock' => 'Desbloquear', + 'sharingInvite.title' => 'Esto se enciende cuando empiezas a compartir', + 'sharingInvite.perkChat' => 'Escribirte con quien tiene semillas cerca', + 'sharingInvite.perkFavorites' => 'Guardar las ofertas que te gustan', + 'sharingInvite.perkPeople' => 'Tu círculo de gente de confianza', + 'sharingInvite.networkNote' => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.', + 'sharingInvite.start' => 'Empezar a compartir', + 'sharingInvite.notNow' => 'Ahora no', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_fr.g.dart b/apps/app_seeds/lib/i18n/strings_fr.g.dart index 9575732..528e2d4 100644 --- a/apps/app_seeds/lib/i18n/strings_fr.g.dart +++ b/apps/app_seeds/lib/i18n/strings_fr.g.dart @@ -199,7 +199,6 @@ class _Translations$common$fr extends Translations$common$en { @override String get delete => 'Supprimer'; @override String get edit => 'Modifier'; @override String get type => 'Type'; - @override String get comingSoon => 'À venir'; @override String get offline => 'Vous êtes hors ligne — le partage est en pause'; } @@ -1461,7 +1460,6 @@ extension on TranslationsFr { 'common.delete' => 'Supprimer', 'common.edit' => 'Modifier', 'common.type' => 'Type', - 'common.comingSoon' => 'À venir', 'common.offline' => 'Vous êtes hors ligne — le partage est en pause', 'home.tagline' => 'Partagez et cultivez des graines locales', 'home.openMarket' => 'Marché', @@ -1926,9 +1924,9 @@ extension on TranslationsFr { 'sale.add' => 'Enregistrer une vente', 'sale.empty' => 'Pas encore de ventes. Notez ici ce que vous vendez ou achetez.', 'sale.iSold' => 'J\'ai vendu', + 'sale.iBought' => 'J\'ai acheté', _ => null, } ?? switch (path) { - 'sale.iBought' => 'J\'ai acheté', 'sale.direction' => 'Vendu ou acheté ?', 'sale.counterparty' => 'Avec qui ?', 'sale.counterpartyHint' => 'Une personne ou un collectif (optionnel)', diff --git a/apps/app_seeds/lib/i18n/strings_ja.g.dart b/apps/app_seeds/lib/i18n/strings_ja.g.dart index df11454..548c863 100644 --- a/apps/app_seeds/lib/i18n/strings_ja.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ja.g.dart @@ -68,7 +68,6 @@ class _Translations$common$ja extends Translations$common$en { @override String get delete => '削除'; @override String get edit => '編集'; @override String get type => '種類'; - @override String get comingSoon => '近日公開'; @override String get offline => 'オフラインです — 共有を一時停止しています'; } @@ -140,7 +139,6 @@ extension on TranslationsJa { 'common.delete' => '削除', 'common.edit' => '編集', 'common.type' => '種類', - 'common.comingSoon' => '近日公開', 'common.offline' => 'オフラインです — 共有を一時停止しています', 'menu.tagline' => 'あなたの種子バンク', 'menu.inventory' => '在庫', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index d4aae83..3973e13 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -222,7 +222,6 @@ class Translations$common$pt extends Translations$common$en { @override String get delete => 'Eliminar'; @override String get edit => 'Editar'; @override String get type => 'Tipo'; - @override String get comingSoon => 'Em breve'; @override String get offline => 'Sem ligação — a partilha está em pausa'; } @@ -1579,7 +1578,6 @@ extension on TranslationsPt { 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', - 'common.comingSoon' => 'Em breve', 'common.offline' => 'Sem ligação — a partilha está em pausa', 'home.tagline' => 'Partilha e cultiva sementes locais', 'home.openMarket' => 'Mercado', @@ -2033,9 +2031,9 @@ extension on TranslationsPt { 'plantare.statusReturned' => 'Devolvido', 'plantare.statusForgiven' => 'Saldado', 'plantare.openSection' => 'Pendentes', + 'plantare.settledSection' => 'Feitos', _ => null, } ?? switch (path) { - 'plantare.settledSection' => 'Feitos', 'plantare.removeConfirm' => 'Remover este compromisso?', 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', 'plantare.overdue' => 'vencido', diff --git a/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart b/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart index 0d51e10..9e965a6 100644 --- a/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart @@ -223,7 +223,6 @@ class _Translations$common$pt_BR extends Translations$common$pt { @override String get delete => 'Eliminar'; @override String get edit => 'Editar'; @override String get type => 'Tipo'; - @override String get comingSoon => 'Em breve'; @override String get offline => 'Sem ligação — a compartilha está em pausa'; } @@ -1580,7 +1579,6 @@ extension on TranslationsPtBr { 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', - 'common.comingSoon' => 'Em breve', 'common.offline' => 'Sem ligação — a compartilha está em pausa', 'home.tagline' => 'Compartilha e cultiva sementes locais', 'home.openMarket' => 'Mercado', @@ -2034,9 +2032,9 @@ extension on TranslationsPtBr { 'plantare.statusReturned' => 'Devolvido', 'plantare.statusForgiven' => 'Saldado', 'plantare.openSection' => 'Pendentes', + 'plantare.settledSection' => 'Feitos', _ => null, } ?? switch (path) { - 'plantare.settledSection' => 'Feitos', 'plantare.removeConfirm' => 'Remover este compromisso?', 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', 'plantare.overdue' => 'vencido', diff --git a/apps/app_seeds/lib/services/sharing_switch.dart b/apps/app_seeds/lib/services/sharing_switch.dart new file mode 100644 index 0000000..e1fed0b --- /dev/null +++ b/apps/app_seeds/lib/services/sharing_switch.dart @@ -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 on; + + Future 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 disable() async { + await _settings.setSharingEnabled(false); + await _connection?.stop(); + on.value = false; + } + + void dispose() => on.dispose(); +} diff --git a/apps/app_seeds/lib/services/social_connection.dart b/apps/app_seeds/lib/services/social_connection.dart index 332621c..80bd091 100644 --- a/apps/app_seeds/lib/services/social_connection.dart +++ b/apps/app_seeds/lib/services/social_connection.dart @@ -63,8 +63,11 @@ class SocialConnection { SocialSession? get current => _current; /// Begins watching connectivity (reconnect on regain, drop when offline) and - /// attempts an initial connect. Idempotent-ish; call once at startup. + /// attempts an initial connect. Called at startup when sharing is already on, + /// and again the moment someone joins the sharing side — hence the guard, so + /// a second call never stacks a second connectivity subscription. void start() { + if (_started || _disposed) return; _started = true; _onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) { _knownOffline = !isOnline; @@ -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 stop() async { + _started = false; + _cancelRetry(); + await _onlineSub?.cancel(); + _onlineSub = null; + _drop(); + } + Future dispose() async { _disposed = true; _cancelRetry(); diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart index 8dbe023..75917cc 100644 --- a/apps/app_seeds/lib/services/social_settings.dart +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -5,9 +5,11 @@ import '../security/secret_store.dart'; /// keystore (via [SecretStore]) to honour "no plaintext at rest" — no /// shared_preferences. /// -/// Relays default to a small set of well-known public servers so the market -/// works out of the box; the exposure is minimal (offers are opt-in and carry -/// only a coarse geohash) and the user can swap them for a community server. +/// Sharing is off until the person joins it ([sharingEnabled]); until then the +/// app opens no connection at all. Once they do, relays default to a small set +/// of well-known public servers so the market works out of the box; the exposure +/// is minimal (offers are opt-in and carry only a coarse geohash) and the user +/// can swap them for a community server or turn them all off. /// The area stays unset until the user picks one (it's inherently personal). class SocialSettings { SocialSettings(this._store); @@ -16,6 +18,7 @@ class SocialSettings { static const _areaKey = 'tane.social.area_geohash'; static const _relaysKey = 'tane.social.relays'; + static const _sharingKey = 'tane.social.sharing_enabled'; static const _searchPrecisionKey = 'tane.social.search_precision'; static const _blockedKey = 'tane.social.blocked_pubkeys'; static const _hiddenOffersKey = 'tane.social.hidden_offers'; @@ -29,11 +32,11 @@ class SocialSettings { static const int maxSearchPrecision = 5; static const int defaultSearchPrecision = 4; - /// Community servers used automatically so sharing works from the first - /// launch. The relay pool skips any that are unreachable, so a dead one never - /// breaks the market; the user never has to know these exist. The Comunes - /// relay comes first as the reliable, non-commercial home; the public ones - /// are backup. + /// Community servers used automatically once the person joins the sharing + /// side, so the market works without any setup. The relay pool skips any that + /// are unreachable, so a dead one never breaks the market; the user never has + /// to know these exist. The Comunes relay comes first as the reliable, + /// non-commercial home; the public ones are backup. static const List defaultRelays = [ 'wss://relay.comunes.org', 'wss://nos.lol', @@ -62,6 +65,38 @@ class SocialSettings { urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'), ); + /// Whether the person has said yes to the sharing side of the app. Until they + /// do, Tane opens no connection at all — the seed book is entirely offline. + /// + /// Three states on purpose: `null` means "never asked", which is what lets an + /// install that predates this setting keep working exactly as before (see + /// `migrateSharingEnabled`). Once written it is a plain yes/no the person + /// controls from the sharing setup. + Future sharingEnabled() async { + final raw = await _store.read(_sharingKey); + if (raw == null) return null; + return raw == '1'; + } + + Future 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 migrateSharingEnabled({required bool introSeen}) async { + final stored = await sharingEnabled(); + if (stored != null) return stored; + await setSharingEnabled(introSeen); + return introSeen; + } + /// How wide to search — a geohash prefix length in [minSearchPrecision, /// maxSearchPrecision]. Defaults (and falls back on any garbage) to /// [defaultSearchPrecision]. diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 28d9264..df79f87 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -3,24 +3,66 @@ import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'seed_glyph.dart'; +import 'sharing_invite_sheet.dart'; import 'theme.dart'; import 'unread_badge.dart'; /// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is -/// the live destination (green seed glyph), the social items (market, profile, -/// chat…) belong to Block 2 and are greyed with a "soon" tag, and Settings sits -/// pinned at the bottom. +/// the live destination (green seed glyph), the social items sit below a divider, +/// and Settings is pinned at the bottom. +/// +/// While sharing is off the social items stay visible but quiet, with a small +/// padlock: tapping one explains what it does and offers to join. The Market is +/// the exception — it is always live, because it is the door people go through +/// to join in the first place. class AppDrawer extends StatelessWidget { - const AppDrawer({this.marketEnabled = false, super.key}); + const AppDrawer({this.sharing, this.onboarding, super.key}); - /// When the Block 2 social layer is wired, the Market becomes a live drawer - /// destination; other social items stay "soon" until they're built. - final bool marketEnabled; + /// The sharing on/off switch. Null when the social layer isn't there at all + /// (identity derivation failed) — then the social items are not drawn, since + /// there is nothing the person could do to turn them on. + final SharingSwitch? sharing; + + /// Needed to run the community-rules step when someone accepts the invite. + final OnboardingStore? onboarding; @override Widget build(BuildContext context) { + final sharing = this.sharing; + if (sharing == null) return _build(context, social: false, sharingOn: false); + return ValueListenableBuilder( + valueListenable: sharing.on, + builder: (context, on, _) => + _build(context, social: true, sharingOn: on), + ); + } + + Widget _build( + BuildContext context, { + required bool social, + required bool sharingOn, + }) { final t = context.t; + // While sharing is off, tapping a social entry invites the person in rather + // than doing nothing. + Future invite() async { + final sharing = this.sharing; + final onboarding = this.onboarding; + if (sharing == null || onboarding == null) return; + // Close the drawer first, then run the sheet off the navigator's own + // context — this one dies with the drawer. + final navigator = Navigator.of(context); + navigator.pop(); + await showSharingInvite( + navigator.context, + onboarding: onboarding, + sharing: sharing, + ); + } + return Drawer( child: SafeArea( child: Column( @@ -69,59 +111,68 @@ class AppDrawer extends StatelessWidget { context.push('/calendar'); }, ), - _DrawerItem( - icon: const Icon(Symbols.storefront), - label: t.menu.market, - divider: true, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/market'); - } - : null, - ), - _DrawerItem( - icon: const Icon(Icons.person), - label: t.menu.profile, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/profile'); - } - : null, - ), - _DrawerItem( - icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), - label: t.menu.chat, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/messages'); - } - : null, - ), - _DrawerItem( - icon: const Icon(Icons.favorite), - label: t.menu.wishlist, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/favorites'); - } - : null, - ), - // The ego-centric web of trust ("your people") — live once the - // social layer is on. - _DrawerItem( - icon: const Icon(Icons.group), - label: t.menu.following, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/your-people'); - } - : null, - ), + // The market is always live when the social layer exists: it + // is where people join sharing, so locking it would lock the + // only door. + if (social) + _DrawerItem( + icon: const Icon(Symbols.storefront), + label: t.menu.market, + divider: true, + onTap: () { + Navigator.of(context).pop(); + context.push('/market'); + }, + ), + if (social) + _DrawerItem( + icon: const Icon(Icons.person), + label: t.menu.profile, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/profile'); + } + : invite, + ), + if (social) + _DrawerItem( + icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), + label: t.menu.chat, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/messages'); + } + : invite, + ), + if (social) + _DrawerItem( + icon: const Icon(Icons.favorite), + label: t.menu.wishlist, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/favorites'); + } + : invite, + ), + // The ego-centric web of trust ("your people"). + if (social) + _DrawerItem( + icon: const Icon(Icons.group), + label: t.menu.following, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/your-people'); + } + : invite, + ), ], ), ), @@ -203,6 +254,7 @@ class _DrawerItem extends StatelessWidget { required this.label, this.onTap, this.divider = false, + this.locked = false, }); final Widget icon; @@ -210,9 +262,13 @@ class _DrawerItem extends StatelessWidget { final VoidCallback? onTap; final bool divider; + /// Drawn quiet, with a padlock, but still tappable — it leads to the + /// invitation to join sharing rather than to the destination itself. + final bool locked; + @override Widget build(BuildContext context) { - final enabled = onTap != null; + final enabled = onTap != null && !locked; final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F); final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F); final row = InkWell( @@ -247,15 +303,11 @@ class _DrawerItem extends StatelessWidget { ), ), ), - if (!enabled) - Text( - context.t.common.comingSoon.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB3BDA8), - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), + if (locked) + const Icon( + Icons.lock_outline, + size: 16, + color: Color(0xFFB3BDA8), ), ], ), diff --git a/apps/app_seeds/lib/ui/home_screen.dart b/apps/app_seeds/lib/ui/home_screen.dart index adf4aba..c13c7dc 100644 --- a/apps/app_seeds/lib/ui/home_screen.dart +++ b/apps/app_seeds/lib/ui/home_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'app_drawer.dart'; import 'seed_glyph.dart'; import 'theme.dart'; @@ -9,14 +11,19 @@ import 'unread_badge.dart'; /// The main menu (redesign screen 00): a sprout logo in a soft green disc over a /// faint seed-glyph watermark, with "Your inventory" as the primary green call -/// to action and "Open market" (Block 2) as a disabled outlined card. The -/// hamburger opens [AppDrawer]. +/// to action and "Open market" as an outlined card below it. The hamburger opens +/// [AppDrawer]. +/// +/// The market card is always live when the social layer exists — it is the door +/// people go through to join sharing — and simply isn't drawn when it doesn't. class HomeScreen extends StatelessWidget { - const HomeScreen({this.marketEnabled = false, super.key}); + const HomeScreen({this.sharing, this.onboarding, super.key}); - /// When the Block 2 social layer is wired, the market becomes a live - /// destination; otherwise it stays a disabled "coming soon" card. - final bool marketEnabled; + /// The sharing on/off switch, or null when there is no social layer at all. + final SharingSwitch? sharing; + + /// Needed to run the community-rules step from the drawer's invitation. + final OnboardingStore? onboarding; @override Widget build(BuildContext context) { @@ -34,7 +41,7 @@ class HomeScreen extends StatelessWidget { ), ), ), - drawer: AppDrawer(marketEnabled: marketEnabled), + drawer: AppDrawer(sharing: sharing, onboarding: onboarding), body: Stack( children: [ const Positioned.fill(child: _SeedWatermark()), @@ -99,17 +106,16 @@ class HomeScreen extends StatelessWidget { subtitle: t.home.yourInventorySubtitle, onTap: () => context.push('/inventory'), ), - const SizedBox(height: 16), - _OutlinedMenuCard( - key: const Key('home.market'), - icon: Icons.storefront_outlined, - label: t.home.openMarket, - subtitle: t.home.openMarketSubtitle, - tag: marketEnabled ? null : t.common.comingSoon, - onTap: marketEnabled - ? () => context.push('/market') - : null, - ), + if (sharing != null) ...[ + const SizedBox(height: 16), + _OutlinedMenuCard( + key: const Key('home.market'), + icon: Icons.storefront_outlined, + label: t.home.openMarket, + subtitle: t.home.openMarketSubtitle, + onTap: () => context.push('/market'), + ), + ], ], ), ), @@ -175,14 +181,12 @@ class _PrimaryMenuCard extends StatelessWidget { } } -/// A disabled Block-2 destination: an outlined white card with a green-disc -/// icon and a "soon" tag. +/// A secondary destination: an outlined white card with a green-disc icon. class _OutlinedMenuCard extends StatelessWidget { const _OutlinedMenuCard({ required this.icon, required this.label, required this.subtitle, - this.tag, this.onTap, super.key, }); @@ -191,9 +195,6 @@ class _OutlinedMenuCard extends StatelessWidget { final String label; final String subtitle; - /// A small trailing tag (e.g. "coming soon"); omitted for live cards. - final String? tag; - /// When set, the card is tappable; otherwise it reads as disabled. final VoidCallback? onTap; @@ -221,17 +222,7 @@ class _OutlinedMenuCard extends StatelessWidget { subtitleColor: seedMuted, ), ), - if (tag != null) - Text( - tag!.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB3BDA8), - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), - ) - else if (onTap != null) + if (onTap != null) const Icon(Icons.chevron_right, color: seedMuted), ], ), diff --git a/apps/app_seeds/lib/ui/market_gate.dart b/apps/app_seeds/lib/ui/market_gate.dart index b43edb1..c6eb383 100644 --- a/apps/app_seeds/lib/ui/market_gate.dart +++ b/apps/app_seeds/lib/ui/market_gate.dart @@ -3,17 +3,29 @@ import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'theme.dart'; /// Makes sure the community rules have been accepted once before the user /// joins the market or publishes anything. Returns true when the rules are /// (or become) accepted; false when the user declines. Play/App Store UGC /// policies require this acceptance before content can be created. +/// +/// This is also where Tane goes online for the first time: agreeing here is the +/// opt-in that turns [sharing] on. Keeping both in one step means there is a +/// single moment where someone says yes, and it is a moment that explains +/// itself — rather than a connection that happened at launch without asking. Future ensureMarketRulesAccepted( BuildContext context, - OnboardingStore store, -) async { - if (await store.marketRulesAccepted()) return true; + OnboardingStore store, { + SharingSwitch? sharing, +}) async { + if (await store.marketRulesAccepted()) { + // Already agreed, but sharing may still be off (they turned it off in the + // sharing setup, or agreed on a build that had no switch): honour the ask. + if (sharing != null && !sharing.on.value) await sharing.enable(); + return true; + } if (!context.mounted) return false; final accepted = await showModalBottomSheet( context: context, @@ -24,6 +36,7 @@ Future ensureMarketRulesAccepted( ); if (accepted == true) { await store.markMarketRulesAccepted(); + await sharing?.enable(); return true; } return false; @@ -77,6 +90,14 @@ class MarketGateSheet extends StatelessWidget { height: 1.4, ), ), + const SizedBox(height: 8), + Text( + t.marketGate.networkNote, + style: theme.textTheme.bodySmall?.copyWith( + color: seedMuted, + height: 1.4, + ), + ), TextButton.icon( style: TextButton.styleFrom( padding: EdgeInsets.zero, diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index b172ca2..d0a77a0 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -11,6 +11,7 @@ import '../services/offer_outbox.dart'; import '../services/saved_searches_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; +import '../services/sharing_switch.dart'; import '../services/social_settings.dart'; import '../services/onboarding_store.dart'; import '../state/offers_cubit.dart'; @@ -35,6 +36,7 @@ class MarketScreen extends StatefulWidget { this.onboarding, this.savedSearches, this.initialSearch, + this.sharing, super.key, }); @@ -54,6 +56,10 @@ class MarketScreen extends StatefulWidget { /// created). Null in tests → no gate. final OnboardingStore? onboarding; + /// The sharing on/off switch. Accepting the rules turns it on (that is the + /// moment Tane first goes online); the sharing setup can turn it back off. + final SharingSwitch? sharing; + /// The shared relay connection (one per identity), reused for discovery. final SocialConnection connection; @@ -84,11 +90,18 @@ class _MarketScreenState extends State { /// network; declining leaves the market. Future _start() async { final store = widget.onboarding; - if (store != null && !await store.marketRulesAccepted()) { + final sharing = widget.sharing; + // The rules step is also the moment Tane first goes online, so it runs + // whenever sharing is still off — even for someone who agreed long ago and + // later switched sharing back off. + if (store != null && + (!await store.marketRulesAccepted() || + (sharing != null && !sharing.on.value))) { // Wait for the first frame so the sheet has a surface to attach to. await WidgetsBinding.instance.endOfFrame; if (!mounted) return; - final ok = await ensureMarketRulesAccepted(context, store); + final ok = await ensureMarketRulesAccepted(context, store, + sharing: sharing); if (!ok) { if (mounted) context.pop(); return; @@ -156,8 +169,11 @@ class _MarketScreenState extends State { final changed = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => - _ConfigSheet(settings: widget.settings, location: widget.location), + builder: (_) => _ConfigSheet( + settings: widget.settings, + location: widget.location, + sharing: widget.sharing, + ), ); if (changed == true) await _init(); } @@ -786,10 +802,11 @@ class _EmptyState extends StatelessWidget { /// Coarse-area + community-server setup. Kept behind progressive disclosure — a /// power-user surface — with human-worded labels. class _ConfigSheet extends StatefulWidget { - const _ConfigSheet({required this.settings, this.location}); + const _ConfigSheet({required this.settings, this.location, this.sharing}); final SocialSettings settings; final CoarseLocationProvider? location; + final SharingSwitch? sharing; @override State<_ConfigSheet> createState() => _ConfigSheetState(); @@ -1067,6 +1084,30 @@ class _ConfigSheetState extends State<_ConfigSheet> { ), ), children: [ + // The master switch: turning it off takes Tane fully + // offline right away, not at the next launch. + if (widget.sharing != null) + ValueListenableBuilder( + valueListenable: widget.sharing!.on, + builder: (context, on, _) => SwitchListTile( + key: const Key('market.sharingOn'), + contentPadding: EdgeInsets.zero, + dense: true, + value: on, + title: Text(t.market.sharingOnLabel), + subtitle: Text( + t.market.sharingOnHelp, + style: const TextStyle( + fontSize: 12, + color: seedMuted, + height: 1.3, + ), + ), + onChanged: (want) => want + ? widget.sharing!.enable() + : widget.sharing!.disable(), + ), + ), TextField( key: const Key('market.area'), controller: _area, diff --git a/apps/app_seeds/lib/ui/sharing_invite_sheet.dart b/apps/app_seeds/lib/ui/sharing_invite_sheet.dart new file mode 100644 index 0000000..5cbe32b --- /dev/null +++ b/apps/app_seeds/lib/ui/sharing_invite_sheet.dart @@ -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 showSharingInvite( + BuildContext context, { + required OnboardingStore onboarding, + required SharingSwitch sharing, +}) async { + final wants = await showModalBottomSheet( + 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/test/screenshots/screenshots_test.dart b/apps/app_seeds/test/screenshots/screenshots_test.dart index 7589506..edfe643 100644 --- a/apps/app_seeds/test/screenshots/screenshots_test.dart +++ b/apps/app_seeds/test/screenshots/screenshots_test.dart @@ -105,7 +105,7 @@ void main() { seeded = await seedShowcase(db, repo, locale); }); final child = switch (name) { - 'home' => const HomeScreen(marketEnabled: true), + 'home' => HomeScreen(sharing: newTestSharingSwitch()), 'inventory' => const InventoryListScreen(), 'market' => marketWidget(locale), 'calendar' => const CalendarScreen(initialMonth: 4), diff --git a/apps/app_seeds/test/services/social_connection_test.dart b/apps/app_seeds/test/services/social_connection_test.dart index 7b856de..4618691 100644 --- a/apps/app_seeds/test/services/social_connection_test.dart +++ b/apps/app_seeds/test/services/social_connection_test.dart @@ -181,4 +181,70 @@ void main() { expect(await conn.session(), isNotNull); // succeeds on retry await conn.dispose(); }); + + test('start is idempotent: a second call adds no second connect', () async { + // Joining sharing calls start() while bootstrap may already have, so a + // repeat must not stack another connectivity subscription or dial again. + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + + conn.start(); + await conn.session(); + conn.start(); + await Future.delayed(Duration.zero); + expect(opened, hasLength(1)); + + // One subscription, so one drop — not two competing reactions. + online.add(false); + await Future.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 = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + final emitted = []; + conn.sessions.listen(emitted.add); + + conn.start(); + expect(await conn.session(), isNotNull); + + await conn.stop(); + await Future.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.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 = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + conn.start(); + await conn.session(); + await conn.stop(); + + conn.start(); + await Future.delayed(Duration.zero); + expect(conn.current, isNotNull); + expect(opened, hasLength(2)); + await conn.dispose(); + await online.close(); + }); } diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart index c3abe43..02577dd 100644 --- a/apps/app_seeds/test/services/social_settings_test.dart +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -101,4 +101,34 @@ void main() { {SocialSettings.offerKey('ab' * 32, 'tomate-1')}, ); }); + + group('sharing opt-in', () { + test('starts unanswered, then round-trips', () async { + expect(await settings.sharingEnabled(), isNull); + await settings.setSharingEnabled(true); + expect(await settings.sharingEnabled(), isTrue); + await settings.setSharingEnabled(false); + expect(await settings.sharingEnabled(), isFalse); + }); + + test('an install that had been through the intro keeps sharing on', + () async { + // The upgrade path that must not regress: these people were on a build + // that connected at launch, so they keep messaging, sync and alerts. + expect(await settings.migrateSharingEnabled(introSeen: true), isTrue); + expect(await settings.sharingEnabled(), isTrue); + }); + + test('a fresh install starts offline', () async { + expect(await settings.migrateSharingEnabled(introSeen: false), isFalse); + expect(await settings.sharingEnabled(), isFalse); + }); + + test('migration never overwrites an answer already given', () async { + await settings.setSharingEnabled(false); + // Later launches see the intro as seen; the recorded "no" must survive. + expect(await settings.migrateSharingEnabled(introSeen: true), isFalse); + expect(await settings.sharingEnabled(), isFalse); + }); + }); } diff --git a/apps/app_seeds/test/support/test_support.dart b/apps/app_seeds/test/support/test_support.dart index ba39a2f..eefb065 100644 --- a/apps/app_seeds/test/support/test_support.dart +++ b/apps/app_seeds/test/support/test_support.dart @@ -11,6 +11,8 @@ import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/security/secret_store.dart'; import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/services/sharing_switch.dart'; +import 'package:tane/services/social_settings.dart'; import 'package:tane/app.dart' show materialLocaleFor; import 'package:tane/state/inventory_cubit.dart'; import 'package:tane/state/variety_detail_cubit.dart'; @@ -59,6 +61,14 @@ OnboardingStore newTestOnboardingStore({bool introSeen = true}) { return OnboardingStore(store); } +/// A [SharingSwitch] over in-memory storage and no relay connection, for +/// screens that only care whether sharing is on. Defaults to on, which is what +/// most screen tests want (the social entries live, nothing dialled). +SharingSwitch newTestSharingSwitch({bool enabled = true}) => SharingSwitch( + settings: SocialSettings(InMemorySecretStore()), + enabled: enabled, + ); + /// Wraps [child] with the providers a screen expects (repository, inventory /// cubit) plus i18n and Material localizations, pinned to [locale]. Widget wrapScreen({ diff --git a/apps/app_seeds/test/ui/home_screen_test.dart b/apps/app_seeds/test/ui/home_screen_test.dart index 1ca72ec..76b84be 100644 --- a/apps/app_seeds/test/ui/home_screen_test.dart +++ b/apps/app_seeds/test/ui/home_screen_test.dart @@ -2,15 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/app.dart'; import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/sharing_switch.dart'; import '../support/test_support.dart'; void main() { - Widget app(db) => TranslationProvider( + /// The everyday case: the social layer exists and sharing is already on. + Widget app(db, {SharingSwitch? sharing}) => TranslationProvider( child: TaneApp( repository: newTestRepository(db), species: newTestSpeciesRepository(db), onboarding: newTestOnboardingStore(), + sharing: sharing ?? newTestSharingSwitch(), ), ); @@ -24,7 +27,8 @@ void main() { expect(find.text('Your inventory'), findsOneWidget); expect(find.text('Market'), findsOneWidget); - expect(find.text('COMING SOON'), findsOneWidget); // market is Block 2 + // "Coming soon" is gone for good: everything on this screen is built. + expect(find.textContaining('COMING SOON'), findsNothing); // Redesign copy: tagline + the two destination subtitles. expect(find.text('Share and grow local seeds'), findsOneWidget); @@ -49,8 +53,9 @@ void main() { await tester.tap(find.byIcon(Icons.menu)); // hamburger await tester.pumpAndSettle(); - // Social destinations are shown but disabled. + // With sharing on, the social destinations are live. expect(find.text('Your profile'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsNothing); await tester.tap(find.text('Inventory')); // active drawer item await tester.pumpAndSettle(); @@ -84,6 +89,62 @@ void main() { await disposeTree(tester); }); + testWidgets('sharing off: market stays open, the rest wears a padlock', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget( + app(db, sharing: newTestSharingSwitch(enabled: false)), + ); + await tester.pumpAndSettle(); + + // The market card is the door into sharing, so it must stay reachable. + expect(find.byKey(const Key('home.market')), findsOneWidget); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + + // Market live; the social entries are visible but padlocked — and tapping + // one invites the person in rather than doing nothing. + expect(find.text('Your profile'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsWidgets); + + await tester.tap(find.text('Your profile')); + await tester.pumpAndSettle(); + expect(find.text('This wakes up when you start sharing'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('no social layer: the market and its friends are not drawn', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget( + TranslationProvider( + child: TaneApp( + repository: newTestRepository(db), + species: newTestSpeciesRepository(db), + onboarding: newTestOnboardingStore(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Nothing here can be switched on, so it is hidden rather than teased. + expect(find.byKey(const Key('home.market')), findsNothing); + expect(find.text('Your inventory'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + expect(find.text('Your profile'), findsNothing); + expect(find.byIcon(Icons.lock_outline), findsNothing); + await disposeTree(tester); + }); + testWidgets('drawer Settings opens the settings screen', (tester) async { diff --git a/apps/app_seeds/test/ui/market_gate_test.dart b/apps/app_seeds/test/ui/market_gate_test.dart index 7cce096..6a74331 100644 --- a/apps/app_seeds/test/ui/market_gate_test.dart +++ b/apps/app_seeds/test/ui/market_gate_test.dart @@ -3,12 +3,17 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/services/sharing_switch.dart'; import 'package:tane/ui/market_gate.dart'; import '../support/test_support.dart'; void main() { - Widget host(OnboardingStore store, void Function(bool) onResult) => + Widget host( + OnboardingStore store, + void Function(bool) onResult, { + SharingSwitch? sharing, + }) => TranslationProvider( child: MaterialApp( localizationsDelegates: GlobalMaterialLocalizations.delegates, @@ -16,7 +21,13 @@ void main() { builder: (context) => Center( child: ElevatedButton( onPressed: () async { - onResult(await ensureMarketRulesAccepted(context, store)); + onResult( + await ensureMarketRulesAccepted( + context, + store, + sharing: sharing, + ), + ); }, child: const Text('enter'), ), @@ -87,5 +98,52 @@ void main() { expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget); expect(find.textContaining('public'), findsWidgets); expect(find.text('Privacy & rules'), findsOneWidget); + // The sheet is also where Tane says it is about to go online for the first + // time — the reviewer's complaint was that this was never stated. + expect(find.textContaining('community servers'), findsOneWidget); + }); + + testWidgets('agreeing is what turns sharing on', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + expect(sharing.on.value, isFalse, reason: 'still offline while asking'); + + await tester.tap(find.text('I agree')); + await tester.pumpAndSettle(); + expect(sharing.on.value, isTrue); + }); + + testWidgets('declining leaves the app offline', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + + expect(sharing.on.value, isFalse); + }); + + testWidgets('someone who agreed long ago but switched sharing off is asked ' + 'nothing, yet comes back online', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + await store.markMarketRulesAccepted(); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + + expect(find.text('Before you join the market'), findsNothing); + expect(sharing.on.value, isTrue); }); } diff --git a/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart b/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart new file mode 100644 index 0000000..d8b61ee --- /dev/null +++ b/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart @@ -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); + }); +} diff --git a/apps/app_seeds/test/ui/small_screen_overflow_test.dart b/apps/app_seeds/test/ui/small_screen_overflow_test.dart index d5c18c8..1255da9 100644 --- a/apps/app_seeds/test/ui/small_screen_overflow_test.dart +++ b/apps/app_seeds/test/ui/small_screen_overflow_test.dart @@ -93,7 +93,7 @@ void main() { wrapScreen( repository: newTestRepository(db), locale: locale, - child: const HomeScreen(marketEnabled: true), + child: HomeScreen(sharing: newTestSharingSwitch()), ), ); await disposeTree(tester); From 954dc2caae10c373c4bdfcbd5dfb3d6c3906b598 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 25 Jul 2026 16:50:40 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20prep=20v0.1.16=20?= =?UTF-8?q?=E2=80=94=20say=20plainly=20which=20servers=20Tane=20talks=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../metadata/android/en-US/changelogs/18.txt | 1 + .../android/en-US/full_description.txt | 25 ++++++++++++++++++- .../metadata/android/es-ES/changelogs/18.txt | 1 + .../android/es-ES/full_description.txt | 24 +++++++++++++++++- apps/app_seeds/pubspec.yaml | 2 +- docs/fdroid/org.comunes.tane.yml | 22 ++++++++-------- site/content/_index.en.md | 2 +- site/content/_index.es.md | 2 +- site/i18n/en.json | 4 +-- site/i18n/es.json | 4 +-- 10 files changed, 68 insertions(+), 19 deletions(-) create mode 100644 apps/app_seeds/fastlane/metadata/android/en-US/changelogs/18.txt create mode 100644 apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/18.txt diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/18.txt b/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/18.txt new file mode 100644 index 0000000..d4934e0 --- /dev/null +++ b/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/18.txt @@ -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. diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt b/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt index c6adfc2..71e51df 100644 --- a/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt +++ b/apps/app_seeds/fastlane/metadata/android/en-US/full_description.txt @@ -13,8 +13,11 @@ MANAGE YOUR BANK • Search and filter; snap a photo now and name it later. YOURS, AND ONLY YOURS -• Works fully offline. No account, no server, no trackers. +• Your seed book works with no internet at all. What you write stays on your + phone; nothing goes out unless you put some of your seeds up for sharing, or + write to someone. • Everything is encrypted at rest on your device. +• No account, no sign-up, no ads, no trackers. • Save an encrypted backup and keep a printed recovery sheet — restore your whole bank on a new device. @@ -22,6 +25,26 @@ SHARE, THE WAY IT'S ALWAYS BEEN DONE • Mark what you have spare to give away, swap or sell. • Print a catalog of what you share to take to a seed fair. +SHARING NEEDS THE INTERNET +Keeping your seed book needs nothing but your phone. Sharing does need a +connection: to get an offer or a message across to someone, Tane leaves it on +community servers — machines run by people, not by a company. Tane comes with +four: relay.comunes.org, which Asociación Comunes runs (the same people who make +Tane), plus three public ones that others already use — nos.lol, relay.damus.io +and relay.primal.net. + +Tane only talks to them once you join the sharing part and agree to the +community rules. Before that it doesn't connect to anything, and messaging stays +switched off. Once you join, those servers see the key that stands for you and +the address your phone connects from — no name, no email, no account. Your seed +book itself is never sent anywhere. + +You stay in charge of that list of servers. In the sharing setup you can switch +off any server on it, switch off every one of them — Tane then goes back to +being a seed book that never connects — or add the address of a different +server. Anyone can run a server of their own, and Tane works just as well with +it. + Tane is free software (AGPL-3.0). No ads, no commissions, no business model — it exists to support traditional varieties and push back against the seed monopoly. diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/18.txt b/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/18.txt new file mode 100644 index 0000000..df31c73 --- /dev/null +++ b/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/18.txt @@ -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. diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt b/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt index d25105b..d014862 100644 --- a/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt +++ b/apps/app_seeds/fastlane/metadata/android/es-ES/full_description.txt @@ -13,8 +13,11 @@ GESTIONA TU BANCO • Busca y filtra; haz una foto ahora y ponle nombre después. TUYO, Y SOLO TUYO -• Funciona totalmente sin conexión. Sin cuenta, sin servidor, sin rastreadores. +• Tu cuaderno de semillas funciona sin nada de internet. Lo que escribes se + queda en el móvil; no sale nada salvo que pongas a compartir parte de tus + semillas o le escribas a alguien. • Todo va cifrado en tu dispositivo. +• Sin cuenta, sin registro, sin anuncios, sin rastreadores. • Guarda una copia cifrada y ten una hoja de recuperación impresa: recupera todo tu banco en un dispositivo nuevo. @@ -22,6 +25,25 @@ COMPARTIR, COMO SE HA HECHO SIEMPRE • Marca lo que te sobra para regalar, intercambiar o vender. • Imprime un catálogo de lo que compartes para llevar a una feria de semillas. +COMPARTIR SÍ NECESITA INTERNET +Para llevar tu cuaderno de semillas no hace falta más que el móvil. Compartir sí +necesita conexión: para que una oferta o un mensaje le lleguen a otra persona, +Tane los deja en servidores comunitarios, máquinas que lleva gente, no una +empresa. Tane viene con cuatro: relay.comunes.org, que lleva la Asociación +Comunes (la misma gente que hace Tane), y tres públicos que ya usa otra gente: +nos.lol, relay.damus.io y relay.primal.net. + +Tane solo habla con ellos cuando entras en la parte de compartir y aceptas las +normas de la comunidad. Antes de eso no se conecta a nada, y la mensajería está +apagada. Cuando entras, esos servidores ven la clave que te representa y la +dirección desde la que se conecta tu móvil: ni nombre, ni correo, ni cuenta. Tu +cuaderno de semillas no se envía nunca a ningún sitio. + +Tú mandas sobre esa lista de servidores. En la configuración de compartir puedes +desactivar el servidor que quieras, desactivarlos todos —y Tane vuelve a ser un +cuaderno que no se conecta nunca— o añadir la dirección de otro servidor. +Cualquiera puede montar su propio servidor, y Tane funciona igual de bien con él. + Tane es software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo de negocio: existe para apoyar las variedades tradicionales y plantar cara al monopolio de las semillas. diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index dafb81d..402f502 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -1,7 +1,7 @@ name: tane description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market." publish_to: 'none' -version: 0.1.15+17 +version: 0.1.16+18 environment: sdk: ^3.11.5 diff --git a/docs/fdroid/org.comunes.tane.yml b/docs/fdroid/org.comunes.tane.yml index 2046fca..c59e036 100644 --- a/docs/fdroid/org.comunes.tane.yml +++ b/docs/fdroid/org.comunes.tane.yml @@ -16,8 +16,8 @@ RepoType: git Repo: https://git.comunes.org/comunes/tane.git Builds: - - versionName: 0.1.15 - versionCode: 171 + - versionName: 0.1.16 + versionCode: 181 commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d subdir: apps/app_seeds output: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk @@ -53,8 +53,8 @@ Builds: $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 - - versionName: 0.1.15 - versionCode: 172 + - versionName: 0.1.16 + versionCode: 182 commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d subdir: apps/app_seeds output: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk @@ -90,8 +90,8 @@ Builds: $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 - - versionName: 0.1.15 - versionCode: 173 + - versionName: 0.1.16 + versionCode: 183 commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d subdir: apps/app_seeds output: build/app/outputs/flutter-apk/app-x86_64-release.apk @@ -131,8 +131,10 @@ Builds: # 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; -# f04dd3da1a3993c5a8f3222544f75b979d2c7f9d is the commit tag v0.1.15 points to. +# 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 @@ -143,5 +145,5 @@ VercodeOperation: - '%c * 10 + 3' UpdateCheckData: apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+ -CurrentVersion: 0.1.15 -CurrentVersionCode: 173 +CurrentVersion: 0.1.16 +CurrentVersionCode: 183 diff --git a/site/content/_index.en.md b/site/content/_index.en.md index 1212621..7babb61 100644 --- a/site/content/_index.en.md +++ b/site/content/_index.en.md @@ -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." - icon: "🔒" 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: "🤝" 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." diff --git a/site/content/_index.es.md b/site/content/_index.es.md index 9cf3285..9d767d6 100644 --- a/site/content/_index.es.md +++ b/site/content/_index.es.md @@ -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." - icon: "🔒" 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: "🤝" 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." diff --git a/site/i18n/en.json b/site/i18n/en.json index bca3d1c..847e4f0 100644 --- a/site/i18n/en.json +++ b/site/i18n/en.json @@ -45,7 +45,7 @@ "other": "Where Tane is right now" }, "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": { "other": "Home — your bank and the market, one tap away" @@ -114,7 +114,7 @@ "other": "Do I need an account?" }, "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": { "other": "Does it work offline?" diff --git a/site/i18n/es.json b/site/i18n/es.json index 4a3ebab..3b6a212 100644 --- a/site/i18n/es.json +++ b/site/i18n/es.json @@ -45,7 +45,7 @@ "other": "En qué punto está Tane" }, "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": { "other": "Inicio — tu banco y el mercado a un toque" @@ -114,7 +114,7 @@ "other": "¿Necesito cuenta?" }, "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": { "other": "¿Funciona sin conexión?"