Save the current market search (query + facets) and get notified when a matching offer appears in your zone while the app is open, with a catch-up scan on start. Adds a save affordance in the search bar, a saved-searches list (apply/delete) with a per-search new-match badge, an AppBar entry badged with the unseen total, and notification-tap routing to the list. Alerts evaluate against the user's current area, dedup via seen keys, and skip own/blocked/hidden/non-active offers. en+es strings.
377 lines
13 KiB
Dart
377 lines
13 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 '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,
|
|
super.key,
|
|
}) : _router = _buildRouter(
|
|
repository,
|
|
onboarding,
|
|
showIntro,
|
|
social,
|
|
socialSettings,
|
|
connection,
|
|
location,
|
|
outbox,
|
|
messageStore,
|
|
profileStore,
|
|
profileCache,
|
|
savedOffers,
|
|
savedSearches,
|
|
socialAccounts,
|
|
inbox,
|
|
) {
|
|
// 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,
|
|
) {
|
|
return GoRouter(
|
|
initialLocation: showIntro ? '/intro' : '/',
|
|
routes: [
|
|
GoRoute(
|
|
path: '/',
|
|
builder: (context, state) =>
|
|
HomeScreen(marketEnabled: social != null),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
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,
|
|
};
|
|
}
|