add ticker page bloc and avatar disk cache

This commit is contained in:
2026-07-12 23:18:53 +02:00
parent 94794ff092
commit 9b5198c6db
18 changed files with 944 additions and 233 deletions
@@ -1,28 +1,31 @@
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/errors/ticker_content_unavailable_exception.dart';
import '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/ticker/repository/ticker_repository.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/placeholder_view.dart';
import '../../../../widget/prosemirror/pm_json_view.dart';
import 'ticker_content_card.dart';
import 'ticker_updated_bar.dart';
/// Embeddable renderer for a single ticker page. Loads the page on demand and
/// renders it by kind: CONTENT via the native ProseMirror renderer,
/// PROXIED_FILE as a PDF. It carries no Scaffold/AppBar so it can live both
/// in-place inside [TickerView] and inside the standalone `TickerPageView`
/// (deep links from outside the ticker module).
///
/// Reaching a REDIRECT here (e.g. via an internal content link whose slug turns
/// out to be a redirect) opens the browser and, if given, invokes [onRedirect]
/// so the host can leave this page.
class TickerPageBody extends StatefulWidget {
/// 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;
@@ -35,75 +38,70 @@ class TickerPageBody extends StatefulWidget {
});
@override
State<TickerPageBody> createState() => _TickerPageBodyState();
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,
),
),
);
}
class _TickerPageBodyState extends State<TickerPageBody> {
final TickerRepository _repo = TickerRepository();
late Future<TickerPageResponse> _future;
/// 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
void initState() {
super.initState();
_future = _repo.getPage(widget.slug);
}
State<_TickerPageContent> createState() => _TickerPageContentState();
}
void _reload() {
setState(() => _future = _repo.getPage(widget.slug));
}
class _TickerPageContentState extends State<_TickerPageContent> {
bool _redirected = false;
@override
Widget build(BuildContext context) => FutureBuilder<TickerPageResponse>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final error = snapshot.error;
if (error != null) return _buildError(context, error);
return _buildContent(context, snapshot.data!);
},
);
Widget _buildError(BuildContext context, Object error) {
if (error is TickerContentUnavailableException) {
return PlaceholderView(
icon: Icons.public_off_outlined,
text: error.userMessage,
button: error.webUrl == null
? null
: ElevatedButton.icon(
onPressed: () => AppRoutes.openWebUrl(error.webUrl!),
icon: const Icon(Icons.open_in_new),
label: const Text('Im Browser öffnen'),
),
);
}
return PlaceholderView(
icon: Icons.error_outline,
text: errorToUserMessage(error),
button: errorAllowsRetry(error)
? ElevatedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
)
: null,
);
}
Widget _buildContent(BuildContext context, TickerPageResponse page) {
Widget build(BuildContext context) {
final page = widget.page;
switch (page.kind) {
case TickerPageKind.redirect:
final url = page.externalUrl;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url);
widget.onRedirect?.call();
});
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: CircularProgressIndicator());
case TickerPageKind.proxiedFile:
return _ProxiedFileView(repo: _repo, slug: widget.slug);
return Column(
children: [
TickerUpdatedBar(publishedAt: 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) {
@@ -120,9 +118,17 @@ class _TickerPageBodyState extends State<TickerPageBody> {
);
}
return SingleChildScrollView(
// Pull-to-refresh must trigger even when content fits the viewport.
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
child: TickerContentCard(
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TickerUpdatedBar(publishedAt: page.publishedAt),
TickerContentCard(
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
),
],
),
);
}
@@ -130,7 +136,7 @@ class _TickerPageBodyState extends State<TickerPageBody> {
}
class _ProxiedFileView extends StatefulWidget {
final TickerRepository repo;
final TickerPageRepository repo;
final String slug;
const _ProxiedFileView({required this.repo, required this.slug});
@@ -162,9 +168,9 @@ class _ProxiedFileViewState extends State<_ProxiedFileView> {
text: errorToUserMessage(error),
button: errorAllowsRetry(error)
? ElevatedButton.icon(
onPressed: () => setState(
() => _bytes = widget.repo.getPageFile(widget.slug),
),
onPressed: () => setState(() {
_bytes = widget.repo.getPageFile(widget.slug);
}),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
)