tane/apps/app_seeds/lib/ui/app_drawer.dart
vjrj fed0e8200e 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).
2026-07-25 16:47:56 +02:00

318 lines
11 KiB
Dart

import 'package:flutter/material.dart';
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 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.sharing, this.onboarding, super.key});
/// 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(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _DrawerHeader(),
// The destinations scroll; the header above and the utility footer
// below stay pinned, so Settings/About are always reachable even on
// a short screen (drawer used to overflow once Calendar was added).
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_DrawerItem(
icon: const SeedGlyph(SeedGlyphs.jars, size: 23),
label: t.menu.inventory,
onTap: () {
Navigator.of(context).pop();
context.push('/inventory');
},
),
// Local, works offline — a reproduction-commitment ledger.
_DrawerItem(
icon: const Icon(Icons.volunteer_activism_outlined),
label: t.menu.plantares,
onTap: () {
Navigator.of(context).pop();
context.push('/plantares');
},
),
// Local sales ledger — also offline.
_DrawerItem(
icon: const Icon(Icons.sell_outlined),
label: t.menu.sales,
onTap: () {
Navigator.of(context).pop();
context.push('/sales');
},
),
// "What's due this month" — the aggregate crop calendar.
_DrawerItem(
icon: const Icon(Icons.calendar_month),
label: t.menu.calendar,
onTap: () {
Navigator.of(context).pop();
context.push('/calendar');
},
),
// 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,
),
],
),
),
const Divider(height: 1),
_DrawerItem(
icon: const Icon(Icons.auto_stories_outlined),
label: t.intro.menuEntry,
onTap: () {
Navigator.of(context).pop();
context.push('/intro');
},
),
_DrawerItem(
icon: const Icon(Icons.settings),
label: t.menu.settings,
onTap: () {
Navigator.of(context).pop();
context.push('/settings');
},
),
_DrawerItem(
icon: const Icon(Icons.info_outline),
label: t.about.title,
onTap: () {
Navigator.of(context).pop();
context.push('/about');
},
),
],
),
),
);
}
}
/// The drawer's brand header. Tapping it closes the drawer and returns to the
/// home screen.
class _DrawerHeader extends StatelessWidget {
const _DrawerHeader();
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Navigator.of(context).pop();
context.go('/');
},
child: Container(
padding: const EdgeInsets.fromLTRB(22, 18, 22, 16),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: seedDivider)),
),
child: Row(
children: [
Image.asset('assets/logo.png', height: 34),
const SizedBox(width: 12),
Text(
context.t.app.title,
style: const TextStyle(
color: seedGreen,
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
),
],
),
),
);
}
}
/// A 52-tall drawer row. A live destination has an [onTap] (dark text, green
/// icon); a disabled Block-2 destination is greyed with a "soon" tag. [divider]
/// draws a hairline under the row (separating live from upcoming items).
class _DrawerItem extends StatelessWidget {
const _DrawerItem({
required this.icon,
required this.label,
this.onTap,
this.divider = false,
this.locked = false,
});
final Widget icon;
final String label;
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 && !locked;
final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F);
final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F);
final row = InkWell(
onTap: onTap,
child: Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 22),
decoration: divider
? const BoxDecoration(
border: Border(bottom: BorderSide(color: seedDivider)),
)
: null,
child: Row(
children: [
SizedBox(
width: 24,
child: Center(
child: IconTheme.merge(
data: IconThemeData(size: 23, color: iconColor),
child: icon,
),
),
),
const SizedBox(width: 18),
Expanded(
child: Text(
label,
style: TextStyle(
color: fg,
fontSize: 15,
fontWeight: enabled ? FontWeight.w500 : FontWeight.w400,
),
),
),
if (locked)
const Icon(
Icons.lock_outline,
size: 16,
color: Color(0xFFB3BDA8),
),
],
),
),
);
return row;
}
}