From fed0e8200e5f49392df4b75df5bb0e00e06b48be Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 25 Jul 2026 16:47:56 +0200 Subject: [PATCH] feat(sharing): make going online opt-in, and show what it unlocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tane dialled its four default relays at launch, before anyone had asked for anything — an F-Droid reviewer spotted it, and they were right. The seed book needs no network at all, so the app should not have one until the person joins the sharing side. - SocialSettings gains a three-state `sharingEnabled`. `null` means "never asked", which is what lets `migrateSharingEnabled` keep an existing install exactly as it was: anyone past the intro was on a build that connected at launch, so they keep messaging, device sync and offer alerts. A fresh install starts fully offline. - bootstrap only starts the shared connection when sharing is on. The inbox/sync/plantaré/alert listeners are untouched: they react to a session, and none arrives. - SharingSwitch is the single place that moves the stored choice, the live connection and the flag the UI listens to, so they cannot drift. - Agreeing to the community rules is the opt-in — one consent surface, reached from the market or from the drawer's invitation. - SocialConnection.start is now idempotent and gains stop(), so turning sharing off goes offline immediately instead of at the next launch. - The social drawer entries stay visible but padlocked while sharing is off; tapping one explains what wakes up and offers to join. Hiding them would have kept the tool a secret. "Coming soon" is gone for good — everything it labelled is built. Covered by tests for the migration in both directions, start/stop lifecycle, the gate turning sharing on, the invitation, and the drawer in all three states (no social layer / off / on). --- apps/app_seeds/lib/app.dart | 19 +- apps/app_seeds/lib/bootstrap.dart | 22 +- apps/app_seeds/lib/i18n/ast.i18n.json | 3 +- apps/app_seeds/lib/i18n/de.i18n.json | 1 - apps/app_seeds/lib/i18n/en.i18n.json | 17 +- apps/app_seeds/lib/i18n/es.i18n.json | 17 +- apps/app_seeds/lib/i18n/fr.i18n.json | 1 - apps/app_seeds/lib/i18n/ja.i18n.json | 1 - apps/app_seeds/lib/i18n/pt.i18n.json | 1 - apps/app_seeds/lib/i18n/pt_BR.i18n.json | 1 - apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_de.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 56 ++++- apps/app_seeds/lib/i18n/strings_es.g.dart | 34 +++- apps/app_seeds/lib/i18n/strings_fr.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ja.g.dart | 2 - apps/app_seeds/lib/i18n/strings_pt.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_pt_BR.g.dart | 4 +- .../lib/services/sharing_switch.dart | 46 +++++ .../lib/services/social_connection.dart | 18 +- .../lib/services/social_settings.dart | 51 ++++- apps/app_seeds/lib/ui/app_drawer.dart | 192 +++++++++++------- apps/app_seeds/lib/ui/home_screen.dart | 61 +++--- apps/app_seeds/lib/ui/market_gate.dart | 27 ++- apps/app_seeds/lib/ui/market_screen.dart | 51 ++++- .../lib/ui/sharing_invite_sheet.dart | 123 +++++++++++ .../test/screenshots/screenshots_test.dart | 2 +- .../test/services/social_connection_test.dart | 66 ++++++ .../test/services/social_settings_test.dart | 30 +++ apps/app_seeds/test/support/test_support.dart | 10 + apps/app_seeds/test/ui/home_screen_test.dart | 67 +++++- apps/app_seeds/test/ui/market_gate_test.dart | 62 +++++- .../test/ui/sharing_invite_sheet_test.dart | 92 +++++++++ .../test/ui/small_screen_overflow_test.dart | 2 +- 35 files changed, 926 insertions(+), 173 deletions(-) create mode 100644 apps/app_seeds/lib/services/sharing_switch.dart create mode 100644 apps/app_seeds/lib/ui/sharing_invite_sheet.dart create mode 100644 apps/app_seeds/test/ui/sharing_invite_sheet_test.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index a6aec7b..d02af0a 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -23,6 +23,7 @@ import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; +import 'services/sharing_switch.dart'; import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; import 'ui/about_screen.dart'; @@ -70,6 +71,7 @@ class TaneApp extends StatelessWidget { this.notifications, this.showIntro = false, this.autoBackup, + SharingSwitch? sharing, super.key, }) : _router = _buildRouter( repository, @@ -87,6 +89,17 @@ class TaneApp extends StatelessWidget { savedSearches, socialAccounts, inbox, + // `bootstrap` passes the real switch, holding the person's stored + // answer. A widget test that doesn't care gets one already on, so + // screens behave as they did before sharing became opt-in. + sharing ?? + (socialSettings == null + ? null + : SharingSwitch( + settings: socialSettings, + connection: connection, + enabled: true, + )), ) { // A tapped message notification opens that peer's chat. Wired here because // the router only exists now; taps only happen while the app is foreground, @@ -161,14 +174,17 @@ class TaneApp extends StatelessWidget { SavedSearchesStore? savedSearches, SocialAccountStore? socialAccounts, InboxService? inbox, + SharingSwitch? sharing, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', routes: [ GoRoute( path: '/', + // A null switch means there is no social layer at all: the market card + // and the social drawer entries aren't drawn. builder: (context, state) => - HomeScreen(marketEnabled: social != null), + HomeScreen(sharing: sharing, onboarding: onboarding), ), if (social != null && socialSettings != null && connection != null) GoRoute( @@ -181,6 +197,7 @@ class TaneApp extends StatelessWidget { outbox: outbox, onboarding: onboarding, savedSearches: savedSearches, + sharing: sharing, ), ), if (social != null && connection != null) diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index e0ecc26..62a4c93 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -23,6 +23,7 @@ import 'services/profile_store.dart'; import 'services/saved_offers_store.dart'; import 'services/saved_search_alert_service.dart'; import 'services/saved_searches_store.dart'; +import 'services/sharing_switch.dart'; import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; @@ -81,14 +82,24 @@ class _BootstrapState extends State { final savedSearchAlerts = getIt.isRegistered() ? getIt() : null; + // Sharing is opt-in: the app must not touch the network until the person + // has joined the sharing side. An install from before this setting keeps + // whatever it had (see `migrateSharingEnabled`) so nobody silently loses + // messaging on upgrade. + final introSeen = await onboarding.introSeen(); + final sharingOn = await getIt().migrateSharingEnabled( + introSeen: introSeen, + ); + // Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the // shared connection starts connecting, so the first session is caught; then - // bring the connection up. + // bring the connection up. The listeners are harmless while sharing is off: + // they only ever react to a session, and none arrives. inbox?.start(); sync?.start(); plantares?.start(); savedSearchAlerts?.start(); - connection?.start(); + if (sharingOn) connection?.start(); return TaneApp( repository: getIt(), @@ -108,7 +119,12 @@ class _BootstrapState extends State { socialAccounts: getIt(), inbox: inbox, notifications: notifications, - showIntro: !await onboarding.introSeen(), + showIntro: !introSeen, + sharing: SharingSwitch( + settings: getIt(), + connection: connection, + enabled: sharingOn, + ), autoBackup: getIt.isRegistered() ? getIt() : null, diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 7b1a054..c7d0454 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -58,8 +58,7 @@ "cancel": "Encaboxar", "delete": "Desaniciar", "edit": "Editar", - "type": "Triba", - "comingSoon": "Aína" + "type": "Triba" }, "home": { "tagline": "Comparte y cultiva simiente llocal", diff --git a/apps/app_seeds/lib/i18n/de.i18n.json b/apps/app_seeds/lib/i18n/de.i18n.json index d6230c7..45889cc 100644 --- a/apps/app_seeds/lib/i18n/de.i18n.json +++ b/apps/app_seeds/lib/i18n/de.i18n.json @@ -59,7 +59,6 @@ "delete": "Löschen", "edit": "Bearbeiten", "type": "Typ", - "comingSoon": "Bald", "offline": "Offline - Teilen ist unterbrochen" }, "home": { diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index e1caf31..4247fd4 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -72,7 +72,6 @@ "delete": "Delete", "edit": "Edit", "type": "Type", - "comingSoon": "Coming soon", "offline": "You're offline — sharing is paused" }, "home": { @@ -572,7 +571,9 @@ "noProfile": "This person hasn't shared a profile yet", "copyId": "Copy code", "idCopied": "Code copied", - "photo": "Photo" + "photo": "Photo", + "sharingOnLabel": "Sharing is on", + "sharingOnHelp": "Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same." }, "profile": { "title": "Your profile", @@ -780,7 +781,8 @@ "publicNote": "What you publish here is public, and copies may remain even if you remove it later.", "viewLegal": "Privacy & rules", "accept": "I agree", - "decline": "Not now" + "decline": "Not now", + "networkNote": "To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn't connect to anything." }, "report": { "offer": "Report this offer", @@ -806,5 +808,14 @@ "manageTitle": "Blocked people", "manageEmpty": "You haven't blocked anyone", "unblock": "Unblock" + }, + "sharingInvite": { + "title": "This wakes up when you start sharing", + "perkChat": "Write to whoever has seeds near you", + "perkFavorites": "Keep the offers you like", + "perkPeople": "Your circle of people you trust", + "networkNote": "For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.", + "start": "Start sharing", + "notNow": "Not now" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 5b1a570..a3a9214 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -72,7 +72,6 @@ "delete": "Eliminar", "edit": "Editar", "type": "Tipo", - "comingSoon": "Pronto", "offline": "Sin conexión — el compartir está en pausa" }, "home": { @@ -571,7 +570,9 @@ "noProfile": "Esta persona aún no ha compartido su perfil", "copyId": "Copiar código", "idCopied": "Código copiado", - "photo": "Foto" + "photo": "Foto", + "sharingOnLabel": "Compartir está activado", + "sharingOnHelp": "Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual." }, "profile": { "title": "Tu perfil", @@ -779,7 +780,8 @@ "publicNote": "Lo que publiques aquí es público, y pueden quedar copias aunque lo retires después.", "viewLegal": "Privacidad y normas", "accept": "Acepto", - "decline": "Ahora no" + "decline": "Ahora no", + "networkNote": "Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada." }, "report": { "offer": "Denunciar esta oferta", @@ -805,5 +807,14 @@ "manageTitle": "Personas bloqueadas", "manageEmpty": "No has bloqueado a nadie", "unblock": "Desbloquear" + }, + "sharingInvite": { + "title": "Esto se enciende cuando empiezas a compartir", + "perkChat": "Escribirte con quien tiene semillas cerca", + "perkFavorites": "Guardar las ofertas que te gustan", + "perkPeople": "Tu círculo de gente de confianza", + "networkNote": "Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.", + "start": "Empezar a compartir", + "notNow": "Ahora no" } } diff --git a/apps/app_seeds/lib/i18n/fr.i18n.json b/apps/app_seeds/lib/i18n/fr.i18n.json index 8b5b4b8..c285006 100644 --- a/apps/app_seeds/lib/i18n/fr.i18n.json +++ b/apps/app_seeds/lib/i18n/fr.i18n.json @@ -59,7 +59,6 @@ "delete": "Supprimer", "edit": "Modifier", "type": "Type", - "comingSoon": "À venir", "offline": "Vous êtes hors ligne — le partage est en pause" }, "home": { diff --git a/apps/app_seeds/lib/i18n/ja.i18n.json b/apps/app_seeds/lib/i18n/ja.i18n.json index 6f07653..42fbff1 100644 --- a/apps/app_seeds/lib/i18n/ja.i18n.json +++ b/apps/app_seeds/lib/i18n/ja.i18n.json @@ -8,7 +8,6 @@ "delete": "削除", "edit": "編集", "type": "種類", - "comingSoon": "近日公開", "offline": "オフラインです — 共有を一時停止しています" }, "menu": { diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 810b0d4..5c93403 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -72,7 +72,6 @@ "delete": "Eliminar", "edit": "Editar", "type": "Tipo", - "comingSoon": "Em breve", "offline": "Sem ligação — a partilha está em pausa" }, "home": { diff --git a/apps/app_seeds/lib/i18n/pt_BR.i18n.json b/apps/app_seeds/lib/i18n/pt_BR.i18n.json index bc4edba..94443df 100644 --- a/apps/app_seeds/lib/i18n/pt_BR.i18n.json +++ b/apps/app_seeds/lib/i18n/pt_BR.i18n.json @@ -72,7 +72,6 @@ "delete": "Eliminar", "edit": "Editar", "type": "Tipo", - "comingSoon": "Em breve", "offline": "Sem ligação — a compartilha está em pausa" }, "home": { diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 0132a97..b28d25d 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 8 -/// Strings: 4287 (535 per locale) +/// Strings: 4299 (537 per locale) /// -/// Built on 2026-07-22 at 11:02 UTC +/// Built on 2026-07-25 at 14:38 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index c3ee9bd..c2f5033 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -199,7 +199,6 @@ class _Translations$common$ast extends Translations$common$en { @override String get delete => 'Desaniciar'; @override String get edit => 'Editar'; @override String get type => 'Triba'; - @override String get comingSoon => 'Aína'; } // Path: home @@ -1465,7 +1464,6 @@ extension on TranslationsAst { 'common.delete' => 'Desaniciar', 'common.edit' => 'Editar', 'common.type' => 'Triba', - 'common.comingSoon' => 'Aína', 'home.tagline' => 'Comparte y cultiva simiente llocal', 'home.openMarket' => 'Mercáu', 'home.openMarketSubtitle' => 'Descubri y comparte simiente cerca', @@ -1930,9 +1928,9 @@ extension on TranslationsAst { 'handover.promiseGave' => 'Van devolveme semiente', 'handover.promiseReceived' => 'Voi devolver semiente', 'sale.title' => 'Ventes', + 'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.', _ => null, } ?? switch (path) { - 'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.', 'sale.add' => 'Rexistrar venta', 'sale.empty' => 'Entá nun hai ventes. Anota equí lo que vendes o merques.', 'sale.iSold' => 'Vendí', diff --git a/apps/app_seeds/lib/i18n/strings_de.g.dart b/apps/app_seeds/lib/i18n/strings_de.g.dart index 20e6a89..22e497e 100644 --- a/apps/app_seeds/lib/i18n/strings_de.g.dart +++ b/apps/app_seeds/lib/i18n/strings_de.g.dart @@ -199,7 +199,6 @@ class _Translations$common$de extends Translations$common$en { @override String get delete => 'Löschen'; @override String get edit => 'Bearbeiten'; @override String get type => 'Typ'; - @override String get comingSoon => 'Bald'; @override String get offline => 'Offline - Teilen ist unterbrochen'; } @@ -1461,7 +1460,6 @@ extension on TranslationsDe { 'common.delete' => 'Löschen', 'common.edit' => 'Bearbeiten', 'common.type' => 'Typ', - 'common.comingSoon' => 'Bald', 'common.offline' => 'Offline - Teilen ist unterbrochen', 'home.tagline' => 'Teile und baue lokale Samen an', 'home.openMarket' => 'Markt', @@ -1926,9 +1924,9 @@ extension on TranslationsDe { 'sale.add' => 'Verkauf notieren', 'sale.empty' => 'Noch keine Verkäufe. Notiere hier, was du verkaufst oder kaufst.', 'sale.iSold' => 'Ich verkaufte', + 'sale.iBought' => 'Ich kaufte', _ => null, } ?? switch (path) { - 'sale.iBought' => 'Ich kaufte', 'sale.direction' => 'Verkauft oder gekauft?', 'sale.counterparty' => 'Mit wem?', 'sale.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index c1d1e8a..f934138 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -93,6 +93,7 @@ class Translations with BaseTranslations { late final Translations$marketGate$en marketGate = Translations$marketGate$en.internal(_root); late final Translations$report$en report = Translations$report$en.internal(_root); late final Translations$block$en block = Translations$block$en.internal(_root); + late final Translations$sharingInvite$en sharingInvite = Translations$sharingInvite$en.internal(_root); } // Path: avatar @@ -340,9 +341,6 @@ class Translations$common$en { /// en: 'Type' String get type => 'Type'; - /// en: 'Coming soon' - String get comingSoon => 'Coming soon'; - /// en: 'You're offline — sharing is paused' String get offline => 'You\'re offline — sharing is paused'; } @@ -1578,6 +1576,12 @@ class Translations$market$en { /// en: 'Photo' String get photo => 'Photo'; + + /// en: 'Sharing is on' + String get sharingOnLabel => 'Sharing is on'; + + /// en: 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.' + String get sharingOnHelp => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.'; } // Path: profile @@ -2241,6 +2245,9 @@ class Translations$marketGate$en { /// en: 'Not now' String get decline => 'Not now'; + + /// en: 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn't connect to anything.' + String get networkNote => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.'; } // Path: report @@ -2324,6 +2331,36 @@ class Translations$block$en { String get unblock => 'Unblock'; } +// Path: sharingInvite +class Translations$sharingInvite$en { + Translations$sharingInvite$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'This wakes up when you start sharing' + String get title => 'This wakes up when you start sharing'; + + /// en: 'Write to whoever has seeds near you' + String get perkChat => 'Write to whoever has seeds near you'; + + /// en: 'Keep the offers you like' + String get perkFavorites => 'Keep the offers you like'; + + /// en: 'Your circle of people you trust' + String get perkPeople => 'Your circle of people you trust'; + + /// en: 'For that, Tane needs to go online. Until you say yes, it doesn't talk to anyone.' + String get networkNote => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.'; + + /// en: 'Start sharing' + String get start => 'Start sharing'; + + /// en: 'Not now' + String get notNow => 'Not now'; +} + // Path: intro.slides class Translations$intro$slides$en { Translations$intro$slides$en.internal(this._root); @@ -2839,7 +2876,6 @@ extension on Translations { 'common.delete' => 'Delete', 'common.edit' => 'Edit', 'common.type' => 'Type', - 'common.comingSoon' => 'Coming soon', 'common.offline' => 'You\'re offline — sharing is paused', 'home.tagline' => 'Share and grow local seeds', 'home.openMarket' => 'Market', @@ -3218,6 +3254,8 @@ extension on Translations { 'market.copyId' => 'Copy code', 'market.idCopied' => 'Code copied', 'market.photo' => 'Photo', + 'market.sharingOnLabel' => 'Sharing is on', + 'market.sharingOnHelp' => 'Turn this off and Tane stops connecting to any server. Your seed book keeps working just the same.', 'profile.title' => 'Your profile', 'profile.name' => 'Display name', 'profile.nameHint' => 'How others see you', @@ -3292,9 +3330,9 @@ extension on Translations { 'plantare.delete' => 'Remove', 'plantare.statusReturned' => 'Returned', 'plantare.statusForgiven' => 'Settled', - 'plantare.openSection' => 'Open', _ => null, } ?? switch (path) { + 'plantare.openSection' => 'Open', 'plantare.settledSection' => 'Done', 'plantare.removeConfirm' => 'Remove this commitment?', 'plantare.returnBy' => ({required Object date}) => 'Return by ${date}', @@ -3402,6 +3440,7 @@ extension on Translations { 'marketGate.viewLegal' => 'Privacy & rules', 'marketGate.accept' => 'I agree', 'marketGate.decline' => 'Not now', + 'marketGate.networkNote' => 'To carry offers and messages between people, Tane needs to go online and leave them on community servers. Until you agree, it doesn\'t connect to anything.', 'report.offer' => 'Report this offer', 'report.person' => 'Report this person', 'report.title' => 'Report', @@ -3423,6 +3462,13 @@ extension on Translations { 'block.manageTitle' => 'Blocked people', 'block.manageEmpty' => 'You haven\'t blocked anyone', 'block.unblock' => 'Unblock', + 'sharingInvite.title' => 'This wakes up when you start sharing', + 'sharingInvite.perkChat' => 'Write to whoever has seeds near you', + 'sharingInvite.perkFavorites' => 'Keep the offers you like', + 'sharingInvite.perkPeople' => 'Your circle of people you trust', + 'sharingInvite.networkNote' => 'For that, Tane needs to go online. Until you say yes, it doesn\'t talk to anyone.', + 'sharingInvite.start' => 'Start sharing', + 'sharingInvite.notNow' => 'Not now', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 3790e39..1d6a9f5 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -92,6 +92,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Eliminar'; @override String get edit => 'Editar'; @override String get type => 'Tipo'; - @override String get comingSoon => 'Pronto'; @override String get offline => 'Sin conexión — el compartir está en pausa'; } @@ -839,6 +839,8 @@ class _Translations$market$es extends Translations$market$en { @override String get copyId => 'Copiar código'; @override String get idCopied => 'Código copiado'; @override String get photo => 'Foto'; + @override String get sharingOnLabel => 'Compartir está activado'; + @override String get sharingOnHelp => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.'; } // Path: profile @@ -1138,6 +1140,7 @@ class _Translations$marketGate$es extends Translations$marketGate$en { @override String get viewLegal => 'Privacidad y normas'; @override String get accept => 'Acepto'; @override String get decline => 'Ahora no'; + @override String get networkNote => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.'; } // Path: report @@ -1179,6 +1182,22 @@ class _Translations$block$es extends Translations$block$en { @override String get unblock => 'Desbloquear'; } +// Path: sharingInvite +class _Translations$sharingInvite$es extends Translations$sharingInvite$en { + _Translations$sharingInvite$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Esto se enciende cuando empiezas a compartir'; + @override String get perkChat => 'Escribirte con quien tiene semillas cerca'; + @override String get perkFavorites => 'Guardar las ofertas que te gustan'; + @override String get perkPeople => 'Tu círculo de gente de confianza'; + @override String get networkNote => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.'; + @override String get start => 'Empezar a compartir'; + @override String get notNow => 'Ahora no'; +} + // Path: intro.slides class _Translations$intro$slides$es extends Translations$intro$slides$en { _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1578,7 +1597,6 @@ extension on TranslationsEs { 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', - 'common.comingSoon' => 'Pronto', 'common.offline' => 'Sin conexión — el compartir está en pausa', 'home.tagline' => 'Comparte y cultiva semillas locales', 'home.openMarket' => 'Mercado', @@ -1956,6 +1974,8 @@ extension on TranslationsEs { 'market.copyId' => 'Copiar código', 'market.idCopied' => 'Código copiado', 'market.photo' => 'Foto', + 'market.sharingOnLabel' => 'Compartir está activado', + 'market.sharingOnHelp' => 'Si lo desactivas, Tane deja de conectarse a ningún servidor. Tu cuaderno de semillas sigue funcionando igual.', 'profile.title' => 'Tu perfil', 'profile.name' => 'Nombre', 'profile.nameHint' => 'Cómo te ven los demás', @@ -2031,9 +2051,9 @@ extension on TranslationsEs { 'plantare.statusReturned' => 'Devuelto', 'plantare.statusForgiven' => 'Saldado', 'plantare.openSection' => 'Pendientes', - 'plantare.settledSection' => 'Hechos', _ => null, } ?? switch (path) { + 'plantare.settledSection' => 'Hechos', 'plantare.removeConfirm' => '¿Quitar este compromiso?', 'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}', 'plantare.overdue' => 'vencido', @@ -2140,6 +2160,7 @@ extension on TranslationsEs { 'marketGate.viewLegal' => 'Privacidad y normas', 'marketGate.accept' => 'Acepto', 'marketGate.decline' => 'Ahora no', + 'marketGate.networkNote' => 'Para llevar las ofertas y los mensajes de unas personas a otras, Tane necesita conectarse y dejarlos en servidores comunitarios. Hasta que aceptes, no se conecta a nada.', 'report.offer' => 'Denunciar esta oferta', 'report.person' => 'Denunciar a esta persona', 'report.title' => 'Denunciar', @@ -2161,6 +2182,13 @@ extension on TranslationsEs { 'block.manageTitle' => 'Personas bloqueadas', 'block.manageEmpty' => 'No has bloqueado a nadie', 'block.unblock' => 'Desbloquear', + 'sharingInvite.title' => 'Esto se enciende cuando empiezas a compartir', + 'sharingInvite.perkChat' => 'Escribirte con quien tiene semillas cerca', + 'sharingInvite.perkFavorites' => 'Guardar las ofertas que te gustan', + 'sharingInvite.perkPeople' => 'Tu círculo de gente de confianza', + 'sharingInvite.networkNote' => 'Para eso Tane necesita conectarse. Hasta que digas que sí, no habla con nadie.', + 'sharingInvite.start' => 'Empezar a compartir', + 'sharingInvite.notNow' => 'Ahora no', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_fr.g.dart b/apps/app_seeds/lib/i18n/strings_fr.g.dart index 9575732..528e2d4 100644 --- a/apps/app_seeds/lib/i18n/strings_fr.g.dart +++ b/apps/app_seeds/lib/i18n/strings_fr.g.dart @@ -199,7 +199,6 @@ class _Translations$common$fr extends Translations$common$en { @override String get delete => 'Supprimer'; @override String get edit => 'Modifier'; @override String get type => 'Type'; - @override String get comingSoon => 'À venir'; @override String get offline => 'Vous êtes hors ligne — le partage est en pause'; } @@ -1461,7 +1460,6 @@ extension on TranslationsFr { 'common.delete' => 'Supprimer', 'common.edit' => 'Modifier', 'common.type' => 'Type', - 'common.comingSoon' => 'À venir', 'common.offline' => 'Vous êtes hors ligne — le partage est en pause', 'home.tagline' => 'Partagez et cultivez des graines locales', 'home.openMarket' => 'Marché', @@ -1926,9 +1924,9 @@ extension on TranslationsFr { 'sale.add' => 'Enregistrer une vente', 'sale.empty' => 'Pas encore de ventes. Notez ici ce que vous vendez ou achetez.', 'sale.iSold' => 'J\'ai vendu', + 'sale.iBought' => 'J\'ai acheté', _ => null, } ?? switch (path) { - 'sale.iBought' => 'J\'ai acheté', 'sale.direction' => 'Vendu ou acheté ?', 'sale.counterparty' => 'Avec qui ?', 'sale.counterpartyHint' => 'Une personne ou un collectif (optionnel)', diff --git a/apps/app_seeds/lib/i18n/strings_ja.g.dart b/apps/app_seeds/lib/i18n/strings_ja.g.dart index df11454..548c863 100644 --- a/apps/app_seeds/lib/i18n/strings_ja.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ja.g.dart @@ -68,7 +68,6 @@ class _Translations$common$ja extends Translations$common$en { @override String get delete => '削除'; @override String get edit => '編集'; @override String get type => '種類'; - @override String get comingSoon => '近日公開'; @override String get offline => 'オフラインです — 共有を一時停止しています'; } @@ -140,7 +139,6 @@ extension on TranslationsJa { 'common.delete' => '削除', 'common.edit' => '編集', 'common.type' => '種類', - 'common.comingSoon' => '近日公開', 'common.offline' => 'オフラインです — 共有を一時停止しています', 'menu.tagline' => 'あなたの種子バンク', 'menu.inventory' => '在庫', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index d4aae83..3973e13 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -222,7 +222,6 @@ class Translations$common$pt extends Translations$common$en { @override String get delete => 'Eliminar'; @override String get edit => 'Editar'; @override String get type => 'Tipo'; - @override String get comingSoon => 'Em breve'; @override String get offline => 'Sem ligação — a partilha está em pausa'; } @@ -1579,7 +1578,6 @@ extension on TranslationsPt { 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', - 'common.comingSoon' => 'Em breve', 'common.offline' => 'Sem ligação — a partilha está em pausa', 'home.tagline' => 'Partilha e cultiva sementes locais', 'home.openMarket' => 'Mercado', @@ -2033,9 +2031,9 @@ extension on TranslationsPt { 'plantare.statusReturned' => 'Devolvido', 'plantare.statusForgiven' => 'Saldado', 'plantare.openSection' => 'Pendentes', + 'plantare.settledSection' => 'Feitos', _ => null, } ?? switch (path) { - 'plantare.settledSection' => 'Feitos', 'plantare.removeConfirm' => 'Remover este compromisso?', 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', 'plantare.overdue' => 'vencido', diff --git a/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart b/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart index 0d51e10..9e965a6 100644 --- a/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt_BR.g.dart @@ -223,7 +223,6 @@ class _Translations$common$pt_BR extends Translations$common$pt { @override String get delete => 'Eliminar'; @override String get edit => 'Editar'; @override String get type => 'Tipo'; - @override String get comingSoon => 'Em breve'; @override String get offline => 'Sem ligação — a compartilha está em pausa'; } @@ -1580,7 +1579,6 @@ extension on TranslationsPtBr { 'common.delete' => 'Eliminar', 'common.edit' => 'Editar', 'common.type' => 'Tipo', - 'common.comingSoon' => 'Em breve', 'common.offline' => 'Sem ligação — a compartilha está em pausa', 'home.tagline' => 'Compartilha e cultiva sementes locais', 'home.openMarket' => 'Mercado', @@ -2034,9 +2032,9 @@ extension on TranslationsPtBr { 'plantare.statusReturned' => 'Devolvido', 'plantare.statusForgiven' => 'Saldado', 'plantare.openSection' => 'Pendentes', + 'plantare.settledSection' => 'Feitos', _ => null, } ?? switch (path) { - 'plantare.settledSection' => 'Feitos', 'plantare.removeConfirm' => 'Remover este compromisso?', 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', 'plantare.overdue' => 'vencido', diff --git a/apps/app_seeds/lib/services/sharing_switch.dart b/apps/app_seeds/lib/services/sharing_switch.dart new file mode 100644 index 0000000..e1fed0b --- /dev/null +++ b/apps/app_seeds/lib/services/sharing_switch.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; + +import 'social_connection.dart'; +import 'social_settings.dart'; + +/// The one place that turns the sharing side of Tane on and off. +/// +/// Sharing is opt-in: until someone joins it, the app opens no connection at +/// all and the seed book is entirely offline. Joining has to move three things +/// at once — the stored choice, the live relay connection, and the flag the UI +/// listens to — and doing that from several screens is how they drift apart. +/// So every caller (the community-rules gate, the invite sheet, the sharing +/// setup) goes through here instead. +class SharingSwitch { + SharingSwitch({ + required SocialSettings settings, + SocialConnection? connection, + bool enabled = false, + }) : _settings = settings, + _connection = connection, + on = ValueNotifier(enabled); + + final SocialSettings _settings; + final SocialConnection? _connection; + + /// Whether sharing is on right now. Screens listen so the drawer's social + /// entries light up the moment someone joins, with no restart. + final ValueNotifier on; + + Future enable() async { + await _settings.setSharingEnabled(true); + // Safe to call even if it is already running: `start` guards itself. + _connection?.start(); + on.value = true; + } + + /// Turning it off must actually go offline — closing the live session, not + /// just recording the choice for the next launch. + Future disable() async { + await _settings.setSharingEnabled(false); + await _connection?.stop(); + on.value = false; + } + + void dispose() => on.dispose(); +} diff --git a/apps/app_seeds/lib/services/social_connection.dart b/apps/app_seeds/lib/services/social_connection.dart index 332621c..80bd091 100644 --- a/apps/app_seeds/lib/services/social_connection.dart +++ b/apps/app_seeds/lib/services/social_connection.dart @@ -63,8 +63,11 @@ class SocialConnection { SocialSession? get current => _current; /// Begins watching connectivity (reconnect on regain, drop when offline) and - /// attempts an initial connect. Idempotent-ish; call once at startup. + /// attempts an initial connect. Called at startup when sharing is already on, + /// and again the moment someone joins the sharing side — hence the guard, so + /// a second call never stacks a second connectivity subscription. void start() { + if (_started || _disposed) return; _started = true; _onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) { _knownOffline = !isOnline; @@ -139,6 +142,19 @@ class SocialConnection { } } + /// Goes offline for good until [start] is called again: stops watching + /// connectivity, cancels any pending retry and tears the live session down. + /// This is what "turn sharing off" must do — before it existed, clearing the + /// server list only took effect on the next launch, because the already-open + /// session was never closed. Unlike [dispose] the object stays usable. + Future stop() async { + _started = false; + _cancelRetry(); + await _onlineSub?.cancel(); + _onlineSub = null; + _drop(); + } + Future dispose() async { _disposed = true; _cancelRetry(); diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart index 8dbe023..75917cc 100644 --- a/apps/app_seeds/lib/services/social_settings.dart +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -5,9 +5,11 @@ import '../security/secret_store.dart'; /// keystore (via [SecretStore]) to honour "no plaintext at rest" — no /// shared_preferences. /// -/// Relays default to a small set of well-known public servers so the market -/// works out of the box; the exposure is minimal (offers are opt-in and carry -/// only a coarse geohash) and the user can swap them for a community server. +/// Sharing is off until the person joins it ([sharingEnabled]); until then the +/// app opens no connection at all. Once they do, relays default to a small set +/// of well-known public servers so the market works out of the box; the exposure +/// is minimal (offers are opt-in and carry only a coarse geohash) and the user +/// can swap them for a community server or turn them all off. /// The area stays unset until the user picks one (it's inherently personal). class SocialSettings { SocialSettings(this._store); @@ -16,6 +18,7 @@ class SocialSettings { static const _areaKey = 'tane.social.area_geohash'; static const _relaysKey = 'tane.social.relays'; + static const _sharingKey = 'tane.social.sharing_enabled'; static const _searchPrecisionKey = 'tane.social.search_precision'; static const _blockedKey = 'tane.social.blocked_pubkeys'; static const _hiddenOffersKey = 'tane.social.hidden_offers'; @@ -29,11 +32,11 @@ class SocialSettings { static const int maxSearchPrecision = 5; static const int defaultSearchPrecision = 4; - /// Community servers used automatically so sharing works from the first - /// launch. The relay pool skips any that are unreachable, so a dead one never - /// breaks the market; the user never has to know these exist. The Comunes - /// relay comes first as the reliable, non-commercial home; the public ones - /// are backup. + /// Community servers used automatically once the person joins the sharing + /// side, so the market works without any setup. The relay pool skips any that + /// are unreachable, so a dead one never breaks the market; the user never has + /// to know these exist. The Comunes relay comes first as the reliable, + /// non-commercial home; the public ones are backup. static const List defaultRelays = [ 'wss://relay.comunes.org', 'wss://nos.lol', @@ -62,6 +65,38 @@ class SocialSettings { urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'), ); + /// Whether the person has said yes to the sharing side of the app. Until they + /// do, Tane opens no connection at all — the seed book is entirely offline. + /// + /// Three states on purpose: `null` means "never asked", which is what lets an + /// install that predates this setting keep working exactly as before (see + /// `migrateSharingEnabled`). Once written it is a plain yes/no the person + /// controls from the sharing setup. + Future sharingEnabled() async { + final raw = await _store.read(_sharingKey); + if (raw == null) return null; + return raw == '1'; + } + + Future setSharingEnabled(bool enabled) => + _store.write(_sharingKey, enabled ? '1' : '0'); + + /// Decides, once, what an install that predates the setting should get, and + /// records it. Anyone who had already been through the intro was on a build + /// that connected at launch, so they keep sharing on and lose nothing — + /// messages, device sync and offer alerts keep arriving. A fresh install has + /// not seen the intro yet, so it starts fully offline and only goes online + /// when the person joins the sharing side. + /// + /// Returns the effective value. Safe to call on every launch: it writes only + /// when nothing has been recorded yet. + Future migrateSharingEnabled({required bool introSeen}) async { + final stored = await sharingEnabled(); + if (stored != null) return stored; + await setSharingEnabled(introSeen); + return introSeen; + } + /// How wide to search — a geohash prefix length in [minSearchPrecision, /// maxSearchPrecision]. Defaults (and falls back on any garbage) to /// [defaultSearchPrecision]. diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 28d9264..df79f87 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -3,24 +3,66 @@ import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'seed_glyph.dart'; +import 'sharing_invite_sheet.dart'; import 'theme.dart'; import 'unread_badge.dart'; /// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is -/// the live destination (green seed glyph), the social items (market, profile, -/// chat…) belong to Block 2 and are greyed with a "soon" tag, and Settings sits -/// pinned at the bottom. +/// the live destination (green seed glyph), the social items sit below a divider, +/// and Settings is pinned at the bottom. +/// +/// While sharing is off the social items stay visible but quiet, with a small +/// padlock: tapping one explains what it does and offers to join. The Market is +/// the exception — it is always live, because it is the door people go through +/// to join in the first place. class AppDrawer extends StatelessWidget { - const AppDrawer({this.marketEnabled = false, super.key}); + const AppDrawer({this.sharing, this.onboarding, super.key}); - /// When the Block 2 social layer is wired, the Market becomes a live drawer - /// destination; other social items stay "soon" until they're built. - final bool marketEnabled; + /// The sharing on/off switch. Null when the social layer isn't there at all + /// (identity derivation failed) — then the social items are not drawn, since + /// there is nothing the person could do to turn them on. + final SharingSwitch? sharing; + + /// Needed to run the community-rules step when someone accepts the invite. + final OnboardingStore? onboarding; @override Widget build(BuildContext context) { + final sharing = this.sharing; + if (sharing == null) return _build(context, social: false, sharingOn: false); + return ValueListenableBuilder( + valueListenable: sharing.on, + builder: (context, on, _) => + _build(context, social: true, sharingOn: on), + ); + } + + Widget _build( + BuildContext context, { + required bool social, + required bool sharingOn, + }) { final t = context.t; + // While sharing is off, tapping a social entry invites the person in rather + // than doing nothing. + Future invite() async { + final sharing = this.sharing; + final onboarding = this.onboarding; + if (sharing == null || onboarding == null) return; + // Close the drawer first, then run the sheet off the navigator's own + // context — this one dies with the drawer. + final navigator = Navigator.of(context); + navigator.pop(); + await showSharingInvite( + navigator.context, + onboarding: onboarding, + sharing: sharing, + ); + } + return Drawer( child: SafeArea( child: Column( @@ -69,59 +111,68 @@ class AppDrawer extends StatelessWidget { context.push('/calendar'); }, ), - _DrawerItem( - icon: const Icon(Symbols.storefront), - label: t.menu.market, - divider: true, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/market'); - } - : null, - ), - _DrawerItem( - icon: const Icon(Icons.person), - label: t.menu.profile, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/profile'); - } - : null, - ), - _DrawerItem( - icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), - label: t.menu.chat, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/messages'); - } - : null, - ), - _DrawerItem( - icon: const Icon(Icons.favorite), - label: t.menu.wishlist, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/favorites'); - } - : null, - ), - // The ego-centric web of trust ("your people") — live once the - // social layer is on. - _DrawerItem( - icon: const Icon(Icons.group), - label: t.menu.following, - onTap: marketEnabled - ? () { - Navigator.of(context).pop(); - context.push('/your-people'); - } - : null, - ), + // The market is always live when the social layer exists: it + // is where people join sharing, so locking it would lock the + // only door. + if (social) + _DrawerItem( + icon: const Icon(Symbols.storefront), + label: t.menu.market, + divider: true, + onTap: () { + Navigator.of(context).pop(); + context.push('/market'); + }, + ), + if (social) + _DrawerItem( + icon: const Icon(Icons.person), + label: t.menu.profile, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/profile'); + } + : invite, + ), + if (social) + _DrawerItem( + icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), + label: t.menu.chat, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/messages'); + } + : invite, + ), + if (social) + _DrawerItem( + icon: const Icon(Icons.favorite), + label: t.menu.wishlist, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/favorites'); + } + : invite, + ), + // The ego-centric web of trust ("your people"). + if (social) + _DrawerItem( + icon: const Icon(Icons.group), + label: t.menu.following, + locked: !sharingOn, + onTap: sharingOn + ? () { + Navigator.of(context).pop(); + context.push('/your-people'); + } + : invite, + ), ], ), ), @@ -203,6 +254,7 @@ class _DrawerItem extends StatelessWidget { required this.label, this.onTap, this.divider = false, + this.locked = false, }); final Widget icon; @@ -210,9 +262,13 @@ class _DrawerItem extends StatelessWidget { final VoidCallback? onTap; final bool divider; + /// Drawn quiet, with a padlock, but still tappable — it leads to the + /// invitation to join sharing rather than to the destination itself. + final bool locked; + @override Widget build(BuildContext context) { - final enabled = onTap != null; + final enabled = onTap != null && !locked; final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F); final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F); final row = InkWell( @@ -247,15 +303,11 @@ class _DrawerItem extends StatelessWidget { ), ), ), - if (!enabled) - Text( - context.t.common.comingSoon.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB3BDA8), - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), + if (locked) + const Icon( + Icons.lock_outline, + size: 16, + color: Color(0xFFB3BDA8), ), ], ), diff --git a/apps/app_seeds/lib/ui/home_screen.dart b/apps/app_seeds/lib/ui/home_screen.dart index adf4aba..c13c7dc 100644 --- a/apps/app_seeds/lib/ui/home_screen.dart +++ b/apps/app_seeds/lib/ui/home_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'app_drawer.dart'; import 'seed_glyph.dart'; import 'theme.dart'; @@ -9,14 +11,19 @@ import 'unread_badge.dart'; /// The main menu (redesign screen 00): a sprout logo in a soft green disc over a /// faint seed-glyph watermark, with "Your inventory" as the primary green call -/// to action and "Open market" (Block 2) as a disabled outlined card. The -/// hamburger opens [AppDrawer]. +/// to action and "Open market" as an outlined card below it. The hamburger opens +/// [AppDrawer]. +/// +/// The market card is always live when the social layer exists — it is the door +/// people go through to join sharing — and simply isn't drawn when it doesn't. class HomeScreen extends StatelessWidget { - const HomeScreen({this.marketEnabled = false, super.key}); + const HomeScreen({this.sharing, this.onboarding, super.key}); - /// When the Block 2 social layer is wired, the market becomes a live - /// destination; otherwise it stays a disabled "coming soon" card. - final bool marketEnabled; + /// The sharing on/off switch, or null when there is no social layer at all. + final SharingSwitch? sharing; + + /// Needed to run the community-rules step from the drawer's invitation. + final OnboardingStore? onboarding; @override Widget build(BuildContext context) { @@ -34,7 +41,7 @@ class HomeScreen extends StatelessWidget { ), ), ), - drawer: AppDrawer(marketEnabled: marketEnabled), + drawer: AppDrawer(sharing: sharing, onboarding: onboarding), body: Stack( children: [ const Positioned.fill(child: _SeedWatermark()), @@ -99,17 +106,16 @@ class HomeScreen extends StatelessWidget { subtitle: t.home.yourInventorySubtitle, onTap: () => context.push('/inventory'), ), - const SizedBox(height: 16), - _OutlinedMenuCard( - key: const Key('home.market'), - icon: Icons.storefront_outlined, - label: t.home.openMarket, - subtitle: t.home.openMarketSubtitle, - tag: marketEnabled ? null : t.common.comingSoon, - onTap: marketEnabled - ? () => context.push('/market') - : null, - ), + if (sharing != null) ...[ + const SizedBox(height: 16), + _OutlinedMenuCard( + key: const Key('home.market'), + icon: Icons.storefront_outlined, + label: t.home.openMarket, + subtitle: t.home.openMarketSubtitle, + onTap: () => context.push('/market'), + ), + ], ], ), ), @@ -175,14 +181,12 @@ class _PrimaryMenuCard extends StatelessWidget { } } -/// A disabled Block-2 destination: an outlined white card with a green-disc -/// icon and a "soon" tag. +/// A secondary destination: an outlined white card with a green-disc icon. class _OutlinedMenuCard extends StatelessWidget { const _OutlinedMenuCard({ required this.icon, required this.label, required this.subtitle, - this.tag, this.onTap, super.key, }); @@ -191,9 +195,6 @@ class _OutlinedMenuCard extends StatelessWidget { final String label; final String subtitle; - /// A small trailing tag (e.g. "coming soon"); omitted for live cards. - final String? tag; - /// When set, the card is tappable; otherwise it reads as disabled. final VoidCallback? onTap; @@ -221,17 +222,7 @@ class _OutlinedMenuCard extends StatelessWidget { subtitleColor: seedMuted, ), ), - if (tag != null) - Text( - tag!.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB3BDA8), - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), - ) - else if (onTap != null) + if (onTap != null) const Icon(Icons.chevron_right, color: seedMuted), ], ), diff --git a/apps/app_seeds/lib/ui/market_gate.dart b/apps/app_seeds/lib/ui/market_gate.dart index b43edb1..c6eb383 100644 --- a/apps/app_seeds/lib/ui/market_gate.dart +++ b/apps/app_seeds/lib/ui/market_gate.dart @@ -3,17 +3,29 @@ import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; import 'theme.dart'; /// Makes sure the community rules have been accepted once before the user /// joins the market or publishes anything. Returns true when the rules are /// (or become) accepted; false when the user declines. Play/App Store UGC /// policies require this acceptance before content can be created. +/// +/// This is also where Tane goes online for the first time: agreeing here is the +/// opt-in that turns [sharing] on. Keeping both in one step means there is a +/// single moment where someone says yes, and it is a moment that explains +/// itself — rather than a connection that happened at launch without asking. Future ensureMarketRulesAccepted( BuildContext context, - OnboardingStore store, -) async { - if (await store.marketRulesAccepted()) return true; + OnboardingStore store, { + SharingSwitch? sharing, +}) async { + if (await store.marketRulesAccepted()) { + // Already agreed, but sharing may still be off (they turned it off in the + // sharing setup, or agreed on a build that had no switch): honour the ask. + if (sharing != null && !sharing.on.value) await sharing.enable(); + return true; + } if (!context.mounted) return false; final accepted = await showModalBottomSheet( context: context, @@ -24,6 +36,7 @@ Future ensureMarketRulesAccepted( ); if (accepted == true) { await store.markMarketRulesAccepted(); + await sharing?.enable(); return true; } return false; @@ -77,6 +90,14 @@ class MarketGateSheet extends StatelessWidget { height: 1.4, ), ), + const SizedBox(height: 8), + Text( + t.marketGate.networkNote, + style: theme.textTheme.bodySmall?.copyWith( + color: seedMuted, + height: 1.4, + ), + ), TextButton.icon( style: TextButton.styleFrom( padding: EdgeInsets.zero, diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index b172ca2..d0a77a0 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -11,6 +11,7 @@ import '../services/offer_outbox.dart'; import '../services/saved_searches_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; +import '../services/sharing_switch.dart'; import '../services/social_settings.dart'; import '../services/onboarding_store.dart'; import '../state/offers_cubit.dart'; @@ -35,6 +36,7 @@ class MarketScreen extends StatefulWidget { this.onboarding, this.savedSearches, this.initialSearch, + this.sharing, super.key, }); @@ -54,6 +56,10 @@ class MarketScreen extends StatefulWidget { /// created). Null in tests → no gate. final OnboardingStore? onboarding; + /// The sharing on/off switch. Accepting the rules turns it on (that is the + /// moment Tane first goes online); the sharing setup can turn it back off. + final SharingSwitch? sharing; + /// The shared relay connection (one per identity), reused for discovery. final SocialConnection connection; @@ -84,11 +90,18 @@ class _MarketScreenState extends State { /// network; declining leaves the market. Future _start() async { final store = widget.onboarding; - if (store != null && !await store.marketRulesAccepted()) { + final sharing = widget.sharing; + // The rules step is also the moment Tane first goes online, so it runs + // whenever sharing is still off — even for someone who agreed long ago and + // later switched sharing back off. + if (store != null && + (!await store.marketRulesAccepted() || + (sharing != null && !sharing.on.value))) { // Wait for the first frame so the sheet has a surface to attach to. await WidgetsBinding.instance.endOfFrame; if (!mounted) return; - final ok = await ensureMarketRulesAccepted(context, store); + final ok = await ensureMarketRulesAccepted(context, store, + sharing: sharing); if (!ok) { if (mounted) context.pop(); return; @@ -156,8 +169,11 @@ class _MarketScreenState extends State { final changed = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => - _ConfigSheet(settings: widget.settings, location: widget.location), + builder: (_) => _ConfigSheet( + settings: widget.settings, + location: widget.location, + sharing: widget.sharing, + ), ); if (changed == true) await _init(); } @@ -786,10 +802,11 @@ class _EmptyState extends StatelessWidget { /// Coarse-area + community-server setup. Kept behind progressive disclosure — a /// power-user surface — with human-worded labels. class _ConfigSheet extends StatefulWidget { - const _ConfigSheet({required this.settings, this.location}); + const _ConfigSheet({required this.settings, this.location, this.sharing}); final SocialSettings settings; final CoarseLocationProvider? location; + final SharingSwitch? sharing; @override State<_ConfigSheet> createState() => _ConfigSheetState(); @@ -1067,6 +1084,30 @@ class _ConfigSheetState extends State<_ConfigSheet> { ), ), children: [ + // The master switch: turning it off takes Tane fully + // offline right away, not at the next launch. + if (widget.sharing != null) + ValueListenableBuilder( + valueListenable: widget.sharing!.on, + builder: (context, on, _) => SwitchListTile( + key: const Key('market.sharingOn'), + contentPadding: EdgeInsets.zero, + dense: true, + value: on, + title: Text(t.market.sharingOnLabel), + subtitle: Text( + t.market.sharingOnHelp, + style: const TextStyle( + fontSize: 12, + color: seedMuted, + height: 1.3, + ), + ), + onChanged: (want) => want + ? widget.sharing!.enable() + : widget.sharing!.disable(), + ), + ), TextField( key: const Key('market.area'), controller: _area, diff --git a/apps/app_seeds/lib/ui/sharing_invite_sheet.dart b/apps/app_seeds/lib/ui/sharing_invite_sheet.dart new file mode 100644 index 0000000..5cbe32b --- /dev/null +++ b/apps/app_seeds/lib/ui/sharing_invite_sheet.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import '../services/onboarding_store.dart'; +import '../services/sharing_switch.dart'; +import 'market_gate.dart'; +import 'theme.dart'; + +/// Shown when someone taps a social entry (chat, profile, favourites, your +/// people) while sharing is still off. +/// +/// The alternative was hiding those entries until sharing is on, but then +/// nobody would ever discover that Tane does any of it. So they stay in the +/// drawer, quiet, and tapping one explains what they are and offers to switch +/// them on — with the community rules, which is the same single consent step +/// the market uses. Returns true when sharing ended up on. +Future showSharingInvite( + BuildContext context, { + required OnboardingStore onboarding, + required SharingSwitch sharing, +}) async { + final wants = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const SharingInviteSheet(), + ); + if (wants != true || !context.mounted) return false; + return ensureMarketRulesAccepted(context, onboarding, sharing: sharing); +} + +/// The invitation itself: what lights up when you join, and the plain fact that +/// it needs a connection. +class SharingInviteSheet extends StatelessWidget { + const SharingInviteSheet({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + final theme = Theme.of(context); + return SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 24, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.sharingInvite.title, + style: theme.textTheme.titleLarge?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + _Perk(Icons.chat_bubble_outline, t.sharingInvite.perkChat), + _Perk(Icons.favorite_border, t.sharingInvite.perkFavorites), + _Perk(Icons.group_outlined, t.sharingInvite.perkPeople), + const SizedBox(height: 12), + Text( + t.sharingInvite.networkNote, + style: theme.textTheme.bodySmall?.copyWith( + color: seedMuted, + height: 1.4, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + key: const Key('sharingInvite.notNow'), + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.sharingInvite.notNow), + ), + const SizedBox(width: 8), + FilledButton( + key: const Key('sharingInvite.start'), + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.sharingInvite.start), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _Perk extends StatelessWidget { + const _Perk(this.icon, this.text); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: seedGreen), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: seedOnSurface, + height: 1.35, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/test/screenshots/screenshots_test.dart b/apps/app_seeds/test/screenshots/screenshots_test.dart index 7589506..edfe643 100644 --- a/apps/app_seeds/test/screenshots/screenshots_test.dart +++ b/apps/app_seeds/test/screenshots/screenshots_test.dart @@ -105,7 +105,7 @@ void main() { seeded = await seedShowcase(db, repo, locale); }); final child = switch (name) { - 'home' => const HomeScreen(marketEnabled: true), + 'home' => HomeScreen(sharing: newTestSharingSwitch()), 'inventory' => const InventoryListScreen(), 'market' => marketWidget(locale), 'calendar' => const CalendarScreen(initialMonth: 4), diff --git a/apps/app_seeds/test/services/social_connection_test.dart b/apps/app_seeds/test/services/social_connection_test.dart index 7b856de..4618691 100644 --- a/apps/app_seeds/test/services/social_connection_test.dart +++ b/apps/app_seeds/test/services/social_connection_test.dart @@ -181,4 +181,70 @@ void main() { expect(await conn.session(), isNotNull); // succeeds on retry await conn.dispose(); }); + + test('start is idempotent: a second call adds no second connect', () async { + // Joining sharing calls start() while bootstrap may already have, so a + // repeat must not stack another connectivity subscription or dial again. + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + + conn.start(); + await conn.session(); + conn.start(); + await Future.delayed(Duration.zero); + expect(opened, hasLength(1)); + + // One subscription, so one drop — not two competing reactions. + online.add(false); + await Future.delayed(Duration.zero); + expect(conn.current, isNull); + expect(opened.first.closed, isTrue); + + await conn.dispose(); + await online.close(); + }); + + test('stop goes offline now, not at the next launch', () async { + // Turning sharing off used to leave the live session open until restart. + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + final emitted = []; + conn.sessions.listen(emitted.add); + + conn.start(); + expect(await conn.session(), isNotNull); + + await conn.stop(); + await Future.delayed(Duration.zero); // let the drop be announced + expect(conn.current, isNull); + expect(opened.single.closed, isTrue); + expect(emitted.last, isNull); + + // And it stays off: a connectivity event must not resurrect it. + online.add(true); + await Future.delayed(Duration.zero); + expect(conn.current, isNull); + expect(opened, hasLength(1)); + + await conn.dispose(); + await online.close(); + }); + + test('stop leaves the connection usable: start brings it back', () async { + final opened = []; + final online = StreamController.broadcast(); + final conn = make(opened: opened, online: online.stream); + conn.start(); + await conn.session(); + await conn.stop(); + + conn.start(); + await Future.delayed(Duration.zero); + expect(conn.current, isNotNull); + expect(opened, hasLength(2)); + await conn.dispose(); + await online.close(); + }); } diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart index c3abe43..02577dd 100644 --- a/apps/app_seeds/test/services/social_settings_test.dart +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -101,4 +101,34 @@ void main() { {SocialSettings.offerKey('ab' * 32, 'tomate-1')}, ); }); + + group('sharing opt-in', () { + test('starts unanswered, then round-trips', () async { + expect(await settings.sharingEnabled(), isNull); + await settings.setSharingEnabled(true); + expect(await settings.sharingEnabled(), isTrue); + await settings.setSharingEnabled(false); + expect(await settings.sharingEnabled(), isFalse); + }); + + test('an install that had been through the intro keeps sharing on', + () async { + // The upgrade path that must not regress: these people were on a build + // that connected at launch, so they keep messaging, sync and alerts. + expect(await settings.migrateSharingEnabled(introSeen: true), isTrue); + expect(await settings.sharingEnabled(), isTrue); + }); + + test('a fresh install starts offline', () async { + expect(await settings.migrateSharingEnabled(introSeen: false), isFalse); + expect(await settings.sharingEnabled(), isFalse); + }); + + test('migration never overwrites an answer already given', () async { + await settings.setSharingEnabled(false); + // Later launches see the intro as seen; the recorded "no" must survive. + expect(await settings.migrateSharingEnabled(introSeen: true), isFalse); + expect(await settings.sharingEnabled(), isFalse); + }); + }); } diff --git a/apps/app_seeds/test/support/test_support.dart b/apps/app_seeds/test/support/test_support.dart index ba39a2f..eefb065 100644 --- a/apps/app_seeds/test/support/test_support.dart +++ b/apps/app_seeds/test/support/test_support.dart @@ -11,6 +11,8 @@ import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/security/secret_store.dart'; import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/services/sharing_switch.dart'; +import 'package:tane/services/social_settings.dart'; import 'package:tane/app.dart' show materialLocaleFor; import 'package:tane/state/inventory_cubit.dart'; import 'package:tane/state/variety_detail_cubit.dart'; @@ -59,6 +61,14 @@ OnboardingStore newTestOnboardingStore({bool introSeen = true}) { return OnboardingStore(store); } +/// A [SharingSwitch] over in-memory storage and no relay connection, for +/// screens that only care whether sharing is on. Defaults to on, which is what +/// most screen tests want (the social entries live, nothing dialled). +SharingSwitch newTestSharingSwitch({bool enabled = true}) => SharingSwitch( + settings: SocialSettings(InMemorySecretStore()), + enabled: enabled, + ); + /// Wraps [child] with the providers a screen expects (repository, inventory /// cubit) plus i18n and Material localizations, pinned to [locale]. Widget wrapScreen({ diff --git a/apps/app_seeds/test/ui/home_screen_test.dart b/apps/app_seeds/test/ui/home_screen_test.dart index 1ca72ec..76b84be 100644 --- a/apps/app_seeds/test/ui/home_screen_test.dart +++ b/apps/app_seeds/test/ui/home_screen_test.dart @@ -2,15 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/app.dart'; import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/sharing_switch.dart'; import '../support/test_support.dart'; void main() { - Widget app(db) => TranslationProvider( + /// The everyday case: the social layer exists and sharing is already on. + Widget app(db, {SharingSwitch? sharing}) => TranslationProvider( child: TaneApp( repository: newTestRepository(db), species: newTestSpeciesRepository(db), onboarding: newTestOnboardingStore(), + sharing: sharing ?? newTestSharingSwitch(), ), ); @@ -24,7 +27,8 @@ void main() { expect(find.text('Your inventory'), findsOneWidget); expect(find.text('Market'), findsOneWidget); - expect(find.text('COMING SOON'), findsOneWidget); // market is Block 2 + // "Coming soon" is gone for good: everything on this screen is built. + expect(find.textContaining('COMING SOON'), findsNothing); // Redesign copy: tagline + the two destination subtitles. expect(find.text('Share and grow local seeds'), findsOneWidget); @@ -49,8 +53,9 @@ void main() { await tester.tap(find.byIcon(Icons.menu)); // hamburger await tester.pumpAndSettle(); - // Social destinations are shown but disabled. + // With sharing on, the social destinations are live. expect(find.text('Your profile'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsNothing); await tester.tap(find.text('Inventory')); // active drawer item await tester.pumpAndSettle(); @@ -84,6 +89,62 @@ void main() { await disposeTree(tester); }); + testWidgets('sharing off: market stays open, the rest wears a padlock', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget( + app(db, sharing: newTestSharingSwitch(enabled: false)), + ); + await tester.pumpAndSettle(); + + // The market card is the door into sharing, so it must stay reachable. + expect(find.byKey(const Key('home.market')), findsOneWidget); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + + // Market live; the social entries are visible but padlocked — and tapping + // one invites the person in rather than doing nothing. + expect(find.text('Your profile'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsWidgets); + + await tester.tap(find.text('Your profile')); + await tester.pumpAndSettle(); + expect(find.text('This wakes up when you start sharing'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('no social layer: the market and its friends are not drawn', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget( + TranslationProvider( + child: TaneApp( + repository: newTestRepository(db), + species: newTestSpeciesRepository(db), + onboarding: newTestOnboardingStore(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Nothing here can be switched on, so it is hidden rather than teased. + expect(find.byKey(const Key('home.market')), findsNothing); + expect(find.text('Your inventory'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + expect(find.text('Your profile'), findsNothing); + expect(find.byIcon(Icons.lock_outline), findsNothing); + await disposeTree(tester); + }); + testWidgets('drawer Settings opens the settings screen', (tester) async { diff --git a/apps/app_seeds/test/ui/market_gate_test.dart b/apps/app_seeds/test/ui/market_gate_test.dart index 7cce096..6a74331 100644 --- a/apps/app_seeds/test/ui/market_gate_test.dart +++ b/apps/app_seeds/test/ui/market_gate_test.dart @@ -3,12 +3,17 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/services/sharing_switch.dart'; import 'package:tane/ui/market_gate.dart'; import '../support/test_support.dart'; void main() { - Widget host(OnboardingStore store, void Function(bool) onResult) => + Widget host( + OnboardingStore store, + void Function(bool) onResult, { + SharingSwitch? sharing, + }) => TranslationProvider( child: MaterialApp( localizationsDelegates: GlobalMaterialLocalizations.delegates, @@ -16,7 +21,13 @@ void main() { builder: (context) => Center( child: ElevatedButton( onPressed: () async { - onResult(await ensureMarketRulesAccepted(context, store)); + onResult( + await ensureMarketRulesAccepted( + context, + store, + sharing: sharing, + ), + ); }, child: const Text('enter'), ), @@ -87,5 +98,52 @@ void main() { expect(find.text('Treat people well — no spam, no abuse'), findsOneWidget); expect(find.textContaining('public'), findsWidgets); expect(find.text('Privacy & rules'), findsOneWidget); + // The sheet is also where Tane says it is about to go online for the first + // time — the reviewer's complaint was that this was never stated. + expect(find.textContaining('community servers'), findsOneWidget); + }); + + testWidgets('agreeing is what turns sharing on', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + expect(sharing.on.value, isFalse, reason: 'still offline while asking'); + + await tester.tap(find.text('I agree')); + await tester.pumpAndSettle(); + expect(sharing.on.value, isTrue); + }); + + testWidgets('declining leaves the app offline', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + + expect(sharing.on.value, isFalse); + }); + + testWidgets('someone who agreed long ago but switched sharing off is asked ' + 'nothing, yet comes back online', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + await store.markMarketRulesAccepted(); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, (_) {}, sharing: sharing)); + await tester.tap(find.text('enter')); + await tester.pumpAndSettle(); + + expect(find.text('Before you join the market'), findsNothing); + expect(sharing.on.value, isTrue); }); } diff --git a/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart b/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart new file mode 100644 index 0000000..d8b61ee --- /dev/null +++ b/apps/app_seeds/test/ui/sharing_invite_sheet_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/onboarding_store.dart'; +import 'package:tane/services/sharing_switch.dart'; +import 'package:tane/ui/sharing_invite_sheet.dart'; + +import '../support/test_support.dart'; + +void main() { + Widget host(OnboardingStore store, SharingSwitch sharing) => + TranslationProvider( + child: MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => showSharingInvite( + context, + onboarding: store, + sharing: sharing, + ), + child: const Text('tap a locked entry'), + ), + ), + ), + ), + ); + + testWidgets('the invite says what lights up and that it needs a connection', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget( + TranslationProvider( + child: const MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Scaffold(body: SharingInviteSheet()), + ), + ), + ); + await tester.pump(); + + expect(find.text('This wakes up when you start sharing'), findsOneWidget); + expect(find.text('Write to whoever has seeds near you'), findsOneWidget); + expect(find.text('Keep the offers you like'), findsOneWidget); + expect(find.text('Your circle of people you trust'), findsOneWidget); + // The honest part: it is not free of consequences. + expect(find.textContaining('go online'), findsOneWidget); + }); + + testWidgets('"Not now" leaves the app exactly as offline as it was', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, sharing)); + await tester.tap(find.text('tap a locked entry')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('sharingInvite.notNow'))); + await tester.pumpAndSettle(); + + expect(sharing.on.value, isFalse); + expect(await store.marketRulesAccepted(), isFalse); + }); + + testWidgets('accepting the invite goes through the community rules once', + (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final store = OnboardingStore(InMemorySecretStore()); + final sharing = newTestSharingSwitch(enabled: false); + + await tester.pumpWidget(host(store, sharing)); + await tester.tap(find.text('tap a locked entry')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('sharingInvite.start'))); + await tester.pumpAndSettle(); + + // Same single consent surface the market uses — not a second one. + expect(find.text('Before you join the market'), findsOneWidget); + expect(sharing.on.value, isFalse, reason: 'not online until they agree'); + + await tester.tap(find.text('I agree')); + await tester.pumpAndSettle(); + + expect(sharing.on.value, isTrue); + expect(await store.marketRulesAccepted(), isTrue); + }); +} diff --git a/apps/app_seeds/test/ui/small_screen_overflow_test.dart b/apps/app_seeds/test/ui/small_screen_overflow_test.dart index d5c18c8..1255da9 100644 --- a/apps/app_seeds/test/ui/small_screen_overflow_test.dart +++ b/apps/app_seeds/test/ui/small_screen_overflow_test.dart @@ -93,7 +93,7 @@ void main() { wrapScreen( repository: newTestRepository(db), locale: locale, - child: const HomeScreen(marketEnabled: true), + child: HomeScreen(sharing: newTestSharingSwitch()), ), ); await disposeTree(tester);