feat(sharing): make going online opt-in, and show what it unlocks

Tane dialled its four default relays at launch, before anyone had asked
for anything — an F-Droid reviewer spotted it, and they were right. The
seed book needs no network at all, so the app should not have one until
the person joins the sharing side.

- SocialSettings gains a three-state `sharingEnabled`. `null` means
  "never asked", which is what lets `migrateSharingEnabled` keep an
  existing install exactly as it was: anyone past the intro was on a
  build that connected at launch, so they keep messaging, device sync
  and offer alerts. A fresh install starts fully offline.
- bootstrap only starts the shared connection when sharing is on. The
  inbox/sync/plantaré/alert listeners are untouched: they react to a
  session, and none arrives.
- SharingSwitch is the single place that moves the stored choice, the
  live connection and the flag the UI listens to, so they cannot drift.
- Agreeing to the community rules is the opt-in — one consent surface,
  reached from the market or from the drawer's invitation.
- SocialConnection.start is now idempotent and gains stop(), so turning
  sharing off goes offline immediately instead of at the next launch.
- The social drawer entries stay visible but padlocked while sharing is
  off; tapping one explains what wakes up and offers to join. Hiding
  them would have kept the tool a secret. "Coming soon" is gone for
  good — everything it labelled is built.

Covered by tests for the migration in both directions, start/stop
lifecycle, the gate turning sharing on, the invitation, and the drawer
in all three states (no social layer / off / on).
This commit is contained in:
vjrj 2026-07-25 16:47:56 +02:00
parent 62123582f5
commit fed0e8200e
35 changed files with 926 additions and 173 deletions

View file

@ -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<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;
// 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(
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),
),
],
),

View file

@ -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),
],
),

View file

@ -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<bool> 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<bool>(
context: context,
@ -24,6 +36,7 @@ Future<bool> 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,

View file

@ -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<MarketScreen> {
/// network; declining leaves the market.
Future<void> _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<MarketScreen> {
final changed = await showModalBottomSheet<bool>(
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<bool>(
valueListenable: widget.sharing!.on,
builder: (context, on, _) => SwitchListTile(
key: const Key('market.sharingOn'),
contentPadding: EdgeInsets.zero,
dense: true,
value: on,
title: Text(t.market.sharingOnLabel),
subtitle: Text(
t.market.sharingOnHelp,
style: const TextStyle(
fontSize: 12,
color: seedMuted,
height: 1.3,
),
),
onChanged: (want) => want
? widget.sharing!.enable()
: widget.sharing!.disable(),
),
),
TextField(
key: const Key('market.area'),
controller: _area,

View file

@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import '../services/onboarding_store.dart';
import '../services/sharing_switch.dart';
import 'market_gate.dart';
import 'theme.dart';
/// Shown when someone taps a social entry (chat, profile, favourites, your
/// people) while sharing is still off.
///
/// The alternative was hiding those entries until sharing is on, but then
/// nobody would ever discover that Tane does any of it. So they stay in the
/// drawer, quiet, and tapping one explains what they are and offers to switch
/// them on with the community rules, which is the same single consent step
/// the market uses. Returns true when sharing ended up on.
Future<bool> showSharingInvite(
BuildContext context, {
required OnboardingStore onboarding,
required SharingSwitch sharing,
}) async {
final wants = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => const SharingInviteSheet(),
);
if (wants != true || !context.mounted) return false;
return ensureMarketRulesAccepted(context, onboarding, sharing: sharing);
}
/// The invitation itself: what lights up when you join, and the plain fact that
/// it needs a connection.
class SharingInviteSheet extends StatelessWidget {
const SharingInviteSheet({super.key});
@override
Widget build(BuildContext context) {
final t = context.t;
final theme = Theme.of(context);
return SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 24,
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
t.sharingInvite.title,
style: theme.textTheme.titleLarge?.copyWith(
color: seedOnSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_Perk(Icons.chat_bubble_outline, t.sharingInvite.perkChat),
_Perk(Icons.favorite_border, t.sharingInvite.perkFavorites),
_Perk(Icons.group_outlined, t.sharingInvite.perkPeople),
const SizedBox(height: 12),
Text(
t.sharingInvite.networkNote,
style: theme.textTheme.bodySmall?.copyWith(
color: seedMuted,
height: 1.4,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
key: const Key('sharingInvite.notNow'),
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.sharingInvite.notNow),
),
const SizedBox(width: 8),
FilledButton(
key: const Key('sharingInvite.start'),
onPressed: () => Navigator.of(context).pop(true),
child: Text(t.sharingInvite.start),
),
],
),
],
),
),
);
}
}
class _Perk extends StatelessWidget {
const _Perk(this.icon, this.text);
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: seedGreen),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: seedOnSurface,
height: 1.35,
),
),
),
],
),
);
}
}