Compare commits
4 commits
f04dd3da1a
...
954dc2caae
| Author | SHA1 | Date | |
|---|---|---|---|
| 954dc2caae | |||
| fed0e8200e | |||
| 62123582f5 | |||
| 7ae35920f8 |
46 changed files with 1100 additions and 220 deletions
|
|
@ -47,7 +47,75 @@ on:
|
||||||
default: ''
|
default: ''
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Test gate. Forgejo/Actions workflows are independent — there is no cross-workflow
|
||||||
|
# `needs:`, and ci.yml does NOT trigger on tag pushes (its `branches` filter never
|
||||||
|
# matches refs/tags/*). So the CI test jobs are duplicated here (verbatim from
|
||||||
|
# ci.yml) to gate the deploy: nothing ships unless analyze + both test suites pass.
|
||||||
|
# Keep these in sync with ci.yml by hand.
|
||||||
|
analyze:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:3.41.9
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter analyze
|
||||||
|
|
||||||
|
test-commons-core:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:3.41.9
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: dart test
|
||||||
|
working-directory: packages/commons_core
|
||||||
|
run: dart test
|
||||||
|
|
||||||
|
test-app-seeds:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:3.41.9
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "http://x-access-token:${TOKEN}@forgejo:3000/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
# SQLCipher so the "no plaintext at rest" security test actually runs.
|
||||||
|
- run: apt-get update -qq && apt-get install -y -qq libsqlcipher-dev
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Generate code + test
|
||||||
|
working-directory: apps/app_seeds
|
||||||
|
run: |
|
||||||
|
dart run slang
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
flutter test --coverage
|
||||||
|
|
||||||
play:
|
play:
|
||||||
|
# Gate on the tests: with no status-check function in this `if`, the implicit
|
||||||
|
# success() on `needs` still applies — play runs only on a tag AND green tests.
|
||||||
|
needs: [analyze, test-commons-core, test-app-seeds]
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
|
|
@ -121,11 +189,16 @@ jobs:
|
||||||
fdroid_reference_armeabi_v7a:
|
fdroid_reference_armeabi_v7a:
|
||||||
# Serialize after play so two heavy Android builds never run concurrently on
|
# Serialize after play so two heavy Android builds never run concurrently on
|
||||||
# the shared host (capacity:2) — that contention OOM-killed play's Gradle
|
# the shared host (capacity:2) — that contention OOM-killed play's Gradle
|
||||||
# daemon. `if: always()` keeps the fdroid chain independent of play's result
|
# daemon. Gated on the test jobs, but kept INDEPENDENT of play's result:
|
||||||
# (runs after play whether it succeeded, failed, or — on workflow_dispatch —
|
# `!cancelled()` is a status function, which drops the implicit success() on
|
||||||
# was skipped).
|
# `needs`, so play failing/skipping (e.g. on workflow_dispatch, where play is
|
||||||
needs: play
|
# tag-only) does not block the fdroid chain — while the explicit result checks
|
||||||
if: ${{ always() }}
|
# still hard-gate on green tests. Nothing gets built/uploaded on a red suite.
|
||||||
|
needs: [analyze, test-commons-core, test-app-seeds, play]
|
||||||
|
if: ${{ !cancelled()
|
||||||
|
&& needs.analyze.result == 'success'
|
||||||
|
&& needs.test-commons-core.result == 'success'
|
||||||
|
&& needs.test-app-seeds.result == 'success' }}
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie
|
image: registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -13,8 +13,11 @@ MANAGE YOUR BANK
|
||||||
• Search and filter; snap a photo now and name it later.
|
• Search and filter; snap a photo now and name it later.
|
||||||
|
|
||||||
YOURS, AND ONLY YOURS
|
YOURS, AND ONLY YOURS
|
||||||
• Works fully offline. No account, no server, no trackers.
|
• Your seed book works with no internet at all. What you write stays on your
|
||||||
|
phone; nothing goes out unless you put some of your seeds up for sharing, or
|
||||||
|
write to someone.
|
||||||
• Everything is encrypted at rest on your device.
|
• Everything is encrypted at rest on your device.
|
||||||
|
• No account, no sign-up, no ads, no trackers.
|
||||||
• Save an encrypted backup and keep a printed recovery sheet — restore your
|
• Save an encrypted backup and keep a printed recovery sheet — restore your
|
||||||
whole bank on a new device.
|
whole bank on a new device.
|
||||||
|
|
||||||
|
|
@ -22,6 +25,26 @@ SHARE, THE WAY IT'S ALWAYS BEEN DONE
|
||||||
• Mark what you have spare to give away, swap or sell.
|
• Mark what you have spare to give away, swap or sell.
|
||||||
• Print a catalog of what you share to take to a seed fair.
|
• Print a catalog of what you share to take to a seed fair.
|
||||||
|
|
||||||
|
SHARING NEEDS THE INTERNET
|
||||||
|
Keeping your seed book needs nothing but your phone. Sharing does need a
|
||||||
|
connection: to get an offer or a message across to someone, Tane leaves it on
|
||||||
|
community servers — machines run by people, not by a company. Tane comes with
|
||||||
|
four: relay.comunes.org, which Asociación Comunes runs (the same people who make
|
||||||
|
Tane), plus three public ones that others already use — nos.lol, relay.damus.io
|
||||||
|
and relay.primal.net.
|
||||||
|
|
||||||
|
Tane only talks to them once you join the sharing part and agree to the
|
||||||
|
community rules. Before that it doesn't connect to anything, and messaging stays
|
||||||
|
switched off. Once you join, those servers see the key that stands for you and
|
||||||
|
the address your phone connects from — no name, no email, no account. Your seed
|
||||||
|
book itself is never sent anywhere.
|
||||||
|
|
||||||
|
You stay in charge of that list of servers. In the sharing setup you can switch
|
||||||
|
off any server on it, switch off every one of them — Tane then goes back to
|
||||||
|
being a seed book that never connects — or add the address of a different
|
||||||
|
server. Anyone can run a server of their own, and Tane works just as well with
|
||||||
|
it.
|
||||||
|
|
||||||
Tane is free software (AGPL-3.0). No ads, no commissions, no business model
|
Tane is free software (AGPL-3.0). No ads, no commissions, no business model
|
||||||
— it exists to support traditional varieties and push back against the seed
|
— it exists to support traditional varieties and push back against the seed
|
||||||
monopoly.
|
monopoly.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -13,8 +13,11 @@ GESTIONA TU BANCO
|
||||||
• Busca y filtra; haz una foto ahora y ponle nombre después.
|
• Busca y filtra; haz una foto ahora y ponle nombre después.
|
||||||
|
|
||||||
TUYO, Y SOLO TUYO
|
TUYO, Y SOLO TUYO
|
||||||
• Funciona totalmente sin conexión. Sin cuenta, sin servidor, sin rastreadores.
|
• Tu cuaderno de semillas funciona sin nada de internet. Lo que escribes se
|
||||||
|
queda en el móvil; no sale nada salvo que pongas a compartir parte de tus
|
||||||
|
semillas o le escribas a alguien.
|
||||||
• Todo va cifrado en tu dispositivo.
|
• Todo va cifrado en tu dispositivo.
|
||||||
|
• Sin cuenta, sin registro, sin anuncios, sin rastreadores.
|
||||||
• Guarda una copia cifrada y ten una hoja de recuperación impresa: recupera
|
• Guarda una copia cifrada y ten una hoja de recuperación impresa: recupera
|
||||||
todo tu banco en un dispositivo nuevo.
|
todo tu banco en un dispositivo nuevo.
|
||||||
|
|
||||||
|
|
@ -22,6 +25,25 @@ COMPARTIR, COMO SE HA HECHO SIEMPRE
|
||||||
• Marca lo que te sobra para regalar, intercambiar o vender.
|
• Marca lo que te sobra para regalar, intercambiar o vender.
|
||||||
• Imprime un catálogo de lo que compartes para llevar a una feria de semillas.
|
• Imprime un catálogo de lo que compartes para llevar a una feria de semillas.
|
||||||
|
|
||||||
|
COMPARTIR SÍ NECESITA INTERNET
|
||||||
|
Para llevar tu cuaderno de semillas no hace falta más que el móvil. Compartir sí
|
||||||
|
necesita conexión: para que una oferta o un mensaje le lleguen a otra persona,
|
||||||
|
Tane los deja en servidores comunitarios, máquinas que lleva gente, no una
|
||||||
|
empresa. Tane viene con cuatro: relay.comunes.org, que lleva la Asociación
|
||||||
|
Comunes (la misma gente que hace Tane), y tres públicos que ya usa otra gente:
|
||||||
|
nos.lol, relay.damus.io y relay.primal.net.
|
||||||
|
|
||||||
|
Tane solo habla con ellos cuando entras en la parte de compartir y aceptas las
|
||||||
|
normas de la comunidad. Antes de eso no se conecta a nada, y la mensajería está
|
||||||
|
apagada. Cuando entras, esos servidores ven la clave que te representa y la
|
||||||
|
dirección desde la que se conecta tu móvil: ni nombre, ni correo, ni cuenta. Tu
|
||||||
|
cuaderno de semillas no se envía nunca a ningún sitio.
|
||||||
|
|
||||||
|
Tú mandas sobre esa lista de servidores. En la configuración de compartir puedes
|
||||||
|
desactivar el servidor que quieras, desactivarlos todos —y Tane vuelve a ser un
|
||||||
|
cuaderno que no se conecta nunca— o añadir la dirección de otro servidor.
|
||||||
|
Cualquiera puede montar su propio servidor, y Tane funciona igual de bien con él.
|
||||||
|
|
||||||
Tane es software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo
|
Tane es software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo
|
||||||
de negocio: existe para apoyar las variedades tradicionales y plantar cara al
|
de negocio: existe para apoyar las variedades tradicionales y plantar cara al
|
||||||
monopolio de las semillas.
|
monopolio de las semillas.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import 'services/social_account_store.dart';
|
||||||
import 'services/social_connection.dart';
|
import 'services/social_connection.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
import 'services/social_settings.dart';
|
import 'services/social_settings.dart';
|
||||||
|
import 'services/sharing_switch.dart';
|
||||||
import 'state/inventory_cubit.dart';
|
import 'state/inventory_cubit.dart';
|
||||||
import 'state/variety_detail_cubit.dart';
|
import 'state/variety_detail_cubit.dart';
|
||||||
import 'ui/about_screen.dart';
|
import 'ui/about_screen.dart';
|
||||||
|
|
@ -70,6 +71,7 @@ class TaneApp extends StatelessWidget {
|
||||||
this.notifications,
|
this.notifications,
|
||||||
this.showIntro = false,
|
this.showIntro = false,
|
||||||
this.autoBackup,
|
this.autoBackup,
|
||||||
|
SharingSwitch? sharing,
|
||||||
super.key,
|
super.key,
|
||||||
}) : _router = _buildRouter(
|
}) : _router = _buildRouter(
|
||||||
repository,
|
repository,
|
||||||
|
|
@ -87,6 +89,17 @@ class TaneApp extends StatelessWidget {
|
||||||
savedSearches,
|
savedSearches,
|
||||||
socialAccounts,
|
socialAccounts,
|
||||||
inbox,
|
inbox,
|
||||||
|
// `bootstrap` passes the real switch, holding the person's stored
|
||||||
|
// answer. A widget test that doesn't care gets one already on, so
|
||||||
|
// screens behave as they did before sharing became opt-in.
|
||||||
|
sharing ??
|
||||||
|
(socialSettings == null
|
||||||
|
? null
|
||||||
|
: SharingSwitch(
|
||||||
|
settings: socialSettings,
|
||||||
|
connection: connection,
|
||||||
|
enabled: true,
|
||||||
|
)),
|
||||||
) {
|
) {
|
||||||
// A tapped message notification opens that peer's chat. Wired here because
|
// A tapped message notification opens that peer's chat. Wired here because
|
||||||
// the router only exists now; taps only happen while the app is foreground,
|
// the router only exists now; taps only happen while the app is foreground,
|
||||||
|
|
@ -161,14 +174,17 @@ class TaneApp extends StatelessWidget {
|
||||||
SavedSearchesStore? savedSearches,
|
SavedSearchesStore? savedSearches,
|
||||||
SocialAccountStore? socialAccounts,
|
SocialAccountStore? socialAccounts,
|
||||||
InboxService? inbox,
|
InboxService? inbox,
|
||||||
|
SharingSwitch? sharing,
|
||||||
) {
|
) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: showIntro ? '/intro' : '/',
|
initialLocation: showIntro ? '/intro' : '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
|
// A null switch means there is no social layer at all: the market card
|
||||||
|
// and the social drawer entries aren't drawn.
|
||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
HomeScreen(marketEnabled: social != null),
|
HomeScreen(sharing: sharing, onboarding: onboarding),
|
||||||
),
|
),
|
||||||
if (social != null && socialSettings != null && connection != null)
|
if (social != null && socialSettings != null && connection != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
@ -181,6 +197,7 @@ class TaneApp extends StatelessWidget {
|
||||||
outbox: outbox,
|
outbox: outbox,
|
||||||
onboarding: onboarding,
|
onboarding: onboarding,
|
||||||
savedSearches: savedSearches,
|
savedSearches: savedSearches,
|
||||||
|
sharing: sharing,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (social != null && connection != null)
|
if (social != null && connection != null)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import 'services/profile_store.dart';
|
||||||
import 'services/saved_offers_store.dart';
|
import 'services/saved_offers_store.dart';
|
||||||
import 'services/saved_search_alert_service.dart';
|
import 'services/saved_search_alert_service.dart';
|
||||||
import 'services/saved_searches_store.dart';
|
import 'services/saved_searches_store.dart';
|
||||||
|
import 'services/sharing_switch.dart';
|
||||||
import 'services/social_account_store.dart';
|
import 'services/social_account_store.dart';
|
||||||
import 'services/social_connection.dart';
|
import 'services/social_connection.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
|
|
@ -81,14 +82,24 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
final savedSearchAlerts = getIt.isRegistered<SavedSearchAlertService>()
|
final savedSearchAlerts = getIt.isRegistered<SavedSearchAlertService>()
|
||||||
? getIt<SavedSearchAlertService>()
|
? getIt<SavedSearchAlertService>()
|
||||||
: null;
|
: null;
|
||||||
|
// Sharing is opt-in: the app must not touch the network until the person
|
||||||
|
// has joined the sharing side. An install from before this setting keeps
|
||||||
|
// whatever it had (see `migrateSharingEnabled`) so nobody silently loses
|
||||||
|
// messaging on upgrade.
|
||||||
|
final introSeen = await onboarding.introSeen();
|
||||||
|
final sharingOn = await getIt<SocialSettings>().migrateSharingEnabled(
|
||||||
|
introSeen: introSeen,
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the
|
// Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the
|
||||||
// shared connection starts connecting, so the first session is caught; then
|
// shared connection starts connecting, so the first session is caught; then
|
||||||
// bring the connection up.
|
// bring the connection up. The listeners are harmless while sharing is off:
|
||||||
|
// they only ever react to a session, and none arrives.
|
||||||
inbox?.start();
|
inbox?.start();
|
||||||
sync?.start();
|
sync?.start();
|
||||||
plantares?.start();
|
plantares?.start();
|
||||||
savedSearchAlerts?.start();
|
savedSearchAlerts?.start();
|
||||||
connection?.start();
|
if (sharingOn) connection?.start();
|
||||||
|
|
||||||
return TaneApp(
|
return TaneApp(
|
||||||
repository: getIt<VarietyRepository>(),
|
repository: getIt<VarietyRepository>(),
|
||||||
|
|
@ -108,7 +119,12 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
socialAccounts: getIt<SocialAccountStore>(),
|
socialAccounts: getIt<SocialAccountStore>(),
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
notifications: notifications,
|
notifications: notifications,
|
||||||
showIntro: !await onboarding.introSeen(),
|
showIntro: !introSeen,
|
||||||
|
sharing: SharingSwitch(
|
||||||
|
settings: getIt<SocialSettings>(),
|
||||||
|
connection: connection,
|
||||||
|
enabled: sharingOn,
|
||||||
|
),
|
||||||
autoBackup: getIt.isRegistered<AutoBackupService>()
|
autoBackup: getIt.isRegistered<AutoBackupService>()
|
||||||
? getIt<AutoBackupService>()
|
? getIt<AutoBackupService>()
|
||||||
: null,
|
: null,
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,7 @@
|
||||||
"cancel": "Encaboxar",
|
"cancel": "Encaboxar",
|
||||||
"delete": "Desaniciar",
|
"delete": "Desaniciar",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"type": "Triba",
|
"type": "Triba"
|
||||||
"comingSoon": "Aína"
|
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"tagline": "Comparte y cultiva simiente llocal",
|
"tagline": "Comparte y cultiva simiente llocal",
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
"comingSoon": "Bald",
|
|
||||||
"offline": "Offline - Teilen ist unterbrochen"
|
"offline": "Offline - Teilen ist unterbrochen"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"comingSoon": "Coming soon",
|
|
||||||
"offline": "You're offline — sharing is paused"
|
"offline": "You're offline — sharing is paused"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
|
|
@ -572,7 +571,9 @@
|
||||||
"noProfile": "This person hasn't shared a profile yet",
|
"noProfile": "This person hasn't shared a profile yet",
|
||||||
"copyId": "Copy code",
|
"copyId": "Copy code",
|
||||||
"idCopied": "Code copied",
|
"idCopied": "Code copied",
|
||||||
"photo": "Photo"
|
"photo": "Photo",
|
||||||
|
"sharingOnLabel": "Sharing is on",
|
||||||
|
"sharingOnHelp": "Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Your profile",
|
"title": "Your profile",
|
||||||
|
|
@ -780,7 +781,8 @@
|
||||||
"publicNote": "What you publish here is public, and copies may remain even if you remove it later.",
|
"publicNote": "What you publish here is public, and copies may remain even if you remove it later.",
|
||||||
"viewLegal": "Privacy & rules",
|
"viewLegal": "Privacy & rules",
|
||||||
"accept": "I agree",
|
"accept": "I agree",
|
||||||
"decline": "Not now"
|
"decline": "Not now",
|
||||||
|
"networkNote": "To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn't connect to anything."
|
||||||
},
|
},
|
||||||
"report": {
|
"report": {
|
||||||
"offer": "Report this offer",
|
"offer": "Report this offer",
|
||||||
|
|
@ -806,5 +808,14 @@
|
||||||
"manageTitle": "Blocked people",
|
"manageTitle": "Blocked people",
|
||||||
"manageEmpty": "You haven't blocked anyone",
|
"manageEmpty": "You haven't blocked anyone",
|
||||||
"unblock": "Unblock"
|
"unblock": "Unblock"
|
||||||
|
},
|
||||||
|
"sharingInvite": {
|
||||||
|
"title": "This wakes up when you start sharing",
|
||||||
|
"perkChat": "Write to whoever has seeds near you",
|
||||||
|
"perkFavorites": "Keep the offers you like",
|
||||||
|
"perkPeople": "Your circle of people you trust",
|
||||||
|
"networkNote": "For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.",
|
||||||
|
"start": "Start sharing",
|
||||||
|
"notNow": "Not now"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
"comingSoon": "Pronto",
|
|
||||||
"offline": "Sin conexión — el compartir está en pausa"
|
"offline": "Sin conexión — el compartir está en pausa"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
|
|
@ -571,7 +570,9 @@
|
||||||
"noProfile": "Esta persona aún no ha compartido su perfil",
|
"noProfile": "Esta persona aún no ha compartido su perfil",
|
||||||
"copyId": "Copiar código",
|
"copyId": "Copiar código",
|
||||||
"idCopied": "Código copiado",
|
"idCopied": "Código copiado",
|
||||||
"photo": "Foto"
|
"photo": "Foto",
|
||||||
|
"sharingOnLabel": "Compartir está activado",
|
||||||
|
"sharingOnHelp": "Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Tu perfil",
|
"title": "Tu perfil",
|
||||||
|
|
@ -779,7 +780,8 @@
|
||||||
"publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.",
|
"publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.",
|
||||||
"viewLegal": "Privacidad y normas",
|
"viewLegal": "Privacidad y normas",
|
||||||
"accept": "Acepto",
|
"accept": "Acepto",
|
||||||
"decline": "Ahora no"
|
"decline": "Ahora no",
|
||||||
|
"networkNote": "Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada."
|
||||||
},
|
},
|
||||||
"report": {
|
"report": {
|
||||||
"offer": "Denunciar esta oferta",
|
"offer": "Denunciar esta oferta",
|
||||||
|
|
@ -805,5 +807,14 @@
|
||||||
"manageTitle": "Personas bloqueadas",
|
"manageTitle": "Personas bloqueadas",
|
||||||
"manageEmpty": "No has bloqueado a nadie",
|
"manageEmpty": "No has bloqueado a nadie",
|
||||||
"unblock": "Desbloquear"
|
"unblock": "Desbloquear"
|
||||||
|
},
|
||||||
|
"sharingInvite": {
|
||||||
|
"title": "Esto se enciende cuando empiezas a compartir",
|
||||||
|
"perkChat": "Escribirte con quien tiene semillas cerca",
|
||||||
|
"perkFavorites": "Guardar las ofertas que te gustan",
|
||||||
|
"perkPeople": "Tu círculo de gente de confianza",
|
||||||
|
"networkNote": "Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.",
|
||||||
|
"start": "Empezar a compartir",
|
||||||
|
"notNow": "Ahora no"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"comingSoon": "À venir",
|
|
||||||
"offline": "Vous êtes hors ligne — le partage est en pause"
|
"offline": "Vous êtes hors ligne — le partage est en pause"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"type": "種類",
|
"type": "種類",
|
||||||
"comingSoon": "近日公開",
|
|
||||||
"offline": "オフラインです — 共有を一時停止しています"
|
"offline": "オフラインです — 共有を一時停止しています"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
"comingSoon": "Em breve",
|
|
||||||
"offline": "Sem ligação — a partilha está em pausa"
|
"offline": "Sem ligação — a partilha está em pausa"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
"comingSoon": "Em breve",
|
|
||||||
"offline": "Sem ligação — a compartilha está em pausa"
|
"offline": "Sem ligação — a compartilha está em pausa"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 8
|
/// Locales: 8
|
||||||
/// Strings: 4287 (535 per locale)
|
/// Strings: 4299 (537 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-22 at 11:02 UTC
|
/// Built on 2026-07-25 at 14:38 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,6 @@ class _Translations$common$ast extends Translations$common$en {
|
||||||
@override String get delete => 'Desaniciar';
|
@override String get delete => 'Desaniciar';
|
||||||
@override String get edit => 'Editar';
|
@override String get edit => 'Editar';
|
||||||
@override String get type => 'Triba';
|
@override String get type => 'Triba';
|
||||||
@override String get comingSoon => 'Aína';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: home
|
// Path: home
|
||||||
|
|
@ -1465,7 +1464,6 @@ extension on TranslationsAst {
|
||||||
'common.delete' => 'Desaniciar',
|
'common.delete' => 'Desaniciar',
|
||||||
'common.edit' => 'Editar',
|
'common.edit' => 'Editar',
|
||||||
'common.type' => 'Triba',
|
'common.type' => 'Triba',
|
||||||
'common.comingSoon' => 'Aína',
|
|
||||||
'home.tagline' => 'Comparte y cultiva simiente llocal',
|
'home.tagline' => 'Comparte y cultiva simiente llocal',
|
||||||
'home.openMarket' => 'Mercáu',
|
'home.openMarket' => 'Mercáu',
|
||||||
'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca',
|
'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca',
|
||||||
|
|
@ -1930,9 +1928,9 @@ extension on TranslationsAst {
|
||||||
'handover.promiseGave' => 'Van devolveme semiente',
|
'handover.promiseGave' => 'Van devolveme semiente',
|
||||||
'handover.promiseReceived' => 'Voi devolver semiente',
|
'handover.promiseReceived' => 'Voi devolver semiente',
|
||||||
'sale.title' => 'Ventes',
|
'sale.title' => 'Ventes',
|
||||||
|
'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.',
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.',
|
|
||||||
'sale.add' => 'Rexistrar venta',
|
'sale.add' => 'Rexistrar venta',
|
||||||
'sale.empty' => 'Entá nun hai ventes. Anota equí lo que vendes o merques.',
|
'sale.empty' => 'Entá nun hai ventes. Anota equí lo que vendes o merques.',
|
||||||
'sale.iSold' => 'Vendí',
|
'sale.iSold' => 'Vendí',
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,6 @@ class _Translations$common$de extends Translations$common$en {
|
||||||
@override String get delete => 'Löschen';
|
@override String get delete => 'Löschen';
|
||||||
@override String get edit => 'Bearbeiten';
|
@override String get edit => 'Bearbeiten';
|
||||||
@override String get type => 'Typ';
|
@override String get type => 'Typ';
|
||||||
@override String get comingSoon => 'Bald';
|
|
||||||
@override String get offline => 'Offline - Teilen ist unterbrochen';
|
@override String get offline => 'Offline - Teilen ist unterbrochen';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1461,7 +1460,6 @@ extension on TranslationsDe {
|
||||||
'common.delete' => 'Löschen',
|
'common.delete' => 'Löschen',
|
||||||
'common.edit' => 'Bearbeiten',
|
'common.edit' => 'Bearbeiten',
|
||||||
'common.type' => 'Typ',
|
'common.type' => 'Typ',
|
||||||
'common.comingSoon' => 'Bald',
|
|
||||||
'common.offline' => 'Offline - Teilen ist unterbrochen',
|
'common.offline' => 'Offline - Teilen ist unterbrochen',
|
||||||
'home.tagline' => 'Teile und baue lokale Samen an',
|
'home.tagline' => 'Teile und baue lokale Samen an',
|
||||||
'home.openMarket' => 'Markt',
|
'home.openMarket' => 'Markt',
|
||||||
|
|
@ -1926,9 +1924,9 @@ extension on TranslationsDe {
|
||||||
'sale.add' => 'Verkauf notieren',
|
'sale.add' => 'Verkauf notieren',
|
||||||
'sale.empty' => 'Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.',
|
'sale.empty' => 'Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.',
|
||||||
'sale.iSold' => 'Ich verkaufte',
|
'sale.iSold' => 'Ich verkaufte',
|
||||||
|
'sale.iBought' => 'Ich kaufte',
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
'sale.iBought' => 'Ich kaufte',
|
|
||||||
'sale.direction' => 'Verkauft oder gekauft?',
|
'sale.direction' => 'Verkauft oder gekauft?',
|
||||||
'sale.counterparty' => 'Mit wem?',
|
'sale.counterparty' => 'Mit wem?',
|
||||||
'sale.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)',
|
'sale.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)',
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
late final Translations$marketGate$en marketGate = Translations$marketGate$en.internal(_root);
|
late final Translations$marketGate$en marketGate = Translations$marketGate$en.internal(_root);
|
||||||
late final Translations$report$en report = Translations$report$en.internal(_root);
|
late final Translations$report$en report = Translations$report$en.internal(_root);
|
||||||
late final Translations$block$en block = Translations$block$en.internal(_root);
|
late final Translations$block$en block = Translations$block$en.internal(_root);
|
||||||
|
late final Translations$sharingInvite$en sharingInvite = Translations$sharingInvite$en.internal(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: avatar
|
// Path: avatar
|
||||||
|
|
@ -340,9 +341,6 @@ class Translations$common$en {
|
||||||
/// en: 'Type'
|
/// en: 'Type'
|
||||||
String get type => 'Type';
|
String get type => 'Type';
|
||||||
|
|
||||||
/// en: 'Coming soon'
|
|
||||||
String get comingSoon => 'Coming soon';
|
|
||||||
|
|
||||||
/// en: 'You're offline — sharing is paused'
|
/// en: 'You're offline — sharing is paused'
|
||||||
String get offline => 'You\'re offline — sharing is paused';
|
String get offline => 'You\'re offline — sharing is paused';
|
||||||
}
|
}
|
||||||
|
|
@ -1578,6 +1576,12 @@ class Translations$market$en {
|
||||||
|
|
||||||
/// en: 'Photo'
|
/// en: 'Photo'
|
||||||
String get photo => 'Photo';
|
String get photo => 'Photo';
|
||||||
|
|
||||||
|
/// en: 'Sharing is on'
|
||||||
|
String get sharingOnLabel => 'Sharing is on';
|
||||||
|
|
||||||
|
/// en: 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.'
|
||||||
|
String get sharingOnHelp => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: profile
|
// Path: profile
|
||||||
|
|
@ -2241,6 +2245,9 @@ class Translations$marketGate$en {
|
||||||
|
|
||||||
/// en: 'Not now'
|
/// en: 'Not now'
|
||||||
String get decline => 'Not now';
|
String get decline => 'Not now';
|
||||||
|
|
||||||
|
/// en: 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn't connect to anything.'
|
||||||
|
String get networkNote => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: report
|
// Path: report
|
||||||
|
|
@ -2324,6 +2331,36 @@ class Translations$block$en {
|
||||||
String get unblock => 'Unblock';
|
String get unblock => 'Unblock';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: sharingInvite
|
||||||
|
class Translations$sharingInvite$en {
|
||||||
|
Translations$sharingInvite$en.internal(this._root);
|
||||||
|
|
||||||
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
|
||||||
|
/// en: 'This wakes up when you start sharing'
|
||||||
|
String get title => 'This wakes up when you start sharing';
|
||||||
|
|
||||||
|
/// en: 'Write to whoever has seeds near you'
|
||||||
|
String get perkChat => 'Write to whoever has seeds near you';
|
||||||
|
|
||||||
|
/// en: 'Keep the offers you like'
|
||||||
|
String get perkFavorites => 'Keep the offers you like';
|
||||||
|
|
||||||
|
/// en: 'Your circle of people you trust'
|
||||||
|
String get perkPeople => 'Your circle of people you trust';
|
||||||
|
|
||||||
|
/// en: 'For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.'
|
||||||
|
String get networkNote => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.';
|
||||||
|
|
||||||
|
/// en: 'Start sharing'
|
||||||
|
String get start => 'Start sharing';
|
||||||
|
|
||||||
|
/// en: 'Not now'
|
||||||
|
String get notNow => 'Not now';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: intro.slides
|
// Path: intro.slides
|
||||||
class Translations$intro$slides$en {
|
class Translations$intro$slides$en {
|
||||||
Translations$intro$slides$en.internal(this._root);
|
Translations$intro$slides$en.internal(this._root);
|
||||||
|
|
@ -2839,7 +2876,6 @@ extension on Translations {
|
||||||
'common.delete' => 'Delete',
|
'common.delete' => 'Delete',
|
||||||
'common.edit' => 'Edit',
|
'common.edit' => 'Edit',
|
||||||
'common.type' => 'Type',
|
'common.type' => 'Type',
|
||||||
'common.comingSoon' => 'Coming soon',
|
|
||||||
'common.offline' => 'You\'re offline — sharing is paused',
|
'common.offline' => 'You\'re offline — sharing is paused',
|
||||||
'home.tagline' => 'Share and grow local seeds',
|
'home.tagline' => 'Share and grow local seeds',
|
||||||
'home.openMarket' => 'Market',
|
'home.openMarket' => 'Market',
|
||||||
|
|
@ -3218,6 +3254,8 @@ extension on Translations {
|
||||||
'market.copyId' => 'Copy code',
|
'market.copyId' => 'Copy code',
|
||||||
'market.idCopied' => 'Code copied',
|
'market.idCopied' => 'Code copied',
|
||||||
'market.photo' => 'Photo',
|
'market.photo' => 'Photo',
|
||||||
|
'market.sharingOnLabel' => 'Sharing is on',
|
||||||
|
'market.sharingOnHelp' => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.',
|
||||||
'profile.title' => 'Your profile',
|
'profile.title' => 'Your profile',
|
||||||
'profile.name' => 'Display name',
|
'profile.name' => 'Display name',
|
||||||
'profile.nameHint' => 'How others see you',
|
'profile.nameHint' => 'How others see you',
|
||||||
|
|
@ -3292,9 +3330,9 @@ extension on Translations {
|
||||||
'plantare.delete' => 'Remove',
|
'plantare.delete' => 'Remove',
|
||||||
'plantare.statusReturned' => 'Returned',
|
'plantare.statusReturned' => 'Returned',
|
||||||
'plantare.statusForgiven' => 'Settled',
|
'plantare.statusForgiven' => 'Settled',
|
||||||
'plantare.openSection' => 'Open',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'plantare.openSection' => 'Open',
|
||||||
'plantare.settledSection' => 'Done',
|
'plantare.settledSection' => 'Done',
|
||||||
'plantare.removeConfirm' => 'Remove this commitment?',
|
'plantare.removeConfirm' => 'Remove this commitment?',
|
||||||
'plantare.returnBy' => ({required Object date}) => 'Return by ${date}',
|
'plantare.returnBy' => ({required Object date}) => 'Return by ${date}',
|
||||||
|
|
@ -3402,6 +3440,7 @@ extension on Translations {
|
||||||
'marketGate.viewLegal' => 'Privacy & rules',
|
'marketGate.viewLegal' => 'Privacy & rules',
|
||||||
'marketGate.accept' => 'I agree',
|
'marketGate.accept' => 'I agree',
|
||||||
'marketGate.decline' => 'Not now',
|
'marketGate.decline' => 'Not now',
|
||||||
|
'marketGate.networkNote' => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.',
|
||||||
'report.offer' => 'Report this offer',
|
'report.offer' => 'Report this offer',
|
||||||
'report.person' => 'Report this person',
|
'report.person' => 'Report this person',
|
||||||
'report.title' => 'Report',
|
'report.title' => 'Report',
|
||||||
|
|
@ -3423,6 +3462,13 @@ extension on Translations {
|
||||||
'block.manageTitle' => 'Blocked people',
|
'block.manageTitle' => 'Blocked people',
|
||||||
'block.manageEmpty' => 'You haven\'t blocked anyone',
|
'block.manageEmpty' => 'You haven\'t blocked anyone',
|
||||||
'block.unblock' => 'Unblock',
|
'block.unblock' => 'Unblock',
|
||||||
|
'sharingInvite.title' => 'This wakes up when you start sharing',
|
||||||
|
'sharingInvite.perkChat' => 'Write to whoever has seeds near you',
|
||||||
|
'sharingInvite.perkFavorites' => 'Keep the offers you like',
|
||||||
|
'sharingInvite.perkPeople' => 'Your circle of people you trust',
|
||||||
|
'sharingInvite.networkNote' => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.',
|
||||||
|
'sharingInvite.start' => 'Start sharing',
|
||||||
|
'sharingInvite.notNow' => 'Not now',
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$marketGate$es marketGate = _Translations$marketGate$es._(_root);
|
@override late final _Translations$marketGate$es marketGate = _Translations$marketGate$es._(_root);
|
||||||
@override late final _Translations$report$es report = _Translations$report$es._(_root);
|
@override late final _Translations$report$es report = _Translations$report$es._(_root);
|
||||||
@override late final _Translations$block$es block = _Translations$block$es._(_root);
|
@override late final _Translations$block$es block = _Translations$block$es._(_root);
|
||||||
|
@override late final _Translations$sharingInvite$es sharingInvite = _Translations$sharingInvite$es._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: avatar
|
// Path: avatar
|
||||||
|
|
@ -222,7 +223,6 @@ class _Translations$common$es extends Translations$common$en {
|
||||||
@override String get delete => 'Eliminar';
|
@override String get delete => 'Eliminar';
|
||||||
@override String get edit => 'Editar';
|
@override String get edit => 'Editar';
|
||||||
@override String get type => 'Tipo';
|
@override String get type => 'Tipo';
|
||||||
@override String get comingSoon => 'Pronto';
|
|
||||||
@override String get offline => 'Sin conexión — el compartir está en pausa';
|
@override String get offline => 'Sin conexión — el compartir está en pausa';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -839,6 +839,8 @@ class _Translations$market$es extends Translations$market$en {
|
||||||
@override String get copyId => 'Copiar código';
|
@override String get copyId => 'Copiar código';
|
||||||
@override String get idCopied => 'Código copiado';
|
@override String get idCopied => 'Código copiado';
|
||||||
@override String get photo => 'Foto';
|
@override String get photo => 'Foto';
|
||||||
|
@override String get sharingOnLabel => 'Compartir está activado';
|
||||||
|
@override String get sharingOnHelp => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: profile
|
// Path: profile
|
||||||
|
|
@ -1138,6 +1140,7 @@ class _Translations$marketGate$es extends Translations$marketGate$en {
|
||||||
@override String get viewLegal => 'Privacidad y normas';
|
@override String get viewLegal => 'Privacidad y normas';
|
||||||
@override String get accept => 'Acepto';
|
@override String get accept => 'Acepto';
|
||||||
@override String get decline => 'Ahora no';
|
@override String get decline => 'Ahora no';
|
||||||
|
@override String get networkNote => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: report
|
// Path: report
|
||||||
|
|
@ -1179,6 +1182,22 @@ class _Translations$block$es extends Translations$block$en {
|
||||||
@override String get unblock => 'Desbloquear';
|
@override String get unblock => 'Desbloquear';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: sharingInvite
|
||||||
|
class _Translations$sharingInvite$es extends Translations$sharingInvite$en {
|
||||||
|
_Translations$sharingInvite$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsEs _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get title => 'Esto se enciende cuando empiezas a compartir';
|
||||||
|
@override String get perkChat => 'Escribirte con quien tiene semillas cerca';
|
||||||
|
@override String get perkFavorites => 'Guardar las ofertas que te gustan';
|
||||||
|
@override String get perkPeople => 'Tu círculo de gente de confianza';
|
||||||
|
@override String get networkNote => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.';
|
||||||
|
@override String get start => 'Empezar a compartir';
|
||||||
|
@override String get notNow => 'Ahora no';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: intro.slides
|
// Path: intro.slides
|
||||||
class _Translations$intro$slides$es extends Translations$intro$slides$en {
|
class _Translations$intro$slides$es extends Translations$intro$slides$en {
|
||||||
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1578,7 +1597,6 @@ extension on TranslationsEs {
|
||||||
'common.delete' => 'Eliminar',
|
'common.delete' => 'Eliminar',
|
||||||
'common.edit' => 'Editar',
|
'common.edit' => 'Editar',
|
||||||
'common.type' => 'Tipo',
|
'common.type' => 'Tipo',
|
||||||
'common.comingSoon' => 'Pronto',
|
|
||||||
'common.offline' => 'Sin conexión — el compartir está en pausa',
|
'common.offline' => 'Sin conexión — el compartir está en pausa',
|
||||||
'home.tagline' => 'Comparte y cultiva semillas locales',
|
'home.tagline' => 'Comparte y cultiva semillas locales',
|
||||||
'home.openMarket' => 'Mercado',
|
'home.openMarket' => 'Mercado',
|
||||||
|
|
@ -1956,6 +1974,8 @@ extension on TranslationsEs {
|
||||||
'market.copyId' => 'Copiar código',
|
'market.copyId' => 'Copiar código',
|
||||||
'market.idCopied' => 'Código copiado',
|
'market.idCopied' => 'Código copiado',
|
||||||
'market.photo' => 'Foto',
|
'market.photo' => 'Foto',
|
||||||
|
'market.sharingOnLabel' => 'Compartir está activado',
|
||||||
|
'market.sharingOnHelp' => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.',
|
||||||
'profile.title' => 'Tu perfil',
|
'profile.title' => 'Tu perfil',
|
||||||
'profile.name' => 'Nombre',
|
'profile.name' => 'Nombre',
|
||||||
'profile.nameHint' => 'Cómo te ven los demás',
|
'profile.nameHint' => 'Cómo te ven los demás',
|
||||||
|
|
@ -2031,9 +2051,9 @@ extension on TranslationsEs {
|
||||||
'plantare.statusReturned' => 'Devuelto',
|
'plantare.statusReturned' => 'Devuelto',
|
||||||
'plantare.statusForgiven' => 'Saldado',
|
'plantare.statusForgiven' => 'Saldado',
|
||||||
'plantare.openSection' => 'Pendientes',
|
'plantare.openSection' => 'Pendientes',
|
||||||
'plantare.settledSection' => 'Hechos',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'plantare.settledSection' => 'Hechos',
|
||||||
'plantare.removeConfirm' => '¿Quitar este compromiso?',
|
'plantare.removeConfirm' => '¿Quitar este compromiso?',
|
||||||
'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}',
|
'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}',
|
||||||
'plantare.overdue' => 'vencido',
|
'plantare.overdue' => 'vencido',
|
||||||
|
|
@ -2140,6 +2160,7 @@ extension on TranslationsEs {
|
||||||
'marketGate.viewLegal' => 'Privacidad y normas',
|
'marketGate.viewLegal' => 'Privacidad y normas',
|
||||||
'marketGate.accept' => 'Acepto',
|
'marketGate.accept' => 'Acepto',
|
||||||
'marketGate.decline' => 'Ahora no',
|
'marketGate.decline' => 'Ahora no',
|
||||||
|
'marketGate.networkNote' => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.',
|
||||||
'report.offer' => 'Denunciar esta oferta',
|
'report.offer' => 'Denunciar esta oferta',
|
||||||
'report.person' => 'Denunciar a esta persona',
|
'report.person' => 'Denunciar a esta persona',
|
||||||
'report.title' => 'Denunciar',
|
'report.title' => 'Denunciar',
|
||||||
|
|
@ -2161,6 +2182,13 @@ extension on TranslationsEs {
|
||||||
'block.manageTitle' => 'Personas bloqueadas',
|
'block.manageTitle' => 'Personas bloqueadas',
|
||||||
'block.manageEmpty' => 'No has bloqueado a nadie',
|
'block.manageEmpty' => 'No has bloqueado a nadie',
|
||||||
'block.unblock' => 'Desbloquear',
|
'block.unblock' => 'Desbloquear',
|
||||||
|
'sharingInvite.title' => 'Esto se enciende cuando empiezas a compartir',
|
||||||
|
'sharingInvite.perkChat' => 'Escribirte con quien tiene semillas cerca',
|
||||||
|
'sharingInvite.perkFavorites' => 'Guardar las ofertas que te gustan',
|
||||||
|
'sharingInvite.perkPeople' => 'Tu círculo de gente de confianza',
|
||||||
|
'sharingInvite.networkNote' => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.',
|
||||||
|
'sharingInvite.start' => 'Empezar a compartir',
|
||||||
|
'sharingInvite.notNow' => 'Ahora no',
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,6 @@ class _Translations$common$fr extends Translations$common$en {
|
||||||
@override String get delete => 'Supprimer';
|
@override String get delete => 'Supprimer';
|
||||||
@override String get edit => 'Modifier';
|
@override String get edit => 'Modifier';
|
||||||
@override String get type => 'Type';
|
@override String get type => 'Type';
|
||||||
@override String get comingSoon => 'À venir';
|
|
||||||
@override String get offline => 'Vous êtes hors ligne — le partage est en pause';
|
@override String get offline => 'Vous êtes hors ligne — le partage est en pause';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1461,7 +1460,6 @@ extension on TranslationsFr {
|
||||||
'common.delete' => 'Supprimer',
|
'common.delete' => 'Supprimer',
|
||||||
'common.edit' => 'Modifier',
|
'common.edit' => 'Modifier',
|
||||||
'common.type' => 'Type',
|
'common.type' => 'Type',
|
||||||
'common.comingSoon' => 'À venir',
|
|
||||||
'common.offline' => 'Vous êtes hors ligne — le partage est en pause',
|
'common.offline' => 'Vous êtes hors ligne — le partage est en pause',
|
||||||
'home.tagline' => 'Partagez et cultivez des graines locales',
|
'home.tagline' => 'Partagez et cultivez des graines locales',
|
||||||
'home.openMarket' => 'Marché',
|
'home.openMarket' => 'Marché',
|
||||||
|
|
@ -1926,9 +1924,9 @@ extension on TranslationsFr {
|
||||||
'sale.add' => 'Enregistrer une vente',
|
'sale.add' => 'Enregistrer une vente',
|
||||||
'sale.empty' => 'Pas encore de ventes. Notez ici ce que vous vendez ou achetez.',
|
'sale.empty' => 'Pas encore de ventes. Notez ici ce que vous vendez ou achetez.',
|
||||||
'sale.iSold' => 'J\'ai vendu',
|
'sale.iSold' => 'J\'ai vendu',
|
||||||
|
'sale.iBought' => 'J\'ai acheté',
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
'sale.iBought' => 'J\'ai acheté',
|
|
||||||
'sale.direction' => 'Vendu ou acheté ?',
|
'sale.direction' => 'Vendu ou acheté ?',
|
||||||
'sale.counterparty' => 'Avec qui ?',
|
'sale.counterparty' => 'Avec qui ?',
|
||||||
'sale.counterpartyHint' => 'Une personne ou un collectif (optionnel)',
|
'sale.counterpartyHint' => 'Une personne ou un collectif (optionnel)',
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ class _Translations$common$ja extends Translations$common$en {
|
||||||
@override String get delete => '削除';
|
@override String get delete => '削除';
|
||||||
@override String get edit => '編集';
|
@override String get edit => '編集';
|
||||||
@override String get type => '種類';
|
@override String get type => '種類';
|
||||||
@override String get comingSoon => '近日公開';
|
|
||||||
@override String get offline => 'オフラインです — 共有を一時停止しています';
|
@override String get offline => 'オフラインです — 共有を一時停止しています';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +139,6 @@ extension on TranslationsJa {
|
||||||
'common.delete' => '削除',
|
'common.delete' => '削除',
|
||||||
'common.edit' => '編集',
|
'common.edit' => '編集',
|
||||||
'common.type' => '種類',
|
'common.type' => '種類',
|
||||||
'common.comingSoon' => '近日公開',
|
|
||||||
'common.offline' => 'オフラインです — 共有を一時停止しています',
|
'common.offline' => 'オフラインです — 共有を一時停止しています',
|
||||||
'menu.tagline' => 'あなたの種子バンク',
|
'menu.tagline' => 'あなたの種子バンク',
|
||||||
'menu.inventory' => '在庫',
|
'menu.inventory' => '在庫',
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,6 @@ class Translations$common$pt extends Translations$common$en {
|
||||||
@override String get delete => 'Eliminar';
|
@override String get delete => 'Eliminar';
|
||||||
@override String get edit => 'Editar';
|
@override String get edit => 'Editar';
|
||||||
@override String get type => 'Tipo';
|
@override String get type => 'Tipo';
|
||||||
@override String get comingSoon => 'Em breve';
|
|
||||||
@override String get offline => 'Sem ligação — a partilha está em pausa';
|
@override String get offline => 'Sem ligação — a partilha está em pausa';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1579,7 +1578,6 @@ extension on TranslationsPt {
|
||||||
'common.delete' => 'Eliminar',
|
'common.delete' => 'Eliminar',
|
||||||
'common.edit' => 'Editar',
|
'common.edit' => 'Editar',
|
||||||
'common.type' => 'Tipo',
|
'common.type' => 'Tipo',
|
||||||
'common.comingSoon' => 'Em breve',
|
|
||||||
'common.offline' => 'Sem ligação — a partilha está em pausa',
|
'common.offline' => 'Sem ligação — a partilha está em pausa',
|
||||||
'home.tagline' => 'Partilha e cultiva sementes locais',
|
'home.tagline' => 'Partilha e cultiva sementes locais',
|
||||||
'home.openMarket' => 'Mercado',
|
'home.openMarket' => 'Mercado',
|
||||||
|
|
@ -2033,9 +2031,9 @@ extension on TranslationsPt {
|
||||||
'plantare.statusReturned' => 'Devolvido',
|
'plantare.statusReturned' => 'Devolvido',
|
||||||
'plantare.statusForgiven' => 'Saldado',
|
'plantare.statusForgiven' => 'Saldado',
|
||||||
'plantare.openSection' => 'Pendentes',
|
'plantare.openSection' => 'Pendentes',
|
||||||
|
'plantare.settledSection' => 'Feitos',
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
'plantare.settledSection' => 'Feitos',
|
|
||||||
'plantare.removeConfirm' => 'Remover este compromisso?',
|
'plantare.removeConfirm' => 'Remover este compromisso?',
|
||||||
'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}',
|
'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}',
|
||||||
'plantare.overdue' => 'vencido',
|
'plantare.overdue' => 'vencido',
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,6 @@ class _Translations$common$pt_BR extends Translations$common$pt {
|
||||||
@override String get delete => 'Eliminar';
|
@override String get delete => 'Eliminar';
|
||||||
@override String get edit => 'Editar';
|
@override String get edit => 'Editar';
|
||||||
@override String get type => 'Tipo';
|
@override String get type => 'Tipo';
|
||||||
@override String get comingSoon => 'Em breve';
|
|
||||||
@override String get offline => 'Sem ligação — a compartilha está em pausa';
|
@override String get offline => 'Sem ligação — a compartilha está em pausa';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1580,7 +1579,6 @@ extension on TranslationsPtBr {
|
||||||
'common.delete' => 'Eliminar',
|
'common.delete' => 'Eliminar',
|
||||||
'common.edit' => 'Editar',
|
'common.edit' => 'Editar',
|
||||||
'common.type' => 'Tipo',
|
'common.type' => 'Tipo',
|
||||||
'common.comingSoon' => 'Em breve',
|
|
||||||
'common.offline' => 'Sem ligação — a compartilha está em pausa',
|
'common.offline' => 'Sem ligação — a compartilha está em pausa',
|
||||||
'home.tagline' => 'Compartilha e cultiva sementes locais',
|
'home.tagline' => 'Compartilha e cultiva sementes locais',
|
||||||
'home.openMarket' => 'Mercado',
|
'home.openMarket' => 'Mercado',
|
||||||
|
|
@ -2034,9 +2032,9 @@ extension on TranslationsPtBr {
|
||||||
'plantare.statusReturned' => 'Devolvido',
|
'plantare.statusReturned' => 'Devolvido',
|
||||||
'plantare.statusForgiven' => 'Saldado',
|
'plantare.statusForgiven' => 'Saldado',
|
||||||
'plantare.openSection' => 'Pendentes',
|
'plantare.openSection' => 'Pendentes',
|
||||||
|
'plantare.settledSection' => 'Feitos',
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
'plantare.settledSection' => 'Feitos',
|
|
||||||
'plantare.removeConfirm' => 'Remover este compromisso?',
|
'plantare.removeConfirm' => 'Remover este compromisso?',
|
||||||
'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}',
|
'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}',
|
||||||
'plantare.overdue' => 'vencido',
|
'plantare.overdue' => 'vencido',
|
||||||
|
|
|
||||||
46
apps/app_seeds/lib/services/sharing_switch.dart
Normal file
46
apps/app_seeds/lib/services/sharing_switch.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'social_connection.dart';
|
||||||
|
import 'social_settings.dart';
|
||||||
|
|
||||||
|
/// The one place that turns the sharing side of Tane on and off.
|
||||||
|
///
|
||||||
|
/// Sharing is opt-in: until someone joins it, the app opens no connection at
|
||||||
|
/// all and the seed book is entirely offline. Joining has to move three things
|
||||||
|
/// at once — the stored choice, the live relay connection, and the flag the UI
|
||||||
|
/// listens to — and doing that from several screens is how they drift apart.
|
||||||
|
/// So every caller (the community-rules gate, the invite sheet, the sharing
|
||||||
|
/// setup) goes through here instead.
|
||||||
|
class SharingSwitch {
|
||||||
|
SharingSwitch({
|
||||||
|
required SocialSettings settings,
|
||||||
|
SocialConnection? connection,
|
||||||
|
bool enabled = false,
|
||||||
|
}) : _settings = settings,
|
||||||
|
_connection = connection,
|
||||||
|
on = ValueNotifier(enabled);
|
||||||
|
|
||||||
|
final SocialSettings _settings;
|
||||||
|
final SocialConnection? _connection;
|
||||||
|
|
||||||
|
/// Whether sharing is on right now. Screens listen so the drawer's social
|
||||||
|
/// entries light up the moment someone joins, with no restart.
|
||||||
|
final ValueNotifier<bool> on;
|
||||||
|
|
||||||
|
Future<void> enable() async {
|
||||||
|
await _settings.setSharingEnabled(true);
|
||||||
|
// Safe to call even if it is already running: `start` guards itself.
|
||||||
|
_connection?.start();
|
||||||
|
on.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning it off must actually go offline — closing the live session, not
|
||||||
|
/// just recording the choice for the next launch.
|
||||||
|
Future<void> disable() async {
|
||||||
|
await _settings.setSharingEnabled(false);
|
||||||
|
await _connection?.stop();
|
||||||
|
on.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => on.dispose();
|
||||||
|
}
|
||||||
|
|
@ -63,8 +63,11 @@ class SocialConnection {
|
||||||
SocialSession? get current => _current;
|
SocialSession? get current => _current;
|
||||||
|
|
||||||
/// Begins watching connectivity (reconnect on regain, drop when offline) and
|
/// Begins watching connectivity (reconnect on regain, drop when offline) and
|
||||||
/// attempts an initial connect. Idempotent-ish; call once at startup.
|
/// attempts an initial connect. Called at startup when sharing is already on,
|
||||||
|
/// and again the moment someone joins the sharing side — hence the guard, so
|
||||||
|
/// a second call never stacks a second connectivity subscription.
|
||||||
void start() {
|
void start() {
|
||||||
|
if (_started || _disposed) return;
|
||||||
_started = true;
|
_started = true;
|
||||||
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
|
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
|
||||||
_knownOffline = !isOnline;
|
_knownOffline = !isOnline;
|
||||||
|
|
@ -139,6 +142,19 @@ class SocialConnection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Goes offline for good until [start] is called again: stops watching
|
||||||
|
/// connectivity, cancels any pending retry and tears the live session down.
|
||||||
|
/// This is what "turn sharing off" must do — before it existed, clearing the
|
||||||
|
/// server list only took effect on the next launch, because the already-open
|
||||||
|
/// session was never closed. Unlike [dispose] the object stays usable.
|
||||||
|
Future<void> stop() async {
|
||||||
|
_started = false;
|
||||||
|
_cancelRetry();
|
||||||
|
await _onlineSub?.cancel();
|
||||||
|
_onlineSub = null;
|
||||||
|
_drop();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_cancelRetry();
|
_cancelRetry();
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import '../security/secret_store.dart';
|
||||||
/// keystore (via [SecretStore]) to honour "no plaintext at rest" — no
|
/// keystore (via [SecretStore]) to honour "no plaintext at rest" — no
|
||||||
/// shared_preferences.
|
/// shared_preferences.
|
||||||
///
|
///
|
||||||
/// Relays default to a small set of well-known public servers so the market
|
/// Sharing is off until the person joins it ([sharingEnabled]); until then the
|
||||||
/// works out of the box; the exposure is minimal (offers are opt-in and carry
|
/// app opens no connection at all. Once they do, relays default to a small set
|
||||||
/// only a coarse geohash) and the user can swap them for a community server.
|
/// of well-known public servers so the market works out of the box; the exposure
|
||||||
|
/// is minimal (offers are opt-in and carry only a coarse geohash) and the user
|
||||||
|
/// can swap them for a community server or turn them all off.
|
||||||
/// The area stays unset until the user picks one (it's inherently personal).
|
/// The area stays unset until the user picks one (it's inherently personal).
|
||||||
class SocialSettings {
|
class SocialSettings {
|
||||||
SocialSettings(this._store);
|
SocialSettings(this._store);
|
||||||
|
|
@ -16,6 +18,7 @@ class SocialSettings {
|
||||||
|
|
||||||
static const _areaKey = 'tane.social.area_geohash';
|
static const _areaKey = 'tane.social.area_geohash';
|
||||||
static const _relaysKey = 'tane.social.relays';
|
static const _relaysKey = 'tane.social.relays';
|
||||||
|
static const _sharingKey = 'tane.social.sharing_enabled';
|
||||||
static const _searchPrecisionKey = 'tane.social.search_precision';
|
static const _searchPrecisionKey = 'tane.social.search_precision';
|
||||||
static const _blockedKey = 'tane.social.blocked_pubkeys';
|
static const _blockedKey = 'tane.social.blocked_pubkeys';
|
||||||
static const _hiddenOffersKey = 'tane.social.hidden_offers';
|
static const _hiddenOffersKey = 'tane.social.hidden_offers';
|
||||||
|
|
@ -29,11 +32,11 @@ class SocialSettings {
|
||||||
static const int maxSearchPrecision = 5;
|
static const int maxSearchPrecision = 5;
|
||||||
static const int defaultSearchPrecision = 4;
|
static const int defaultSearchPrecision = 4;
|
||||||
|
|
||||||
/// Community servers used automatically so sharing works from the first
|
/// Community servers used automatically once the person joins the sharing
|
||||||
/// launch. The relay pool skips any that are unreachable, so a dead one never
|
/// side, so the market works without any setup. The relay pool skips any that
|
||||||
/// breaks the market; the user never has to know these exist. The Comunes
|
/// are unreachable, so a dead one never breaks the market; the user never has
|
||||||
/// relay comes first as the reliable, non-commercial home; the public ones
|
/// to know these exist. The Comunes relay comes first as the reliable,
|
||||||
/// are backup.
|
/// non-commercial home; the public ones are backup.
|
||||||
static const List<String> defaultRelays = [
|
static const List<String> defaultRelays = [
|
||||||
'wss://relay.comunes.org',
|
'wss://relay.comunes.org',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
|
|
@ -62,6 +65,38 @@ class SocialSettings {
|
||||||
urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'),
|
urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Whether the person has said yes to the sharing side of the app. Until they
|
||||||
|
/// do, Tane opens no connection at all — the seed book is entirely offline.
|
||||||
|
///
|
||||||
|
/// Three states on purpose: `null` means "never asked", which is what lets an
|
||||||
|
/// install that predates this setting keep working exactly as before (see
|
||||||
|
/// `migrateSharingEnabled`). Once written it is a plain yes/no the person
|
||||||
|
/// controls from the sharing setup.
|
||||||
|
Future<bool?> sharingEnabled() async {
|
||||||
|
final raw = await _store.read(_sharingKey);
|
||||||
|
if (raw == null) return null;
|
||||||
|
return raw == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSharingEnabled(bool enabled) =>
|
||||||
|
_store.write(_sharingKey, enabled ? '1' : '0');
|
||||||
|
|
||||||
|
/// Decides, once, what an install that predates the setting should get, and
|
||||||
|
/// records it. Anyone who had already been through the intro was on a build
|
||||||
|
/// that connected at launch, so they keep sharing on and lose nothing —
|
||||||
|
/// messages, device sync and offer alerts keep arriving. A fresh install has
|
||||||
|
/// not seen the intro yet, so it starts fully offline and only goes online
|
||||||
|
/// when the person joins the sharing side.
|
||||||
|
///
|
||||||
|
/// Returns the effective value. Safe to call on every launch: it writes only
|
||||||
|
/// when nothing has been recorded yet.
|
||||||
|
Future<bool> migrateSharingEnabled({required bool introSeen}) async {
|
||||||
|
final stored = await sharingEnabled();
|
||||||
|
if (stored != null) return stored;
|
||||||
|
await setSharingEnabled(introSeen);
|
||||||
|
return introSeen;
|
||||||
|
}
|
||||||
|
|
||||||
/// How wide to search — a geohash prefix length in [minSearchPrecision,
|
/// How wide to search — a geohash prefix length in [minSearchPrecision,
|
||||||
/// maxSearchPrecision]. Defaults (and falls back on any garbage) to
|
/// maxSearchPrecision]. Defaults (and falls back on any garbage) to
|
||||||
/// [defaultSearchPrecision].
|
/// [defaultSearchPrecision].
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,66 @@ import 'package:go_router/go_router.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../services/onboarding_store.dart';
|
||||||
|
import '../services/sharing_switch.dart';
|
||||||
import 'seed_glyph.dart';
|
import 'seed_glyph.dart';
|
||||||
|
import 'sharing_invite_sheet.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'unread_badge.dart';
|
import 'unread_badge.dart';
|
||||||
|
|
||||||
/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is
|
/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is
|
||||||
/// the live destination (green seed glyph), the social items (market, profile,
|
/// the live destination (green seed glyph), the social items sit below a divider,
|
||||||
/// chat…) belong to Block 2 and are greyed with a "soon" tag, and Settings sits
|
/// and Settings is pinned at the bottom.
|
||||||
/// pinned at the bottom.
|
///
|
||||||
|
/// While sharing is off the social items stay visible but quiet, with a small
|
||||||
|
/// padlock: tapping one explains what it does and offers to join. The Market is
|
||||||
|
/// the exception — it is always live, because it is the door people go through
|
||||||
|
/// to join in the first place.
|
||||||
class AppDrawer extends StatelessWidget {
|
class AppDrawer extends StatelessWidget {
|
||||||
const AppDrawer({this.marketEnabled = false, super.key});
|
const AppDrawer({this.sharing, this.onboarding, super.key});
|
||||||
|
|
||||||
/// When the Block 2 social layer is wired, the Market becomes a live drawer
|
/// The sharing on/off switch. Null when the social layer isn't there at all
|
||||||
/// destination; other social items stay "soon" until they're built.
|
/// (identity derivation failed) — then the social items are not drawn, since
|
||||||
final bool marketEnabled;
|
/// there is nothing the person could do to turn them on.
|
||||||
|
final SharingSwitch? sharing;
|
||||||
|
|
||||||
|
/// Needed to run the community-rules step when someone accepts the invite.
|
||||||
|
final OnboardingStore? onboarding;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final sharing = this.sharing;
|
||||||
|
if (sharing == null) return _build(context, social: false, sharingOn: false);
|
||||||
|
return ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: sharing.on,
|
||||||
|
builder: (context, on, _) =>
|
||||||
|
_build(context, social: true, sharingOn: on),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _build(
|
||||||
|
BuildContext context, {
|
||||||
|
required bool social,
|
||||||
|
required bool sharingOn,
|
||||||
|
}) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
// While sharing is off, tapping a social entry invites the person in rather
|
||||||
|
// than doing nothing.
|
||||||
|
Future<void> invite() async {
|
||||||
|
final sharing = this.sharing;
|
||||||
|
final onboarding = this.onboarding;
|
||||||
|
if (sharing == null || onboarding == null) return;
|
||||||
|
// Close the drawer first, then run the sheet off the navigator's own
|
||||||
|
// context — this one dies with the drawer.
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
navigator.pop();
|
||||||
|
await showSharingInvite(
|
||||||
|
navigator.context,
|
||||||
|
onboarding: onboarding,
|
||||||
|
sharing: sharing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Drawer(
|
return Drawer(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -69,59 +111,68 @@ class AppDrawer extends StatelessWidget {
|
||||||
context.push('/calendar');
|
context.push('/calendar');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_DrawerItem(
|
// The market is always live when the social layer exists: it
|
||||||
icon: const Icon(Symbols.storefront),
|
// is where people join sharing, so locking it would lock the
|
||||||
label: t.menu.market,
|
// only door.
|
||||||
divider: true,
|
if (social)
|
||||||
onTap: marketEnabled
|
_DrawerItem(
|
||||||
? () {
|
icon: const Icon(Symbols.storefront),
|
||||||
Navigator.of(context).pop();
|
label: t.menu.market,
|
||||||
context.push('/market');
|
divider: true,
|
||||||
}
|
onTap: () {
|
||||||
: null,
|
Navigator.of(context).pop();
|
||||||
),
|
context.push('/market');
|
||||||
_DrawerItem(
|
},
|
||||||
icon: const Icon(Icons.person),
|
),
|
||||||
label: t.menu.profile,
|
if (social)
|
||||||
onTap: marketEnabled
|
_DrawerItem(
|
||||||
? () {
|
icon: const Icon(Icons.person),
|
||||||
Navigator.of(context).pop();
|
label: t.menu.profile,
|
||||||
context.push('/profile');
|
locked: !sharingOn,
|
||||||
}
|
onTap: sharingOn
|
||||||
: null,
|
? () {
|
||||||
),
|
Navigator.of(context).pop();
|
||||||
_DrawerItem(
|
context.push('/profile');
|
||||||
icon: const UnreadBadge(child: Icon(Icons.chat_bubble)),
|
}
|
||||||
label: t.menu.chat,
|
: invite,
|
||||||
onTap: marketEnabled
|
),
|
||||||
? () {
|
if (social)
|
||||||
Navigator.of(context).pop();
|
_DrawerItem(
|
||||||
context.push('/messages');
|
icon: const UnreadBadge(child: Icon(Icons.chat_bubble)),
|
||||||
}
|
label: t.menu.chat,
|
||||||
: null,
|
locked: !sharingOn,
|
||||||
),
|
onTap: sharingOn
|
||||||
_DrawerItem(
|
? () {
|
||||||
icon: const Icon(Icons.favorite),
|
Navigator.of(context).pop();
|
||||||
label: t.menu.wishlist,
|
context.push('/messages');
|
||||||
onTap: marketEnabled
|
}
|
||||||
? () {
|
: invite,
|
||||||
Navigator.of(context).pop();
|
),
|
||||||
context.push('/favorites');
|
if (social)
|
||||||
}
|
_DrawerItem(
|
||||||
: null,
|
icon: const Icon(Icons.favorite),
|
||||||
),
|
label: t.menu.wishlist,
|
||||||
// The ego-centric web of trust ("your people") — live once the
|
locked: !sharingOn,
|
||||||
// social layer is on.
|
onTap: sharingOn
|
||||||
_DrawerItem(
|
? () {
|
||||||
icon: const Icon(Icons.group),
|
Navigator.of(context).pop();
|
||||||
label: t.menu.following,
|
context.push('/favorites');
|
||||||
onTap: marketEnabled
|
}
|
||||||
? () {
|
: invite,
|
||||||
Navigator.of(context).pop();
|
),
|
||||||
context.push('/your-people');
|
// The ego-centric web of trust ("your people").
|
||||||
}
|
if (social)
|
||||||
: null,
|
_DrawerItem(
|
||||||
),
|
icon: const Icon(Icons.group),
|
||||||
|
label: t.menu.following,
|
||||||
|
locked: !sharingOn,
|
||||||
|
onTap: sharingOn
|
||||||
|
? () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.push('/your-people');
|
||||||
|
}
|
||||||
|
: invite,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -203,6 +254,7 @@ class _DrawerItem extends StatelessWidget {
|
||||||
required this.label,
|
required this.label,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.divider = false,
|
this.divider = false,
|
||||||
|
this.locked = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget icon;
|
final Widget icon;
|
||||||
|
|
@ -210,9 +262,13 @@ class _DrawerItem extends StatelessWidget {
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final bool divider;
|
final bool divider;
|
||||||
|
|
||||||
|
/// Drawn quiet, with a padlock, but still tappable — it leads to the
|
||||||
|
/// invitation to join sharing rather than to the destination itself.
|
||||||
|
final bool locked;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled = onTap != null;
|
final enabled = onTap != null && !locked;
|
||||||
final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F);
|
final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F);
|
||||||
final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F);
|
final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F);
|
||||||
final row = InkWell(
|
final row = InkWell(
|
||||||
|
|
@ -247,15 +303,11 @@ class _DrawerItem extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!enabled)
|
if (locked)
|
||||||
Text(
|
const Icon(
|
||||||
context.t.common.comingSoon.toUpperCase(),
|
Icons.lock_outline,
|
||||||
style: const TextStyle(
|
size: 16,
|
||||||
color: Color(0xFFB3BDA8),
|
color: Color(0xFFB3BDA8),
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../services/onboarding_store.dart';
|
||||||
|
import '../services/sharing_switch.dart';
|
||||||
import 'app_drawer.dart';
|
import 'app_drawer.dart';
|
||||||
import 'seed_glyph.dart';
|
import 'seed_glyph.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
@ -9,14 +11,19 @@ import 'unread_badge.dart';
|
||||||
|
|
||||||
/// The main menu (redesign screen 00): a sprout logo in a soft green disc over a
|
/// The main menu (redesign screen 00): a sprout logo in a soft green disc over a
|
||||||
/// faint seed-glyph watermark, with "Your inventory" as the primary green call
|
/// faint seed-glyph watermark, with "Your inventory" as the primary green call
|
||||||
/// to action and "Open market" (Block 2) as a disabled outlined card. The
|
/// to action and "Open market" as an outlined card below it. The hamburger opens
|
||||||
/// hamburger opens [AppDrawer].
|
/// [AppDrawer].
|
||||||
|
///
|
||||||
|
/// The market card is always live when the social layer exists — it is the door
|
||||||
|
/// people go through to join sharing — and simply isn't drawn when it doesn't.
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({this.marketEnabled = false, super.key});
|
const HomeScreen({this.sharing, this.onboarding, super.key});
|
||||||
|
|
||||||
/// When the Block 2 social layer is wired, the market becomes a live
|
/// The sharing on/off switch, or null when there is no social layer at all.
|
||||||
/// destination; otherwise it stays a disabled "coming soon" card.
|
final SharingSwitch? sharing;
|
||||||
final bool marketEnabled;
|
|
||||||
|
/// Needed to run the community-rules step from the drawer's invitation.
|
||||||
|
final OnboardingStore? onboarding;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -34,7 +41,7 @@ class HomeScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
drawer: AppDrawer(marketEnabled: marketEnabled),
|
drawer: AppDrawer(sharing: sharing, onboarding: onboarding),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
const Positioned.fill(child: _SeedWatermark()),
|
const Positioned.fill(child: _SeedWatermark()),
|
||||||
|
|
@ -99,17 +106,16 @@ class HomeScreen extends StatelessWidget {
|
||||||
subtitle: t.home.yourInventorySubtitle,
|
subtitle: t.home.yourInventorySubtitle,
|
||||||
onTap: () => context.push('/inventory'),
|
onTap: () => context.push('/inventory'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
if (sharing != null) ...[
|
||||||
_OutlinedMenuCard(
|
const SizedBox(height: 16),
|
||||||
key: const Key('home.market'),
|
_OutlinedMenuCard(
|
||||||
icon: Icons.storefront_outlined,
|
key: const Key('home.market'),
|
||||||
label: t.home.openMarket,
|
icon: Icons.storefront_outlined,
|
||||||
subtitle: t.home.openMarketSubtitle,
|
label: t.home.openMarket,
|
||||||
tag: marketEnabled ? null : t.common.comingSoon,
|
subtitle: t.home.openMarketSubtitle,
|
||||||
onTap: marketEnabled
|
onTap: () => context.push('/market'),
|
||||||
? () => context.push('/market')
|
),
|
||||||
: null,
|
],
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -175,14 +181,12 @@ class _PrimaryMenuCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A disabled Block-2 destination: an outlined white card with a green-disc
|
/// A secondary destination: an outlined white card with a green-disc icon.
|
||||||
/// icon and a "soon" tag.
|
|
||||||
class _OutlinedMenuCard extends StatelessWidget {
|
class _OutlinedMenuCard extends StatelessWidget {
|
||||||
const _OutlinedMenuCard({
|
const _OutlinedMenuCard({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
this.tag,
|
|
||||||
this.onTap,
|
this.onTap,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
@ -191,9 +195,6 @@ class _OutlinedMenuCard extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
|
|
||||||
/// A small trailing tag (e.g. "coming soon"); omitted for live cards.
|
|
||||||
final String? tag;
|
|
||||||
|
|
||||||
/// When set, the card is tappable; otherwise it reads as disabled.
|
/// When set, the card is tappable; otherwise it reads as disabled.
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
|
@ -221,17 +222,7 @@ class _OutlinedMenuCard extends StatelessWidget {
|
||||||
subtitleColor: seedMuted,
|
subtitleColor: seedMuted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (tag != null)
|
if (onTap != null)
|
||||||
Text(
|
|
||||||
tag!.toUpperCase(),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFFB3BDA8),
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (onTap != null)
|
|
||||||
const Icon(Icons.chevron_right, color: seedMuted),
|
const Icon(Icons.chevron_right, color: seedMuted),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,29 @@ import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/onboarding_store.dart';
|
import '../services/onboarding_store.dart';
|
||||||
|
import '../services/sharing_switch.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// Makes sure the community rules have been accepted once before the user
|
/// Makes sure the community rules have been accepted once before the user
|
||||||
/// joins the market or publishes anything. Returns true when the rules are
|
/// joins the market or publishes anything. Returns true when the rules are
|
||||||
/// (or become) accepted; false when the user declines. Play/App Store UGC
|
/// (or become) accepted; false when the user declines. Play/App Store UGC
|
||||||
/// policies require this acceptance before content can be created.
|
/// policies require this acceptance before content can be created.
|
||||||
|
///
|
||||||
|
/// This is also where Tane goes online for the first time: agreeing here is the
|
||||||
|
/// opt-in that turns [sharing] on. Keeping both in one step means there is a
|
||||||
|
/// single moment where someone says yes, and it is a moment that explains
|
||||||
|
/// itself — rather than a connection that happened at launch without asking.
|
||||||
Future<bool> ensureMarketRulesAccepted(
|
Future<bool> ensureMarketRulesAccepted(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
OnboardingStore store,
|
OnboardingStore store, {
|
||||||
) async {
|
SharingSwitch? sharing,
|
||||||
if (await store.marketRulesAccepted()) return true;
|
}) async {
|
||||||
|
if (await store.marketRulesAccepted()) {
|
||||||
|
// Already agreed, but sharing may still be off (they turned it off in the
|
||||||
|
// sharing setup, or agreed on a build that had no switch): honour the ask.
|
||||||
|
if (sharing != null && !sharing.on.value) await sharing.enable();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!context.mounted) return false;
|
if (!context.mounted) return false;
|
||||||
final accepted = await showModalBottomSheet<bool>(
|
final accepted = await showModalBottomSheet<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -24,6 +36,7 @@ Future<bool> ensureMarketRulesAccepted(
|
||||||
);
|
);
|
||||||
if (accepted == true) {
|
if (accepted == true) {
|
||||||
await store.markMarketRulesAccepted();
|
await store.markMarketRulesAccepted();
|
||||||
|
await sharing?.enable();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -77,6 +90,14 @@ class MarketGateSheet extends StatelessWidget {
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
t.marketGate.networkNote,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: seedMuted,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import '../services/offer_outbox.dart';
|
||||||
import '../services/saved_searches_store.dart';
|
import '../services/saved_searches_store.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
|
import '../services/sharing_switch.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../services/onboarding_store.dart';
|
import '../services/onboarding_store.dart';
|
||||||
import '../state/offers_cubit.dart';
|
import '../state/offers_cubit.dart';
|
||||||
|
|
@ -35,6 +36,7 @@ class MarketScreen extends StatefulWidget {
|
||||||
this.onboarding,
|
this.onboarding,
|
||||||
this.savedSearches,
|
this.savedSearches,
|
||||||
this.initialSearch,
|
this.initialSearch,
|
||||||
|
this.sharing,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -54,6 +56,10 @@ class MarketScreen extends StatefulWidget {
|
||||||
/// created). Null in tests → no gate.
|
/// created). Null in tests → no gate.
|
||||||
final OnboardingStore? onboarding;
|
final OnboardingStore? onboarding;
|
||||||
|
|
||||||
|
/// The sharing on/off switch. Accepting the rules turns it on (that is the
|
||||||
|
/// moment Tane first goes online); the sharing setup can turn it back off.
|
||||||
|
final SharingSwitch? sharing;
|
||||||
|
|
||||||
/// The shared relay connection (one per identity), reused for discovery.
|
/// The shared relay connection (one per identity), reused for discovery.
|
||||||
final SocialConnection connection;
|
final SocialConnection connection;
|
||||||
|
|
||||||
|
|
@ -84,11 +90,18 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
/// network; declining leaves the market.
|
/// network; declining leaves the market.
|
||||||
Future<void> _start() async {
|
Future<void> _start() async {
|
||||||
final store = widget.onboarding;
|
final store = widget.onboarding;
|
||||||
if (store != null && !await store.marketRulesAccepted()) {
|
final sharing = widget.sharing;
|
||||||
|
// The rules step is also the moment Tane first goes online, so it runs
|
||||||
|
// whenever sharing is still off — even for someone who agreed long ago and
|
||||||
|
// later switched sharing back off.
|
||||||
|
if (store != null &&
|
||||||
|
(!await store.marketRulesAccepted() ||
|
||||||
|
(sharing != null && !sharing.on.value))) {
|
||||||
// Wait for the first frame so the sheet has a surface to attach to.
|
// Wait for the first frame so the sheet has a surface to attach to.
|
||||||
await WidgetsBinding.instance.endOfFrame;
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ok = await ensureMarketRulesAccepted(context, store);
|
final ok = await ensureMarketRulesAccepted(context, store,
|
||||||
|
sharing: sharing);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
return;
|
return;
|
||||||
|
|
@ -156,8 +169,11 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
final changed = await showModalBottomSheet<bool>(
|
final changed = await showModalBottomSheet<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) =>
|
builder: (_) => _ConfigSheet(
|
||||||
_ConfigSheet(settings: widget.settings, location: widget.location),
|
settings: widget.settings,
|
||||||
|
location: widget.location,
|
||||||
|
sharing: widget.sharing,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (changed == true) await _init();
|
if (changed == true) await _init();
|
||||||
}
|
}
|
||||||
|
|
@ -786,10 +802,11 @@ class _EmptyState extends StatelessWidget {
|
||||||
/// Coarse-area + community-server setup. Kept behind progressive disclosure — a
|
/// Coarse-area + community-server setup. Kept behind progressive disclosure — a
|
||||||
/// power-user surface — with human-worded labels.
|
/// power-user surface — with human-worded labels.
|
||||||
class _ConfigSheet extends StatefulWidget {
|
class _ConfigSheet extends StatefulWidget {
|
||||||
const _ConfigSheet({required this.settings, this.location});
|
const _ConfigSheet({required this.settings, this.location, this.sharing});
|
||||||
|
|
||||||
final SocialSettings settings;
|
final SocialSettings settings;
|
||||||
final CoarseLocationProvider? location;
|
final CoarseLocationProvider? location;
|
||||||
|
final SharingSwitch? sharing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ConfigSheet> createState() => _ConfigSheetState();
|
State<_ConfigSheet> createState() => _ConfigSheetState();
|
||||||
|
|
@ -1067,6 +1084,30 @@ class _ConfigSheetState extends State<_ConfigSheet> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
|
// The master switch: turning it off takes Tane fully
|
||||||
|
// offline right away, not at the next launch.
|
||||||
|
if (widget.sharing != null)
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: widget.sharing!.on,
|
||||||
|
builder: (context, on, _) => SwitchListTile(
|
||||||
|
key: const Key('market.sharingOn'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
value: on,
|
||||||
|
title: Text(t.market.sharingOnLabel),
|
||||||
|
subtitle: Text(
|
||||||
|
t.market.sharingOnHelp,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: seedMuted,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (want) => want
|
||||||
|
? widget.sharing!.enable()
|
||||||
|
: widget.sharing!.disable(),
|
||||||
|
),
|
||||||
|
),
|
||||||
TextField(
|
TextField(
|
||||||
key: const Key('market.area'),
|
key: const Key('market.area'),
|
||||||
controller: _area,
|
controller: _area,
|
||||||
|
|
|
||||||
123
apps/app_seeds/lib/ui/sharing_invite_sheet.dart
Normal file
123
apps/app_seeds/lib/ui/sharing_invite_sheet.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../services/onboarding_store.dart';
|
||||||
|
import '../services/sharing_switch.dart';
|
||||||
|
import 'market_gate.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
/// Shown when someone taps a social entry (chat, profile, favourites, your
|
||||||
|
/// people) while sharing is still off.
|
||||||
|
///
|
||||||
|
/// The alternative was hiding those entries until sharing is on, but then
|
||||||
|
/// nobody would ever discover that Tane does any of it. So they stay in the
|
||||||
|
/// drawer, quiet, and tapping one explains what they are and offers to switch
|
||||||
|
/// them on — with the community rules, which is the same single consent step
|
||||||
|
/// the market uses. Returns true when sharing ended up on.
|
||||||
|
Future<bool> showSharingInvite(
|
||||||
|
BuildContext context, {
|
||||||
|
required OnboardingStore onboarding,
|
||||||
|
required SharingSwitch sharing,
|
||||||
|
}) async {
|
||||||
|
final wants = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => const SharingInviteSheet(),
|
||||||
|
);
|
||||||
|
if (wants != true || !context.mounted) return false;
|
||||||
|
return ensureMarketRulesAccepted(context, onboarding, sharing: sharing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invitation itself: what lights up when you join, and the plain fact that
|
||||||
|
/// it needs a connection.
|
||||||
|
class SharingInviteSheet extends StatelessWidget {
|
||||||
|
const SharingInviteSheet({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.t;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 24,
|
||||||
|
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.sharingInvite.title,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: seedOnSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_Perk(Icons.chat_bubble_outline, t.sharingInvite.perkChat),
|
||||||
|
_Perk(Icons.favorite_border, t.sharingInvite.perkFavorites),
|
||||||
|
_Perk(Icons.group_outlined, t.sharingInvite.perkPeople),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
t.sharingInvite.networkNote,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: seedMuted,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
key: const Key('sharingInvite.notNow'),
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: Text(t.sharingInvite.notNow),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('sharingInvite.start'),
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: Text(t.sharingInvite.start),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Perk extends StatelessWidget {
|
||||||
|
const _Perk(this.icon, this.text);
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: seedGreen),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: seedOnSurface,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: tane
|
name: tane
|
||||||
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
|
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.15+17
|
version: 0.1.16+18
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.5
|
sdk: ^3.11.5
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ void main() {
|
||||||
seeded = await seedShowcase(db, repo, locale);
|
seeded = await seedShowcase(db, repo, locale);
|
||||||
});
|
});
|
||||||
final child = switch (name) {
|
final child = switch (name) {
|
||||||
'home' => const HomeScreen(marketEnabled: true),
|
'home' => HomeScreen(sharing: newTestSharingSwitch()),
|
||||||
'inventory' => const InventoryListScreen(),
|
'inventory' => const InventoryListScreen(),
|
||||||
'market' => marketWidget(locale),
|
'market' => marketWidget(locale),
|
||||||
'calendar' => const CalendarScreen(initialMonth: 4),
|
'calendar' => const CalendarScreen(initialMonth: 4),
|
||||||
|
|
|
||||||
|
|
@ -181,4 +181,70 @@ void main() {
|
||||||
expect(await conn.session(), isNotNull); // succeeds on retry
|
expect(await conn.session(), isNotNull); // succeeds on retry
|
||||||
await conn.dispose();
|
await conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('start is idempotent: a second call adds no second connect', () async {
|
||||||
|
// Joining sharing calls start() while bootstrap may already have, so a
|
||||||
|
// repeat must not stack another connectivity subscription or dial again.
|
||||||
|
final opened = <FakeChannel>[];
|
||||||
|
final online = StreamController<bool>.broadcast();
|
||||||
|
final conn = make(opened: opened, online: online.stream);
|
||||||
|
|
||||||
|
conn.start();
|
||||||
|
await conn.session();
|
||||||
|
conn.start();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(opened, hasLength(1));
|
||||||
|
|
||||||
|
// One subscription, so one drop — not two competing reactions.
|
||||||
|
online.add(false);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(conn.current, isNull);
|
||||||
|
expect(opened.first.closed, isTrue);
|
||||||
|
|
||||||
|
await conn.dispose();
|
||||||
|
await online.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop goes offline now, not at the next launch', () async {
|
||||||
|
// Turning sharing off used to leave the live session open until restart.
|
||||||
|
final opened = <FakeChannel>[];
|
||||||
|
final online = StreamController<bool>.broadcast();
|
||||||
|
final conn = make(opened: opened, online: online.stream);
|
||||||
|
final emitted = <SocialSession?>[];
|
||||||
|
conn.sessions.listen(emitted.add);
|
||||||
|
|
||||||
|
conn.start();
|
||||||
|
expect(await conn.session(), isNotNull);
|
||||||
|
|
||||||
|
await conn.stop();
|
||||||
|
await Future<void>.delayed(Duration.zero); // let the drop be announced
|
||||||
|
expect(conn.current, isNull);
|
||||||
|
expect(opened.single.closed, isTrue);
|
||||||
|
expect(emitted.last, isNull);
|
||||||
|
|
||||||
|
// And it stays off: a connectivity event must not resurrect it.
|
||||||
|
online.add(true);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(conn.current, isNull);
|
||||||
|
expect(opened, hasLength(1));
|
||||||
|
|
||||||
|
await conn.dispose();
|
||||||
|
await online.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop leaves the connection usable: start brings it back', () async {
|
||||||
|
final opened = <FakeChannel>[];
|
||||||
|
final online = StreamController<bool>.broadcast();
|
||||||
|
final conn = make(opened: opened, online: online.stream);
|
||||||
|
conn.start();
|
||||||
|
await conn.session();
|
||||||
|
await conn.stop();
|
||||||
|
|
||||||
|
conn.start();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(conn.current, isNotNull);
|
||||||
|
expect(opened, hasLength(2));
|
||||||
|
await conn.dispose();
|
||||||
|
await online.close();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,4 +101,34 @@ void main() {
|
||||||
{SocialSettings.offerKey('ab' * 32, 'tomate-1')},
|
{SocialSettings.offerKey('ab' * 32, 'tomate-1')},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('sharing opt-in', () {
|
||||||
|
test('starts unanswered, then round-trips', () async {
|
||||||
|
expect(await settings.sharingEnabled(), isNull);
|
||||||
|
await settings.setSharingEnabled(true);
|
||||||
|
expect(await settings.sharingEnabled(), isTrue);
|
||||||
|
await settings.setSharingEnabled(false);
|
||||||
|
expect(await settings.sharingEnabled(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an install that had been through the intro keeps sharing on',
|
||||||
|
() async {
|
||||||
|
// The upgrade path that must not regress: these people were on a build
|
||||||
|
// that connected at launch, so they keep messaging, sync and alerts.
|
||||||
|
expect(await settings.migrateSharingEnabled(introSeen: true), isTrue);
|
||||||
|
expect(await settings.sharingEnabled(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fresh install starts offline', () async {
|
||||||
|
expect(await settings.migrateSharingEnabled(introSeen: false), isFalse);
|
||||||
|
expect(await settings.sharingEnabled(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migration never overwrites an answer already given', () async {
|
||||||
|
await settings.setSharingEnabled(false);
|
||||||
|
// Later launches see the intro as seen; the recorded "no" must survive.
|
||||||
|
expect(await settings.migrateSharingEnabled(introSeen: true), isFalse);
|
||||||
|
expect(await settings.sharingEnabled(), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import 'package:tane/db/database.dart';
|
||||||
import 'package:tane/i18n/strings.g.dart';
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
import 'package:tane/security/secret_store.dart';
|
import 'package:tane/security/secret_store.dart';
|
||||||
import 'package:tane/services/onboarding_store.dart';
|
import 'package:tane/services/onboarding_store.dart';
|
||||||
|
import 'package:tane/services/sharing_switch.dart';
|
||||||
|
import 'package:tane/services/social_settings.dart';
|
||||||
import 'package:tane/app.dart' show materialLocaleFor;
|
import 'package:tane/app.dart' show materialLocaleFor;
|
||||||
import 'package:tane/state/inventory_cubit.dart';
|
import 'package:tane/state/inventory_cubit.dart';
|
||||||
import 'package:tane/state/variety_detail_cubit.dart';
|
import 'package:tane/state/variety_detail_cubit.dart';
|
||||||
|
|
@ -59,6 +61,14 @@ OnboardingStore newTestOnboardingStore({bool introSeen = true}) {
|
||||||
return OnboardingStore(store);
|
return OnboardingStore(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A [SharingSwitch] over in-memory storage and no relay connection, for
|
||||||
|
/// screens that only care whether sharing is on. Defaults to on, which is what
|
||||||
|
/// most screen tests want (the social entries live, nothing dialled).
|
||||||
|
SharingSwitch newTestSharingSwitch({bool enabled = true}) => SharingSwitch(
|
||||||
|
settings: SocialSettings(InMemorySecretStore()),
|
||||||
|
enabled: enabled,
|
||||||
|
);
|
||||||
|
|
||||||
/// Wraps [child] with the providers a screen expects (repository, inventory
|
/// Wraps [child] with the providers a screen expects (repository, inventory
|
||||||
/// cubit) plus i18n and Material localizations, pinned to [locale].
|
/// cubit) plus i18n and Material localizations, pinned to [locale].
|
||||||
Widget wrapScreen({
|
Widget wrapScreen({
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,18 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/app.dart';
|
import 'package:tane/app.dart';
|
||||||
import 'package:tane/i18n/strings.g.dart';
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/services/sharing_switch.dart';
|
||||||
|
|
||||||
import '../support/test_support.dart';
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Widget app(db) => TranslationProvider(
|
/// The everyday case: the social layer exists and sharing is already on.
|
||||||
|
Widget app(db, {SharingSwitch? sharing}) => TranslationProvider(
|
||||||
child: TaneApp(
|
child: TaneApp(
|
||||||
repository: newTestRepository(db),
|
repository: newTestRepository(db),
|
||||||
species: newTestSpeciesRepository(db),
|
species: newTestSpeciesRepository(db),
|
||||||
onboarding: newTestOnboardingStore(),
|
onboarding: newTestOnboardingStore(),
|
||||||
|
sharing: sharing ?? newTestSharingSwitch(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -24,7 +27,8 @@ void main() {
|
||||||
|
|
||||||
expect(find.text('Your inventory'), findsOneWidget);
|
expect(find.text('Your inventory'), findsOneWidget);
|
||||||
expect(find.text('Market'), findsOneWidget);
|
expect(find.text('Market'), findsOneWidget);
|
||||||
expect(find.text('COMING SOON'), findsOneWidget); // market is Block 2
|
// "Coming soon" is gone for good: everything on this screen is built.
|
||||||
|
expect(find.textContaining('COMING SOON'), findsNothing);
|
||||||
|
|
||||||
// Redesign copy: tagline + the two destination subtitles.
|
// Redesign copy: tagline + the two destination subtitles.
|
||||||
expect(find.text('Share and grow local seeds'), findsOneWidget);
|
expect(find.text('Share and grow local seeds'), findsOneWidget);
|
||||||
|
|
@ -49,8 +53,9 @@ void main() {
|
||||||
await tester.tap(find.byIcon(Icons.menu)); // hamburger
|
await tester.tap(find.byIcon(Icons.menu)); // hamburger
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Social destinations are shown but disabled.
|
// With sharing on, the social destinations are live.
|
||||||
expect(find.text('Your profile'), findsOneWidget);
|
expect(find.text('Your profile'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.lock_outline), findsNothing);
|
||||||
|
|
||||||
await tester.tap(find.text('Inventory')); // active drawer item
|
await tester.tap(find.text('Inventory')); // active drawer item
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -84,6 +89,62 @@ void main() {
|
||||||
await disposeTree(tester);
|
await disposeTree(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('sharing off: market stays open, the rest wears a padlock',
|
||||||
|
(tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final db = newTestDatabase();
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
app(db, sharing: newTestSharingSwitch(enabled: false)),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The market card is the door into sharing, so it must stay reachable.
|
||||||
|
expect(find.byKey(const Key('home.market')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.menu));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Market live; the social entries are visible but padlocked — and tapping
|
||||||
|
// one invites the person in rather than doing nothing.
|
||||||
|
expect(find.text('Your profile'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.lock_outline), findsWidgets);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Your profile'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('This wakes up when you start sharing'), findsOneWidget);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no social layer: the market and its friends are not drawn',
|
||||||
|
(tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final db = newTestDatabase();
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: TaneApp(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
species: newTestSpeciesRepository(db),
|
||||||
|
onboarding: newTestOnboardingStore(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Nothing here can be switched on, so it is hidden rather than teased.
|
||||||
|
expect(find.byKey(const Key('home.market')), findsNothing);
|
||||||
|
expect(find.text('Your inventory'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.menu));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Your profile'), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.lock_outline), findsNothing);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('drawer Settings opens the settings screen', (tester) async {
|
testWidgets('drawer Settings opens the settings screen', (tester) async {
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,17 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/i18n/strings.g.dart';
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
import 'package:tane/services/onboarding_store.dart';
|
import 'package:tane/services/onboarding_store.dart';
|
||||||
|
import 'package:tane/services/sharing_switch.dart';
|
||||||
import 'package:tane/ui/market_gate.dart';
|
import 'package:tane/ui/market_gate.dart';
|
||||||
|
|
||||||
import '../support/test_support.dart';
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Widget host(OnboardingStore store, void Function(bool) onResult) =>
|
Widget host(
|
||||||
|
OnboardingStore store,
|
||||||
|
void Function(bool) onResult, {
|
||||||
|
SharingSwitch? sharing,
|
||||||
|
}) =>
|
||||||
TranslationProvider(
|
TranslationProvider(
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||||
|
|
@ -16,7 +21,13 @@ void main() {
|
||||||
builder: (context) => Center(
|
builder: (context) => Center(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
onResult(await ensureMarketRulesAccepted(context, store));
|
onResult(
|
||||||
|
await ensureMarketRulesAccepted(
|
||||||
|
context,
|
||||||
|
store,
|
||||||
|
sharing: sharing,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: const Text('enter'),
|
child: const Text('enter'),
|
||||||
),
|
),
|
||||||
|
|
@ -87,5 +98,52 @@ void main() {
|
||||||
expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget);
|
expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget);
|
||||||
expect(find.textContaining('public'), findsWidgets);
|
expect(find.textContaining('public'), findsWidgets);
|
||||||
expect(find.text('Privacy & rules'), findsOneWidget);
|
expect(find.text('Privacy & rules'), findsOneWidget);
|
||||||
|
// The sheet is also where Tane says it is about to go online for the first
|
||||||
|
// time — the reviewer's complaint was that this was never stated.
|
||||||
|
expect(find.textContaining('community servers'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('agreeing is what turns sharing on', (tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final store = OnboardingStore(InMemorySecretStore());
|
||||||
|
final sharing = newTestSharingSwitch(enabled: false);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(store, (_) {}, sharing: sharing));
|
||||||
|
await tester.tap(find.text('enter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sharing.on.value, isFalse, reason: 'still offline while asking');
|
||||||
|
|
||||||
|
await tester.tap(find.text('I agree'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sharing.on.value, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('declining leaves the app offline', (tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final store = OnboardingStore(InMemorySecretStore());
|
||||||
|
final sharing = newTestSharingSwitch(enabled: false);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(store, (_) {}, sharing: sharing));
|
||||||
|
await tester.tap(find.text('enter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Not now'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(sharing.on.value, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('someone who agreed long ago but switched sharing off is asked '
|
||||||
|
'nothing, yet comes back online', (tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final store = OnboardingStore(InMemorySecretStore());
|
||||||
|
await store.markMarketRulesAccepted();
|
||||||
|
final sharing = newTestSharingSwitch(enabled: false);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(store, (_) {}, sharing: sharing));
|
||||||
|
await tester.tap(find.text('enter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Before you join the market'), findsNothing);
|
||||||
|
expect(sharing.on.value, isTrue);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
apps/app_seeds/test/ui/sharing_invite_sheet_test.dart
Normal file
92
apps/app_seeds/test/ui/sharing_invite_sheet_test.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/services/onboarding_store.dart';
|
||||||
|
import 'package:tane/services/sharing_switch.dart';
|
||||||
|
import 'package:tane/ui/sharing_invite_sheet.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Widget host(OnboardingStore store, SharingSwitch sharing) =>
|
||||||
|
TranslationProvider(
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => showSharingInvite(
|
||||||
|
context,
|
||||||
|
onboarding: store,
|
||||||
|
sharing: sharing,
|
||||||
|
),
|
||||||
|
child: const Text('tap a locked entry'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('the invite says what lights up and that it needs a connection',
|
||||||
|
(tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: const MaterialApp(
|
||||||
|
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||||
|
home: Scaffold(body: SharingInviteSheet()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('This wakes up when you start sharing'), findsOneWidget);
|
||||||
|
expect(find.text('Write to whoever has seeds near you'), findsOneWidget);
|
||||||
|
expect(find.text('Keep the offers you like'), findsOneWidget);
|
||||||
|
expect(find.text('Your circle of people you trust'), findsOneWidget);
|
||||||
|
// The honest part: it is not free of consequences.
|
||||||
|
expect(find.textContaining('go online'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('"Not now" leaves the app exactly as offline as it was',
|
||||||
|
(tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final store = OnboardingStore(InMemorySecretStore());
|
||||||
|
final sharing = newTestSharingSwitch(enabled: false);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(store, sharing));
|
||||||
|
await tester.tap(find.text('tap a locked entry'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('sharingInvite.notNow')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(sharing.on.value, isFalse);
|
||||||
|
expect(await store.marketRulesAccepted(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('accepting the invite goes through the community rules once',
|
||||||
|
(tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final store = OnboardingStore(InMemorySecretStore());
|
||||||
|
final sharing = newTestSharingSwitch(enabled: false);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(store, sharing));
|
||||||
|
await tester.tap(find.text('tap a locked entry'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('sharingInvite.start')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Same single consent surface the market uses — not a second one.
|
||||||
|
expect(find.text('Before you join the market'), findsOneWidget);
|
||||||
|
expect(sharing.on.value, isFalse, reason: 'not online until they agree');
|
||||||
|
|
||||||
|
await tester.tap(find.text('I agree'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(sharing.on.value, isTrue);
|
||||||
|
expect(await store.marketRulesAccepted(), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -93,7 +93,7 @@ void main() {
|
||||||
wrapScreen(
|
wrapScreen(
|
||||||
repository: newTestRepository(db),
|
repository: newTestRepository(db),
|
||||||
locale: locale,
|
locale: locale,
|
||||||
child: const HomeScreen(marketEnabled: true),
|
child: HomeScreen(sharing: newTestSharingSwitch()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await disposeTree(tester);
|
await disposeTree(tester);
|
||||||
|
|
|
||||||
|
|
@ -15,19 +15,14 @@ AutoName: Tane
|
||||||
RepoType: git
|
RepoType: git
|
||||||
Repo: https://git.comunes.org/comunes/tane.git
|
Repo: https://git.comunes.org/comunes/tane.git
|
||||||
|
|
||||||
# Reproducible, developer-signed: F-Droid rebuilds each split and, if it matches
|
|
||||||
# our own signed APK on the git.comunes.org release byte-for-byte, publishes OUR
|
|
||||||
# APK (same signature as the Play build). Value is the SHA-256 of the tane-upload
|
|
||||||
# signing certificate (public).
|
|
||||||
AllowedAPKSigningKeys: ddfae432091b8248a8a4a1b353487fa626301f4357ef835e94ec312f69418e38
|
|
||||||
|
|
||||||
Builds:
|
Builds:
|
||||||
- versionName: 0.1.15
|
- versionName: 0.1.16
|
||||||
versionCode: 171
|
versionCode: 181
|
||||||
commit: v0.1.15
|
commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d
|
||||||
subdir: apps/app_seeds
|
subdir: apps/app_seeds
|
||||||
output: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
output: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||||
binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-armeabi-v7a-release.apk
|
binary:
|
||||||
|
https://git.comunes.org/comunes/tane/releases/download/v%v/app-armeabi-v7a-release.apk
|
||||||
srclibs:
|
srclibs:
|
||||||
- flutter@stable
|
- flutter@stable
|
||||||
- tesseract4android@4.9.0
|
- tesseract4android@4.9.0
|
||||||
|
|
@ -40,9 +35,9 @@ Builds:
|
||||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||||
- $$flutter$$/bin/flutter config --no-analytics
|
- $$flutter$$/bin/flutter config --no-analytics
|
||||||
- $$flutter$$/bin/flutter pub get --enforce-lockfile
|
- $$flutter$$/bin/flutter pub get --enforce-lockfile
|
||||||
- "sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle"
|
- sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle
|
||||||
- 'echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties'
|
- echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties
|
||||||
- 'echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties'
|
- echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties
|
||||||
- cd apps/app_seeds
|
- cd apps/app_seeds
|
||||||
- $$flutter$$/bin/dart run slang
|
- $$flutter$$/bin/dart run slang
|
||||||
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
|
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
@ -58,12 +53,13 @@ Builds:
|
||||||
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
|
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
|
||||||
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm
|
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm
|
||||||
|
|
||||||
- versionName: 0.1.15
|
- versionName: 0.1.16
|
||||||
versionCode: 172
|
versionCode: 182
|
||||||
commit: v0.1.15
|
commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d
|
||||||
subdir: apps/app_seeds
|
subdir: apps/app_seeds
|
||||||
output: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
output: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||||
binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-arm64-v8a-release.apk
|
binary:
|
||||||
|
https://git.comunes.org/comunes/tane/releases/download/v%v/app-arm64-v8a-release.apk
|
||||||
srclibs:
|
srclibs:
|
||||||
- flutter@stable
|
- flutter@stable
|
||||||
- tesseract4android@4.9.0
|
- tesseract4android@4.9.0
|
||||||
|
|
@ -76,9 +72,9 @@ Builds:
|
||||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||||
- $$flutter$$/bin/flutter config --no-analytics
|
- $$flutter$$/bin/flutter config --no-analytics
|
||||||
- $$flutter$$/bin/flutter pub get --enforce-lockfile
|
- $$flutter$$/bin/flutter pub get --enforce-lockfile
|
||||||
- "sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle"
|
- sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle
|
||||||
- 'echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties'
|
- echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties
|
||||||
- 'echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties'
|
- echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties
|
||||||
- cd apps/app_seeds
|
- cd apps/app_seeds
|
||||||
- $$flutter$$/bin/dart run slang
|
- $$flutter$$/bin/dart run slang
|
||||||
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
|
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
@ -94,12 +90,13 @@ Builds:
|
||||||
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
|
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
|
||||||
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm64
|
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-arm64
|
||||||
|
|
||||||
- versionName: 0.1.15
|
- versionName: 0.1.16
|
||||||
versionCode: 173
|
versionCode: 183
|
||||||
commit: v0.1.15
|
commit: f04dd3da1a3993c5a8f3222544f75b979d2c7f9d
|
||||||
subdir: apps/app_seeds
|
subdir: apps/app_seeds
|
||||||
output: build/app/outputs/flutter-apk/app-x86_64-release.apk
|
output: build/app/outputs/flutter-apk/app-x86_64-release.apk
|
||||||
binary: https://git.comunes.org/comunes/tane/releases/download/v%v/app-x86_64-release.apk
|
binary:
|
||||||
|
https://git.comunes.org/comunes/tane/releases/download/v%v/app-x86_64-release.apk
|
||||||
srclibs:
|
srclibs:
|
||||||
- flutter@stable
|
- flutter@stable
|
||||||
- tesseract4android@4.9.0
|
- tesseract4android@4.9.0
|
||||||
|
|
@ -112,9 +109,9 @@ Builds:
|
||||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||||
- $$flutter$$/bin/flutter config --no-analytics
|
- $$flutter$$/bin/flutter config --no-analytics
|
||||||
- $$flutter$$/bin/flutter pub get --enforce-lockfile
|
- $$flutter$$/bin/flutter pub get --enforce-lockfile
|
||||||
- "sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle"
|
- sed -i '/^buildscript {/,/^}/d' $PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-*/android/build.gradle
|
||||||
- 'echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties'
|
- echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> apps/app_seeds/android/gradle.properties
|
||||||
- 'echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties'
|
- echo "org.gradle.daemon=false" >> apps/app_seeds/android/gradle.properties
|
||||||
- cd apps/app_seeds
|
- cd apps/app_seeds
|
||||||
- $$flutter$$/bin/dart run slang
|
- $$flutter$$/bin/dart run slang
|
||||||
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
|
- $$flutter$$/bin/dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
@ -130,6 +127,16 @@ Builds:
|
||||||
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
|
$PUB_CACHE/hosted/pub.dev/flutter_tesseract_ocr-0.4.31/android/libs/tesseract4android-release.aar
|
||||||
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-x64
|
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=android-x64
|
||||||
|
|
||||||
|
# Reproducible, developer-signed: F-Droid rebuilds each split and, if it matches
|
||||||
|
# our own signed APK on the git.comunes.org release byte-for-byte, publishes OUR
|
||||||
|
# APK (same signature as the Play build). Value is the SHA-256 of the tane-upload
|
||||||
|
# signing certificate (public).
|
||||||
|
# Note: `commit:` must be a full commit hash (not a tag) per F-Droid review, so
|
||||||
|
# it can only be filled in once the tag exists. The hash below still points at
|
||||||
|
# v0.1.15; it is synced (here and in the fdroiddata fork) right after v0.1.16 is
|
||||||
|
# tagged, before the fork's pipeline runs.
|
||||||
|
AllowedAPKSigningKeys: ddfae432091b8248a8a4a1b353487fa626301f4357ef835e94ec312f69418e38
|
||||||
|
|
||||||
AutoUpdateMode: Version
|
AutoUpdateMode: Version
|
||||||
UpdateCheckMode: Tags v[\d.]+
|
UpdateCheckMode: Tags v[\d.]+
|
||||||
VercodeOperation:
|
VercodeOperation:
|
||||||
|
|
@ -138,5 +145,5 @@ VercodeOperation:
|
||||||
- '%c * 10 + 3'
|
- '%c * 10 + 3'
|
||||||
UpdateCheckData:
|
UpdateCheckData:
|
||||||
apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+
|
apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+
|
||||||
CurrentVersion: 0.1.15
|
CurrentVersion: 0.1.16
|
||||||
CurrentVersionCode: 173
|
CurrentVersionCode: 183
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pillars:
|
||||||
body: "Add a seed or seedling with just a photo and a name — no Latin required. Note the year, how much you have, where it came from, germination tests. Search, filter, snap now and name later."
|
body: "Add a seed or seedling with just a photo and a name — no Latin required. Note the year, how much you have, where it came from, germination tests. Search, filter, snap now and name later."
|
||||||
- icon: "🔒"
|
- icon: "🔒"
|
||||||
title: "Yours, and only yours"
|
title: "Yours, and only yours"
|
||||||
body: "Works fully offline. No account, no server, no trackers. Everything is encrypted on your device. Keep an encrypted backup and a printed recovery sheet to restore your whole bank on a new device."
|
body: "Your seed book works with no internet at all, and Tane doesn't connect to anything until you join the sharing part. No account, no sign-up, no trackers. Everything is encrypted on your device. Keep an encrypted backup and a printed recovery sheet to restore your whole bank on a new device."
|
||||||
- icon: "🤝"
|
- icon: "🤝"
|
||||||
title: "Share, as it's always been done"
|
title: "Share, as it's always been done"
|
||||||
body: "Mark what you have spare to give away, swap or sell. Someone nearby finds it and contacts you — no middleman. Print a catalog of what you share to take to a seed fair."
|
body: "Mark what you have spare to give away, swap or sell. Someone nearby finds it and contacts you — no middleman. Print a catalog of what you share to take to a seed fair."
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pillars:
|
||||||
body: "Añade una semilla o plantón con solo una foto y un nombre; sin latín. Anota el año, cuánta tienes, de dónde viene, pruebas de germinación. Busca, filtra, haz la foto ahora y ponle nombre después."
|
body: "Añade una semilla o plantón con solo una foto y un nombre; sin latín. Anota el año, cuánta tienes, de dónde viene, pruebas de germinación. Busca, filtra, haz la foto ahora y ponle nombre después."
|
||||||
- icon: "🔒"
|
- icon: "🔒"
|
||||||
title: "Tuyo, y solo tuyo"
|
title: "Tuyo, y solo tuyo"
|
||||||
body: "Funciona totalmente sin conexión. Sin cuenta, sin servidor, sin rastreadores. Todo va cifrado en tu dispositivo. Guarda una copia cifrada y una hoja de recuperación impresa para restaurar todo tu banco en un dispositivo nuevo."
|
body: "Tu cuaderno de semillas funciona sin nada de internet, y Tane no se conecta a nada hasta que entras en la parte de compartir. Sin cuenta, sin registro, sin rastreadores. Todo va cifrado en tu dispositivo. Guarda una copia cifrada y una hoja de recuperación impresa para restaurar todo tu banco en un dispositivo nuevo."
|
||||||
- icon: "🤝"
|
- icon: "🤝"
|
||||||
title: "Compartir, como se ha hecho siempre"
|
title: "Compartir, como se ha hecho siempre"
|
||||||
body: "Marca lo que te sobra para regalar, intercambiar o vender. Alguien cerca lo encuentra y te contacta, sin intermediarios. Imprime un catálogo de lo que compartes para llevar a una feria de semillas."
|
body: "Marca lo que te sobra para regalar, intercambiar o vender. Alguien cerca lo encuentra y te contacta, sin intermediarios. Imprime un catálogo de lo que compartes para llevar a una feria de semillas."
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
"other": "Where Tane is right now"
|
"other": "Where Tane is right now"
|
||||||
},
|
},
|
||||||
"statusBody": {
|
"statusBody": {
|
||||||
"other": "The inventory works today: keep your seeds offline, encrypted, with no account. The neighbourly sharing layer — offers, messaging and trust — is now arriving, in beta. Everything already works with zero network."
|
"other": "The inventory works today: keep your seeds offline, encrypted, with no account — the app doesn't connect to anything until you join the sharing part. The neighbourly sharing layer — offers, messaging and trust — is now arriving, in beta."
|
||||||
},
|
},
|
||||||
"screenCapHome": {
|
"screenCapHome": {
|
||||||
"other": "Home — your bank and the market, one tap away"
|
"other": "Home — your bank and the market, one tap away"
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
"other": "Do I need an account?"
|
"other": "Do I need an account?"
|
||||||
},
|
},
|
||||||
"faqAccountA": {
|
"faqAccountA": {
|
||||||
"other": "No account, no sign-up, no server. Your data stays on your device."
|
"other": "No account and no sign-up. Your seeds stay on your device, and Tane doesn't connect to anything until you join the sharing part — then it uses community servers you can change or switch off."
|
||||||
},
|
},
|
||||||
"faqOfflineQ": {
|
"faqOfflineQ": {
|
||||||
"other": "Does it work offline?"
|
"other": "Does it work offline?"
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
"other": "En qué punto está Tane"
|
"other": "En qué punto está Tane"
|
||||||
},
|
},
|
||||||
"statusBody": {
|
"statusBody": {
|
||||||
"other": "El inventario ya funciona: guarda tus semillas sin conexión, cifradas y sin cuenta. La capa de intercambio vecinal —ofertas, mensajería y confianza— está llegando ya, en beta. Todo funciona sin ninguna red."
|
"other": "El inventario ya funciona: guarda tus semillas sin conexión, cifradas y sin cuenta; la app no se conecta a nada hasta que entras en la parte de compartir. La capa de intercambio vecinal —ofertas, mensajería y confianza— está llegando ya, en beta."
|
||||||
},
|
},
|
||||||
"screenCapHome": {
|
"screenCapHome": {
|
||||||
"other": "Inicio — tu banco y el mercado a un toque"
|
"other": "Inicio — tu banco y el mercado a un toque"
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
"other": "¿Necesito cuenta?"
|
"other": "¿Necesito cuenta?"
|
||||||
},
|
},
|
||||||
"faqAccountA": {
|
"faqAccountA": {
|
||||||
"other": "Sin cuenta, sin registro, sin servidor. Tus datos se quedan en tu dispositivo."
|
"other": "Sin cuenta y sin registro. Tus semillas se quedan en tu dispositivo, y Tane no se conecta a nada hasta que entras en la parte de compartir; entonces usa servidores comunitarios que puedes cambiar o desactivar."
|
||||||
},
|
},
|
||||||
"faqOfflineQ": {
|
"faqOfflineQ": {
|
||||||
"other": "¿Funciona sin conexión?"
|
"other": "¿Funciona sin conexión?"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue