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

394 lines
14 KiB
Dart

import 'package:commons_core/commons_core.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'data/species_repository.dart';
import 'data/variety_repository.dart';
import 'i18n/strings.g.dart';
import 'services/auto_backup_service.dart';
import 'services/coarse_location.dart';
import 'services/inbox_service.dart';
import 'services/message_store.dart';
import 'services/notification_service.dart';
import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart';
import 'services/profile_cache.dart';
import 'services/profile_store.dart';
import 'services/saved_offers_store.dart';
import 'services/saved_searches_store.dart';
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';
import 'ui/auto_backup_gate.dart';
import 'ui/calendar_screen.dart';
import 'ui/chat_list_screen.dart';
import 'ui/chat_screen.dart';
import 'ui/favorites_screen.dart';
import 'ui/home_screen.dart';
import 'ui/intro_screen.dart';
import 'ui/inventory_list_screen.dart';
import 'ui/legal_screen.dart';
import 'ui/plantares_screen.dart';
import 'ui/sales_screen.dart';
import 'ui/saved_searches_screen.dart';
import 'ui/market_offer_detail_screen.dart';
import 'ui/market_screen.dart';
import 'ui/offline_banner.dart';
import 'ui/profile_screen.dart';
import 'ui/settings_screen.dart';
import 'ui/theme.dart';
import 'ui/variety_detail_screen.dart';
import 'ui/your_people_screen.dart';
/// Root widget. Provides the repositories to the tree and wires go_router:
/// `/` is the home menu, `/inventory` the list, `/variety/:id` the item detail.
/// When [showIntro] is set (first launch), the app opens on `/intro`.
class TaneApp extends StatelessWidget {
TaneApp({
required this.repository,
required this.species,
required this.onboarding,
this.social,
this.socialSettings,
this.connection,
this.location,
this.outbox,
this.messageStore,
this.profileStore,
this.profileCache,
this.savedOffers,
this.savedSearches,
this.socialAccounts,
this.inbox,
this.notifications,
this.showIntro = false,
this.autoBackup,
SharingSwitch? sharing,
super.key,
}) : _router = _buildRouter(
repository,
onboarding,
showIntro,
social,
socialSettings,
connection,
location,
outbox,
messageStore,
profileStore,
profileCache,
savedOffers,
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,
// so a live router reference is enough.
notifications?.onTapChat = (pubkey) => _router.push('/chat/$pubkey');
// A tapped saved-search alert opens the saved-searches list.
notifications?.onTapSearch = (_) => _router.push('/saved-searches');
}
final VarietyRepository repository;
final SpeciesRepository species;
final OnboardingStore onboarding;
/// Block 2 social layer. Null until the social round is wired in `main`; when
/// null the market stays a "coming soon" card and `/market` is not routed.
final SocialService? social;
final SocialSettings? socialSettings;
/// The shared relay connection (one per identity) reused by every feature.
final SocialConnection? connection;
/// Optional device-location source for the market's "use my location".
final CoarseLocationProvider? location;
/// Optional offline outbox for the market's "share my seeds".
final OfferOutbox? outbox;
/// Optional persistence for chat history.
final MessageStore? messageStore;
/// Optional local store for your own profile (name/about).
final ProfileStore? profileStore;
/// Optional cache of peers' published display names.
final ProfileCache? profileCache;
/// Optional store of the user's saved ("favorite") market offers.
final SavedOffersStore? savedOffers;
/// Optional store of the user's saved market searches (with alerts).
final SavedSearchesStore? savedSearches;
/// Optional store of the active social identity, for the profile switcher.
final SocialAccountStore? socialAccounts;
/// App-wide inbox listener; drives the messages list's live refresh.
final InboxService? inbox;
/// OS notifications for foreground messages; its tap handler is wired to the
/// router here. Null in tests / on platforms without notification support.
final NotificationService? notifications;
final bool showIntro;
/// Drives silent periodic backups off the app lifecycle. Null in widget tests
/// and on platforms without file storage (web) — the gate then does nothing.
final AutoBackupService? autoBackup;
final GoRouter _router;
static GoRouter _buildRouter(
VarietyRepository repository,
OnboardingStore onboarding,
bool showIntro,
SocialService? social,
SocialSettings? socialSettings,
SocialConnection? connection,
CoarseLocationProvider? location,
OfferOutbox? outbox,
MessageStore? messageStore,
ProfileStore? profileStore,
ProfileCache? profileCache,
SavedOffersStore? savedOffers,
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(sharing: sharing, onboarding: onboarding),
),
if (social != null && socialSettings != null && connection != null)
GoRoute(
path: '/market',
builder: (context, state) => MarketScreen(
social: social,
settings: socialSettings,
connection: connection,
location: location,
outbox: outbox,
onboarding: onboarding,
savedSearches: savedSearches,
sharing: sharing,
),
),
if (social != null && connection != null)
GoRoute(
path: '/market/offer',
builder: (context, state) {
final offer = state.extra as Offer;
return MarketOfferDetailScreen(
offer: offer,
connection: connection,
mine: offer.authorPubkeyHex == social.publicKeyHex,
profileCache: profileCache,
selfPubkey: social.publicKeyHex,
savedOffers: savedOffers,
settings: socialSettings,
);
},
),
if (social != null && savedOffers != null)
GoRoute(
path: '/favorites',
builder: (context, state) => FavoritesScreen(
savedOffers: savedOffers,
connection: connection,
),
),
if (social != null && savedSearches != null)
GoRoute(
path: '/saved-searches',
builder: (context, state) =>
SavedSearchesScreen(store: savedSearches),
),
if (messageStore != null)
GoRoute(
path: '/messages',
builder: (context, state) => ChatListScreen(
store: messageStore,
connection: connection,
profileCache: profileCache,
inbox: inbox,
settings: socialSettings,
),
),
if (social != null &&
connection != null &&
profileStore != null &&
socialAccounts != null)
GoRoute(
path: '/profile',
builder: (context, state) => ProfileScreen(
social: social,
connection: connection,
profileStore: profileStore,
accounts: socialAccounts,
yourPeopleEnabled: true,
),
),
if (social != null && connection != null)
GoRoute(
path: '/your-people',
builder: (context, state) => YourPeopleScreen(
social: social,
connection: connection,
profileCache: profileCache,
),
),
if (social != null && connection != null)
GoRoute(
path: '/chat/:pubkey',
builder: (context, state) => ChatScreen(
social: social,
connection: connection,
peerPubkey: state.pathParameters['pubkey']!,
messageStore: messageStore,
profileCache: profileCache,
onboarding: onboarding,
settings: socialSettings,
),
),
GoRoute(
path: '/intro',
builder: (context, state) => IntroScreen(
onDone: () async {
await onboarding.markIntroSeen();
if (context.mounted) context.go('/');
},
),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/about',
builder: (context, state) => const AboutScreen(),
),
GoRoute(
path: '/legal',
builder: (context, state) => const LegalScreen(),
),
GoRoute(
path: '/inventory',
builder: (context, state) => BlocProvider(
create: (_) => InventoryCubit(repository),
child: const InventoryListScreen(),
),
),
// Local reproduction commitments — inventory-adjacent, no network.
GoRoute(
path: '/plantares',
builder: (context, state) => const PlantaresScreen(),
),
// Local sales ledger — also inventory-adjacent, no network.
GoRoute(
path: '/sales',
builder: (context, state) => const SalesScreen(),
),
// "What's due this month" across the inventory — reads the per-variety
// crop calendar, no network.
GoRoute(
path: '/calendar',
builder: (context, state) => const CalendarScreen(),
),
GoRoute(
path: '/variety/:id',
builder: (context, state) => BlocProvider(
create: (_) =>
VarietyDetailCubit(repository, state.pathParameters['id']!),
child: const VarietyDetailScreen(),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return MultiRepositoryProvider(
providers: [
RepositoryProvider.value(value: repository),
RepositoryProvider.value(value: species),
],
child: AutoBackupGate(
service: autoBackup,
child: MaterialApp.router(
onGenerateTitle: (context) => context.t.app.title,
debugShowCheckedModeBanner: false,
theme: buildTaneTheme(),
// Let scrollables & PageViews be dragged with a mouse/trackpad on
// desktop (Flutter disables that by default), so the photo carousel and
// lists swipe there too.
scrollBehavior: const _DragScrollBehavior(),
// Flutter ships no Material/Cupertino localizations for Asturian
// (`ast`), so render the framework chrome (date pickers, native dialog
// buttons) in Spanish — the closest shipped language — while the app's
// own text stays Asturian via slang. Every other locale maps 1:1.
locale: materialLocaleFor(TranslationProvider.of(context).flutterLocale),
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
// A global "you're offline" strip above every screen — makes it clear
// why the social layer is paused (the inventory still works).
builder: (context, child) =>
OfflineBanner(child: child ?? const SizedBox.shrink()),
routerConfig: _router,
),
),
);
}
}
/// Maps the app's active locale to one Flutter's bundled Material/Cupertino
/// localizations can serve. Asturian (`ast`) has none, so it borrows Spanish
/// (`es`) for the framework chrome; the app's own strings stay Asturian (they
/// come from slang, not from `Localizations`). All other locales pass through.
Locale materialLocaleFor(Locale locale) =>
locale.languageCode == 'ast' ? const Locale('es') : locale;
/// Enables mouse/trackpad drag scrolling (off by default on desktop) so the
/// photo carousel and lists can be swiped with a pointer.
class _DragScrollBehavior extends MaterialScrollBehavior {
const _DragScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
};
}