import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import '../../../../api/errors/error_mapper.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/modules/ticker/bloc/ticker_page_bloc.dart'; import '../../../../state/app/modules/ticker/bloc/ticker_page_state.dart'; import '../../../../state/app/modules/ticker/repository/ticker_page_repository.dart'; import '../../../../theming/app_theme.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/placeholder_view.dart'; import '../../../../widget/prosemirror/pm_json_view.dart'; import '../../../../widget/route_transition_gate.dart'; import 'ticker_content_card.dart'; import 'ticker_updated_bar.dart'; /// Embeddable renderer for a single ticker page: drives a per-slug /// [TickerPageBloc] through [LoadableStateConsumer], so pages behave like the /// home surface (cache, background refresh, offline banner, pull-to-refresh). /// Carries no Scaffold so it works both in-place in [TickerView] and in the /// standalone `TickerPageView`. A REDIRECT opens the browser and invokes /// [onRedirect] so the host can leave this page. class TickerPageBody extends StatelessWidget { final String slug; final void Function(String href) onLinkTap; final VoidCallback? onRedirect; const TickerPageBody({ super.key, required this.slug, required this.onLinkTap, this.onRedirect, }); @override Widget build(BuildContext context) => BlocModule>( // A slug switch must rebuild the provider with a fresh bloc. key: ValueKey(slug), create: (context) => TickerPageBloc(slug), child: (context, bloc, _) => LoadableStateConsumer( isReady: (state) => state.page != null, child: (state, loading) => _TickerPageContent( page: state.page!, onLinkTap: onLinkTap, onRedirect: onRedirect, ), ), ); } /// Renders a resolved [TickerPageResponse] by kind. Stateful so a REDIRECT /// fires only once across the consumer's background-refresh rebuilds. class _TickerPageContent extends StatefulWidget { final TickerPageResponse page; final void Function(String href) onLinkTap; final VoidCallback? onRedirect; const _TickerPageContent({ required this.page, required this.onLinkTap, this.onRedirect, }); @override State<_TickerPageContent> createState() => _TickerPageContentState(); } class _TickerPageContentState extends State<_TickerPageContent> { bool _redirected = false; @override Widget build(BuildContext context) { final page = widget.page; switch (page.kind) { case TickerPageKind.redirect: final url = page.externalUrl; if (!_redirected) { _redirected = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url); widget.onRedirect?.call(); }); } return const Center(child: AppProgressIndicator.large()); case TickerPageKind.proxiedFile: return Column( children: [ // For proxied files the data currency is the last successful // proxy fetch, not the page's publish date. TickerUpdatedBar(publishedAt: page.fileFetchedAt ?? page.publishedAt), Expanded( child: _ProxiedFileView( repo: context.read().repo, slug: page.slug ?? context.read().slug, ), ), ], ); default: final content = page.content; if (content == null) { return PlaceholderView( icon: Icons.public_off_outlined, text: 'Dieser Inhalt ist in der App nicht verfügbar.', button: page.webUrl == null ? null : ElevatedButton.icon( onPressed: () => AppRoutes.openWebUrl(page.webUrl!), icon: const Icon(Icons.open_in_new), label: const Text('Im Browser öffnen'), ), ); } return SingleChildScrollView( // Pull-to-refresh must trigger even when content fits the viewport. physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: AppSpacing.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TickerUpdatedBar(publishedAt: page.publishedAt), TickerContentCard( child: PmJsonView(json: content, onLinkTap: widget.onLinkTap), ), ], ), ); } } } class _ProxiedFileView extends StatefulWidget { final TickerPageRepository repo; final String slug; const _ProxiedFileView({required this.repo, required this.slug}); @override State<_ProxiedFileView> createState() => _ProxiedFileViewState(); } class _ProxiedFileViewState extends State<_ProxiedFileView> { late Future _bytes; @override void initState() { super.initState(); _bytes = widget.repo.getPageFile(widget.slug); } @override Widget build(BuildContext context) => FutureBuilder( future: _bytes, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: AppProgressIndicator.large()); } final error = snapshot.error; if (error != null) { return PlaceholderView( icon: Icons.error_outline, text: errorToUserMessage(error), button: errorAllowsRetry(error) ? ElevatedButton.icon( onPressed: () => setState(() { _bytes = widget.repo.getPageFile(widget.slug); }), icon: const Icon(Icons.refresh), label: const Text('Erneut versuchen'), ) : null, ); } final bytes = snapshot.data!; if (bytes.isEmpty) { return const PlaceholderView( icon: Icons.picture_as_pdf_outlined, text: 'Das Dokument konnte nicht geladen werden.', ); } return RouteTransitionGate( builder: (context) => SfPdfViewer.memory(bytes), ); }, ); }