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
+6 -70
View File
@@ -2,7 +2,6 @@ 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';
@@ -21,6 +20,7 @@ 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';
import 'widgets/ticker_updated_bar.dart';
/// Ticker module entry. Wires the [TickerBloc] to the presentation
/// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable
@@ -46,8 +46,6 @@ class TickerView extends StatelessWidget {
onSelectionChanged: (slug) => bloc.add(
Emit<TickerState>((state) => state.copyWith(selectedSlug: slug)),
),
homePublishedAt: data?.ticker?.publishedAt,
onRefreshHome: bloc.retry,
homeBuilder: (context, onLinkTap) =>
LoadableStateConsumer<TickerBloc, TickerState>(
child: (state, loading) => _TickerHome(
@@ -93,14 +91,6 @@ class TickerScaffold extends StatefulWidget {
/// 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,
@@ -108,8 +98,6 @@ class TickerScaffold extends StatefulWidget {
this.pageBuilder,
this.initialSlug,
this.onSelectionChanged,
this.homePublishedAt,
this.onRefreshHome,
});
static const double sidebarBreakpoint = 900;
@@ -326,22 +314,6 @@ class _TickerScaffoldState extends State<TickerScaffold> {
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(
@@ -415,8 +387,12 @@ class _TickerHome extends StatelessWidget {
}
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
children: [_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap)],
children: [
TickerUpdatedBar(publishedAt: ticker.publishedAt),
_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap),
],
);
}
}
@@ -440,46 +416,6 @@ class _CurrentTickerCard extends StatelessWidget {
}
}
/// 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;
@@ -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'),
)
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../../../../extensions/date_time.dart';
import '../../../../theming/app_theme.dart';
/// Non-interactive "Aktualisiert am …" mini heading above the content card
/// (refresh is pull-to-refresh). Left-aligned with the card's content indent.
/// Renders nothing when [publishedAt] is missing/unparseable.
class TickerUpdatedBar extends StatelessWidget {
final String? publishedAt;
const TickerUpdatedBar({super.key, this.publishedAt});
@override
Widget build(BuildContext context) {
final iso = publishedAt;
final parsed = iso == null || iso.isEmpty ? null : DateTime.tryParse(iso);
if (parsed == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.sm + AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
AppSpacing.xs,
),
child: Row(
children: [
Icon(Icons.schedule, size: 13, color: muted),
const SizedBox(width: AppSpacing.xs),
Text(
'Aktualisiert am ${parsed.toLocal().formatDateTime()}',
style: theme.textTheme.labelSmall?.copyWith(color: muted),
),
],
),
);
}
}