Files
Client/lib/view/pages/ticker/widgets/ticker_page_body.dart
T

197 lines
6.9 KiB
Dart

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<TickerPageBloc, LoadableState<TickerPageState>>(
// A slug switch must rebuild the provider with a fresh bloc.
key: ValueKey(slug),
create: (context) => TickerPageBloc(slug),
child: (context, bloc, _) =>
LoadableStateConsumer<TickerPageBloc, TickerPageState>(
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<TickerPageBloc>().repo,
slug: page.slug ?? context.read<TickerPageBloc>().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<Uint8List> _bytes;
@override
void initState() {
super.initState();
_bytes = widget.repo.getPageFile(widget.slug);
}
@override
Widget build(BuildContext context) => FutureBuilder<Uint8List>(
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),
);
},
);
}