add ticker page bloc and avatar disk cache
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user