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

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