tane/apps/app_seeds/lib/app.dart
vjrj 171daabce3 feat(calendar): "what's due this month" — dedicated screen + inventory filter
Reuses the existing per-variety crop-calendar masks (sow/transplant/
flowering/fruiting/seed-harvest) — no schema change, no migration — to
answer "qué toca este mes" across the whole inventory. Since the months
are the grower's own record, there's no hemisphere assumption.

- Repo: CalendarEntry + watchCalendar() (varieties with any recorded
  phase); VarietyListItem now carries sowMonths.
- Dedicated CalendarScreen (/calendar): a month strip (defaults to the
  current month, intl month names) and varieties grouped by phase —
  actions (sow/transplant/harvest seed) above informational (flowering/
  fruiting), each tinted, tap → variety detail; empty-month state.
- Inventory filter: a 'this month' chip (shown only when some variety
  has a calendar) keeps what sows in the current month; folded into the
  attributes group + clear-filters.
- Entry points: a live Home card + a drawer item.
- i18n calendar block + menu.calendar (en/es/pt/ast).
- Tests: calendar_test (watchCalendar, list carries sow-months, pure
  filter), calendar_screen_test (phase grouping, month switch, empty),
  plus the screen added to the small-screen overflow guard.
2026-07-11 06:27:57 +02:00

346 lines
12 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/social_account_store.dart';
import 'services/social_connection.dart';
import 'services/social_service.dart';
import 'services/social_settings.dart';
import 'services/trust_referents.dart';
import 'services/wot_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/home_screen.dart';
import 'ui/intro_screen.dart';
import 'ui/inventory_list_screen.dart';
import 'ui/plantares_screen.dart';
import 'ui/sales_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/trust_network_screen.dart';
import 'ui/variety_detail_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.socialAccounts,
this.trustReferents,
this.wotSettings,
this.inbox,
this.notifications,
this.showIntro = false,
this.autoBackup,
super.key,
}) : _router = _buildRouter(
repository,
onboarding,
showIntro,
social,
socialSettings,
connection,
location,
outbox,
messageStore,
profileStore,
profileCache,
socialAccounts,
trustReferents,
wotSettings,
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');
}
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 active social identity, for the profile switcher.
final SocialAccountStore? socialAccounts;
/// Web-of-trust bootstrap referents + parameters (network membership).
final TrustReferents? trustReferents;
final WotSettings? wotSettings;
/// 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,
SocialAccountStore? socialAccounts,
TrustReferents? trustReferents,
WotSettings? wotSettings,
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,
),
),
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,
);
},
),
if (messageStore != null)
GoRoute(
path: '/messages',
builder: (context, state) => ChatListScreen(
store: messageStore,
connection: connection,
profileCache: profileCache,
inbox: inbox,
),
),
if (social != null &&
connection != null &&
profileStore != null &&
socialAccounts != null)
GoRoute(
path: '/profile',
builder: (context, state) => ProfileScreen(
social: social,
connection: connection,
profileStore: profileStore,
accounts: socialAccounts,
trustNetworkEnabled:
trustReferents != null && wotSettings != null,
),
),
if (trustReferents != null && wotSettings != null)
GoRoute(
path: '/trust',
builder: (context, state) => TrustNetworkScreen(
referents: trustReferents,
wotSettings: wotSettings,
),
),
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,
trustReferents: trustReferents,
wotSettings: wotSettings,
),
),
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: '/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,
};
}