import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import '../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart'; import '../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart'; import '../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../../state/app/modules/ticker/bloc/ticker_bloc.dart'; import '../../../state/app/modules/ticker/bloc/ticker_state.dart'; import '../../../theming/app_theme.dart'; import '../../../widget/details_bottom_sheet.dart'; import '../../../widget/placeholder_view.dart'; import '../../../widget/prosemirror/pm_json_view.dart'; import 'widgets/ticker_content_card.dart'; import 'widgets/ticker_nav_list.dart'; import 'widgets/ticker_page_body.dart'; /// Ticker module entry. Wires the [TickerBloc] to the presentation /// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable /// consumer (loading/error/pull-to-refresh), individual pages load themselves. class TickerView extends StatelessWidget { const TickerView({super.key}); @override Widget build(BuildContext context) => BlocModule>( create: (context) => TickerBloc(), child: (context, bloc, _) { final data = context.watch().state.data; final sections = data?.nav?.sections .where((section) => section.pages.isNotEmpty) .toList() ?? const []; return TickerScaffold( sections: sections, initialSlug: data?.selectedSlug, onSelectionChanged: (slug) => bloc.add( Emit((state) => state.copyWith(selectedSlug: slug)), ), homePublishedAt: data?.ticker?.publishedAt, onRefreshHome: bloc.retry, homeBuilder: (context, onLinkTap) => LoadableStateConsumer( child: (state, loading) => _TickerHome( ticker: state.ticker, hasSections: sections.isNotEmpty, onLinkTap: onLinkTap, ), ), ); }, ); } /// Bloc-free presentation shell: owns the in-place selection state, renders the /// phone bottom-sheet nav / tablet sidebar navigation, the app bar (with home /// action) and swaps the content region between the home surface and a page. class TickerScaffold extends StatefulWidget { final List sections; /// Builds the "Aktuelles" home surface. Receives the in-place link handler so /// internal ticker links switch the selection instead of pushing. final Widget Function( BuildContext context, void Function(String href) onLinkTap, ) homeBuilder; /// Test seam: overrides the per-page content (default renders /// [TickerPageBody]). final Widget Function( BuildContext context, String slug, void Function(String href) onLinkTap, )? pageBuilder; /// Slug of the page to reopen on mount (persisted from a previous session), /// or null for the "Aktuelles" home. Restored only if it still refers to a /// selectable page in [sections]; otherwise it falls back to home. final String? initialSlug; /// Invoked whenever the in-place selection changes so the host can persist /// it (null = home). Also fired when a stale [initialSlug] is discarded. final void Function(String? slug)? onSelectionChanged; /// ISO publish timestamp of the current "Aktuelles" post, shown as a dated /// refresh button in the app bar while the home surface is open. Null hides /// the button (no post / no date / on a page, which carries no date). final String? homePublishedAt; /// Tapped from the app bar's dated button to reload the ticker. final VoidCallback? onRefreshHome; const TickerScaffold({ super.key, required this.sections, required this.homeBuilder, this.pageBuilder, this.initialSlug, this.onSelectionChanged, this.homePublishedAt, this.onRefreshHome, }); static const double sidebarBreakpoint = 900; static const double sidebarWidth = 300; @override State createState() => _TickerScaffoldState(); } class _TickerScaffoldState extends State { String? _selectedSlug; String? _selectedTitle; // Back-gesture handling needs two pieces to cooperate with // PersistentTabView: a PopScope (canPop=false on a sub-page) emits the // NavigationNotification that makes the tab shell intercept the system back // instead of popping the root route, and this LocalHistoryEntry is what the // shell then finds poppable on the tab navigator (same mechanism a Drawer // uses) — popping it returns to "Aktuelles". Either piece alone fails: the // entry emits no notification (back closes the app), the scope alone leaves // the tab navigator unpoppable (back switches tabs). LocalHistoryEntry? _backEntry; bool _disposing = false; @override void initState() { super.initState(); final initial = widget.initialSlug; if (initial != null && _isSelectable(widget.sections, initial)) { _selectedSlug = initial; _selectedTitle = _titleForSlug(initial); } else if (initial != null) { // Remembered page is gone (deleted/hidden) → drop the stale slug once // mounted and stay on "Aktuelles". WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) widget.onSelectionChanged?.call(null); }); } } @override void didChangeDependencies() { super.didChangeDependencies(); // A restored selection needs the same back interception a tap sets up, but // ModalRoute is only reliable here, not in initState. if (_selectedSlug != null && _backEntry == null && !_disposing) { _ensureBackEntry(); } } @override void didUpdateWidget(TickerScaffold oldWidget) { super.didUpdateWidget(oldWidget); // A fresh nav can drop the open page (deleted/hidden/unpublished server // side) → fall back to "Aktuelles". final slug = _selectedSlug; if (slug != null && !_isSelectable(widget.sections, slug)) _selectHome(); } @override void dispose() { _disposing = true; _removeBackEntry(); super.dispose(); } bool _isSelectable(List sections, String slug) { for (final section in sections) { for (final page in section.pages) { if (page.slug == slug) return page.kind != TickerPageKind.redirect; } } return false; } void _ensureBackEntry() { if (_backEntry != null) return; final route = ModalRoute.of(context); if (route == null) return; final entry = LocalHistoryEntry( onRemove: () { _backEntry = null; if (_disposing || !mounted) return; _clearSelection(); }, ); _backEntry = entry; route.addLocalHistoryEntry(entry); } void _removeBackEntry() { final entry = _backEntry; _backEntry = null; entry?.remove(); } void _clearSelection() { if (_selectedSlug == null) return; setState(() { _selectedSlug = null; _selectedTitle = null; }); widget.onSelectionChanged?.call(null); } // On phones the section/page navigation lives in a modal bottom sheet opened // from an app bar action (right), matching the app's actions-on-the-right and // shared bottom-sheet conventions instead of a left drawer. On tablets the // sidebar is permanent and this is unused. void _openNavSheet() { showDetailsBottomSheet( context, children: (sheetContext) => tickerNavItems( sheetContext, sections: widget.sections, selectedSlug: _selectedSlug, onSelectHome: () { Navigator.of(sheetContext).pop(); _selectHome(); }, onSelectPage: (page) { Navigator.of(sheetContext).pop(); _selectPage(page); }, onRedirect: (page) { Navigator.of(sheetContext).pop(); _openRedirect(page); }, ), ); } void _selectHome() { _clearSelection(); _removeBackEntry(); } void _selectPage(TickerNavPage page) { setState(() { _selectedSlug = page.slug; _selectedTitle = page.title; }); widget.onSelectionChanged?.call(page.slug); _ensureBackEntry(); } void _openRedirect(TickerNavPage page) { final url = page.externalUrl; if (url != null && url.isNotEmpty) { unawaited(AppRoutes.openExternalUrl(url)); } } void _onLinkTap(String href) { final slug = AppRoutes.tickerSlugOf(href); if (slug != null && slug.isNotEmpty) { setState(() { _selectedSlug = slug; _selectedTitle = _titleForSlug(slug); }); widget.onSelectionChanged?.call(slug); _ensureBackEntry(); return; } unawaited(AppRoutes.openExternalUrl(href)); } String? _titleForSlug(String slug) { for (final section in widget.sections) { for (final page in section.pages) { if (page.slug == slug) return page.title; } } return null; } @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final wide = constraints.maxWidth >= TickerScaffold.sidebarBreakpoint; final slug = _selectedSlug; final nav = TickerNavList( sections: widget.sections, selectedSlug: slug, onSelectHome: _selectHome, onSelectPage: _selectPage, onRedirect: _openRedirect, ); // Keep the "Aktuelles" surface permanently mounted (offstage while a // page is open) so returning to it is instant — a re-mount would re-run // PmJsonView's image precache and flash its spinner for a frame. final content = IndexedStack( index: slug == null ? 0 : 1, sizing: StackFit.expand, children: [ widget.homeBuilder(context, _onLinkTap), if (slug == null) const SizedBox.shrink() else (widget.pageBuilder ?? _defaultPage)(context, slug, _onLinkTap), ], ); return PopScope( canPop: slug == null, // Normally the pop lands on the LocalHistoryEntry, not here; this only // fires if the entry is missing (no enclosing route) as a fallback. onPopInvokedWithResult: (didPop, _) { if (!didPop) _selectHome(); }, child: Scaffold( appBar: AppBar( title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')), actions: [ // The "Aktuelles" post's date, shown only on the home surface // (pages carry no date). Tapping reloads the ticker. if (slug == null) Builder( builder: (context) { final updatedAt = _formatPublishedAt( context, widget.homePublishedAt, ); if (updatedAt == null) return const SizedBox.shrink(); return _UpdatedAtButton( text: updatedAt, onTap: widget.onRefreshHome, ); }, ), // Always present so "Aktuelles" is a fixed anchor; greyed out // (disabled) while it is the current surface. IconButton( icon: const Icon(Icons.home_outlined), tooltip: 'Aktuelles', onPressed: slug == null ? null : _selectHome, ), ], ), // Phones open the page nav via a FAB (bottom sheet); tablets show the // permanent sidebar instead, so no FAB there. floatingActionButton: wide ? null : FloatingActionButton( heroTag: 'tickerNav', backgroundColor: Theme.of(context).primaryColor, tooltip: 'Seiten', onPressed: _openNavSheet, child: const Icon(Icons.toc), ), body: wide ? Row( children: [ SizedBox(width: TickerScaffold.sidebarWidth, child: nav), const VerticalDivider(width: 1), Expanded(child: content), ], ) : content, ), ); }, ); Widget _defaultPage( BuildContext context, String slug, void Function(String href) onLinkTap, ) => TickerPageBody( key: ValueKey(slug), slug: slug, onLinkTap: onLinkTap, onRedirect: _selectHome, ); } /// The "Aktuelles" home surface: the current ticker post, or a hint when there /// is no post (yet). Scrollable so the loadable consumer's pull-to-refresh /// works. class _TickerHome extends StatelessWidget { final TickerResponse? ticker; final bool hasSections; final void Function(String href) onLinkTap; const _TickerHome({ required this.ticker, required this.hasSections, required this.onLinkTap, }); @override Widget build(BuildContext context) { final ticker = this.ticker; if (ticker == null) { return PlaceholderView( icon: hasSections ? Icons.campaign_outlined : Icons.feed_outlined, text: hasSections ? 'Zurzeit gibt es keine aktuelle Meldung.' : 'Zurzeit sind keine Ticker-Inhalte verfügbar.', ); } return ListView( padding: const EdgeInsets.only(bottom: AppSpacing.lg), children: [_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap)], ); } } class _CurrentTickerCard extends StatelessWidget { final TickerResponse ticker; final void Function(String href) onLinkTap; const _CurrentTickerCard({required this.ticker, required this.onLinkTap}); @override Widget build(BuildContext context) { final content = ticker.content; final hasContent = ticker.available && content != null; return TickerContentCard( child: hasContent ? PmJsonView(json: content, onLinkTap: onLinkTap) : _UnavailableHint(webUrl: ticker.webUrl), ); } } /// App bar button showing when the "Aktuelles" post was last updated; tapping /// reloads the ticker. Styled to sit on the app bar (onSurface foreground) with /// a clock icon and the full date in a tooltip. class _UpdatedAtButton extends StatelessWidget { final String text; final VoidCallback? onTap; const _UpdatedAtButton({required this.text, this.onTap}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), child: Tooltip( message: 'Aktualisiert am $text', child: TextButton.icon( onPressed: onTap, icon: const Icon(Icons.schedule, size: 16), label: Text(text), style: TextButton.styleFrom( foregroundColor: theme.colorScheme.onSurface, textStyle: theme.textTheme.labelMedium, ), ), ), ); } } /// Formats the ISO `publishedAt` as `dd.MM.yyyy, HH:mm` in the device locale, or /// null when it is missing/unparseable so callers can drop the line entirely. String? _formatPublishedAt(BuildContext context, String? iso) { if (iso == null || iso.isEmpty) return null; final parsed = DateTime.tryParse(iso); if (parsed == null) return null; final locale = Localizations.localeOf(context).toString(); return DateFormat('dd.MM.yyyy, HH:mm', locale).format(parsed.toLocal()); } class _UnavailableHint extends StatelessWidget { final String webUrl; const _UnavailableHint({required this.webUrl}); @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Diese Meldung ist in der App nicht verfügbar.', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: AppSpacing.xs), Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: () => AppRoutes.openWebUrl(webUrl), icon: const Icon(Icons.open_in_new), label: const Text('Im Browser öffnen'), ), ), ], ); }