tane/apps/app_seeds/lib/ui/home_screen.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

354 lines
11 KiB
Dart

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';
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" 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.sharing, this.onboarding, super.key});
/// 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) {
final t = context.t;
return Scaffold(
appBar: AppBar(
title: Text(t.app.title),
// Badge the hamburger so unread messages are visible from home, without
// opening the drawer. Total count across conversations.
leading: Builder(
builder: (context) => IconButton(
icon: const UnreadBadge(child: Icon(Icons.menu)),
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
),
drawer: AppDrawer(sharing: sharing, onboarding: onboarding),
body: Stack(
children: [
const Positioned.fill(child: _SeedWatermark()),
// Scroll when the menu doesn't fit a short screen (small phones), so
// the market card / buttons are never clipped; stays centered when it
// does fit.
LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints:
BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 128,
height: 128,
decoration: const BoxDecoration(
color: Color(0xFFDDF3CE),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Image.asset(
'assets/logo.png',
height: 84,
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 10),
Text(
t.app.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF1A2A13),
fontSize: 26,
fontWeight: FontWeight.w500,
letterSpacing: -0.3,
),
),
const SizedBox(height: 2),
Text(
t.home.tagline,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF55624A),
fontSize: 14,
),
),
const SizedBox(height: 34),
_PrimaryMenuCard(
key: const Key('home.inventory'),
icon: Icons.inventory_2_outlined,
label: t.home.yourInventory,
subtitle: t.home.yourInventorySubtitle,
onTap: () => context.push('/inventory'),
),
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'),
),
],
],
),
),
),
),
),
),
),
],
),
);
}
}
/// The primary green call-to-action: white icon in a translucent disc, a title
/// and a subtitle, with a soft green shadow.
class _PrimaryMenuCard extends StatelessWidget {
const _PrimaryMenuCard({
required this.icon,
required this.label,
required this.subtitle,
required this.onTap,
super.key,
});
final IconData icon;
final String label;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: seedGreen,
borderRadius: BorderRadius.circular(18),
elevation: 3,
shadowColor: seedGreen.withValues(alpha: 0.4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 22),
child: Row(
children: [
_IconDisc(
background: Colors.white.withValues(alpha: 0.18),
child: Icon(icon, color: Colors.white, size: 26),
),
const SizedBox(width: 16),
Expanded(
child: _CardText(
label: label,
subtitle: subtitle,
color: Colors.white,
subtitleColor: Colors.white.withValues(alpha: 0.85),
),
),
],
),
),
),
);
}
}
/// 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.onTap,
super.key,
});
final IconData icon;
final String label;
final String subtitle;
/// When set, the card is tappable; otherwise it reads as disabled.
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final card = Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFB8C4AC)),
),
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 22),
child: Row(
children: [
_IconDisc(
background: const Color(0xFFDDF3CE),
child: Icon(icon, color: seedGreen, size: 26),
),
const SizedBox(width: 16),
Expanded(
child: _CardText(
label: label,
subtitle: subtitle,
color: seedOnSurface,
subtitleColor: seedMuted,
),
),
if (onTap != null)
const Icon(Icons.chevron_right, color: seedMuted),
],
),
);
if (onTap == null) return card;
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(18),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: card,
),
);
}
}
class _IconDisc extends StatelessWidget {
const _IconDisc({required this.background, required this.child});
final Color background;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(shape: BoxShape.circle, color: background),
alignment: Alignment.center,
child: child,
);
}
}
class _CardText extends StatelessWidget {
const _CardText({
required this.label,
required this.subtitle,
required this.color,
required this.subtitleColor,
});
final String label;
final String subtitle;
final Color color;
final Color subtitleColor;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: color,
fontSize: 19,
fontWeight: FontWeight.w500,
),
),
Text(subtitle, style: TextStyle(color: subtitleColor, fontSize: 13)),
],
);
}
}
/// A single seed pictogram scattered onto the watermark: [ax]/[ay] are
/// fractional positions (0..1) within the layer, [angle] a slight tilt so the
/// scatter feels hand-strewn rather than gridded (like screen 00).
class _ScatteredGlyph {
const _ScatteredGlyph(this.char, this.ax, this.ay, this.size, this.angle);
final String char;
final double ax;
final double ay;
final double size;
final double angle;
}
/// A faint, disordered scatter of seed pictograms behind the menu (like screen
/// 00): glyphs strewn around the edges at staggered positions and tilts,
/// positioned by fraction so it holds up at any screen size.
class _SeedWatermark extends StatelessWidget {
const _SeedWatermark();
static const _glyphs = [
_ScatteredGlyph(SeedGlyphs.sack, 0.06, 0.14, 62, -0.18),
_ScatteredGlyph(SeedGlyphs.envelope, 0.82, 0.09, 48, 0.22),
_ScatteredGlyph(SeedGlyphs.scattered, 0.70, 0.30, 44, -0.12),
_ScatteredGlyph(SeedGlyphs.jar, 0.16, 0.44, 40, 0.15),
_ScatteredGlyph(SeedGlyphs.jars, 0.85, 0.66, 44, 0.18),
_ScatteredGlyph(SeedGlyphs.smallSpoon, 0.10, 0.74, 48, -0.22),
_ScatteredGlyph(SeedGlyphs.pouring, 0.40, 0.90, 52, 0.10),
_ScatteredGlyph(SeedGlyphs.mug, 0.72, 0.88, 60, -0.14),
];
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
return Stack(
children: [
for (final g in _glyphs)
Positioned(
left: w * g.ax,
top: h * g.ay,
child: Opacity(
opacity: 0.08,
child: Transform.rotate(
angle: g.angle,
child: SeedGlyph(
g.char,
size: g.size,
color: seedGreenDark,
),
),
),
),
],
);
},
),
);
}
}