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 get dragDevices => { PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, PointerDeviceKind.stylus, }; }