implemented Ticker page selection persistence and enhanced ProseMirror table display modes

This commit is contained in:
2026-07-10 19:15:52 +02:00
parent 0d01f6b631
commit 37608e59b3
10 changed files with 827 additions and 131 deletions
+180 -24
View File
@@ -2,16 +2,20 @@ 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';
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/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../../state/app/modules/ticker/bloc/ticker_bloc.dart';
import '../../../state/app/modules/ticker/bloc/ticker_state.dart';
import '../../../theming/app_theme.dart';
import '../../../widget/details_bottom_sheet.dart';
import '../../../widget/placeholder_view.dart';
import '../../../widget/prosemirror/pm_json_view.dart';
import 'widgets/ticker_content_card.dart';
@@ -29,19 +33,21 @@ class TickerView extends StatelessWidget {
BlocModule<TickerBloc, LoadableState<TickerState>>(
create: (context) => TickerBloc(),
child: (context, bloc, _) {
final data = context.watch<TickerBloc>().state.data;
final sections =
context
.watch<TickerBloc>()
.state
.data
?.nav
?.sections
data?.nav?.sections
.where((section) => section.pages.isNotEmpty)
.toList() ??
const <TickerNavSection>[];
return TickerScaffold(
sections: sections,
initialSlug: data?.selectedSlug,
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(
@@ -56,8 +62,8 @@ class TickerView extends StatelessWidget {
}
/// Bloc-free presentation shell: owns the in-place selection state, renders the
/// phone drawer / tablet sidebar navigation, the app bar (with home action) and
/// swaps the content region between the home surface and a single page.
/// phone bottom-sheet nav / tablet sidebar navigation, the app bar (with home
/// action) and swaps the content region between the home surface and a page.
class TickerScaffold extends StatefulWidget {
final List<TickerNavSection> sections;
@@ -78,11 +84,32 @@ class TickerScaffold extends StatefulWidget {
)?
pageBuilder;
/// Slug of the page to reopen on mount (persisted from a previous session),
/// or null for the "Aktuelles" home. Restored only if it still refers to a
/// selectable page in [sections]; otherwise it falls back to home.
final String? initialSlug;
/// Invoked whenever the in-place selection changes so the host can persist
/// 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,
required this.homeBuilder,
this.pageBuilder,
this.initialSlug,
this.onSelectionChanged,
this.homePublishedAt,
this.onRefreshHome,
});
static const double sidebarBreakpoint = 900;
@@ -93,8 +120,6 @@ class TickerScaffold extends StatefulWidget {
}
class _TickerScaffoldState extends State<TickerScaffold> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
String? _selectedSlug;
String? _selectedTitle;
@@ -109,6 +134,41 @@ class _TickerScaffoldState extends State<TickerScaffold> {
LocalHistoryEntry? _backEntry;
bool _disposing = false;
@override
void initState() {
super.initState();
final initial = widget.initialSlug;
if (initial != null && _isSelectable(widget.sections, initial)) {
_selectedSlug = initial;
_selectedTitle = _titleForSlug(initial);
} else if (initial != null) {
// Remembered page is gone (deleted/hidden) → drop the stale slug once
// mounted and stay on "Aktuelles".
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onSelectionChanged?.call(null);
});
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// A restored selection needs the same back interception a tap sets up, but
// ModalRoute is only reliable here, not in initState.
if (_selectedSlug != null && _backEntry == null && !_disposing) {
_ensureBackEntry();
}
}
@override
void didUpdateWidget(TickerScaffold oldWidget) {
super.didUpdateWidget(oldWidget);
// A fresh nav can drop the open page (deleted/hidden/unpublished server
// side) → fall back to "Aktuelles".
final slug = _selectedSlug;
if (slug != null && !_isSelectable(widget.sections, slug)) _selectHome();
}
@override
void dispose() {
_disposing = true;
@@ -116,6 +176,15 @@ class _TickerScaffoldState extends State<TickerScaffold> {
super.dispose();
}
bool _isSelectable(List<TickerNavSection> sections, String slug) {
for (final section in sections) {
for (final page in section.pages) {
if (page.slug == slug) return page.kind != TickerPageKind.redirect;
}
}
return false;
}
void _ensureBackEntry() {
if (_backEntry != null) return;
final route = ModalRoute.of(context);
@@ -143,17 +212,39 @@ class _TickerScaffoldState extends State<TickerScaffold> {
_selectedSlug = null;
_selectedTitle = null;
});
widget.onSelectionChanged?.call(null);
}
void _closeDrawerIfOpen() {
final scaffold = _scaffoldKey.currentState;
if (scaffold != null && scaffold.isDrawerOpen) scaffold.closeDrawer();
// On phones the section/page navigation lives in a modal bottom sheet opened
// from an app bar action (right), matching the app's actions-on-the-right and
// shared bottom-sheet conventions instead of a left drawer. On tablets the
// sidebar is permanent and this is unused.
void _openNavSheet() {
showDetailsBottomSheet(
context,
children: (sheetContext) => tickerNavItems(
sheetContext,
sections: widget.sections,
selectedSlug: _selectedSlug,
onSelectHome: () {
Navigator.of(sheetContext).pop();
_selectHome();
},
onSelectPage: (page) {
Navigator.of(sheetContext).pop();
_selectPage(page);
},
onRedirect: (page) {
Navigator.of(sheetContext).pop();
_openRedirect(page);
},
),
);
}
void _selectHome() {
_clearSelection();
_removeBackEntry();
_closeDrawerIfOpen();
}
void _selectPage(TickerNavPage page) {
@@ -161,8 +252,8 @@ class _TickerScaffoldState extends State<TickerScaffold> {
_selectedSlug = page.slug;
_selectedTitle = page.title;
});
widget.onSelectionChanged?.call(page.slug);
_ensureBackEntry();
_closeDrawerIfOpen();
}
void _openRedirect(TickerNavPage page) {
@@ -170,7 +261,6 @@ class _TickerScaffoldState extends State<TickerScaffold> {
if (url != null && url.isNotEmpty) {
unawaited(AppRoutes.openExternalUrl(url));
}
_closeDrawerIfOpen();
}
void _onLinkTap(String href) {
@@ -180,8 +270,8 @@ class _TickerScaffoldState extends State<TickerScaffold> {
_selectedSlug = slug;
_selectedTitle = _titleForSlug(slug);
});
widget.onSelectionChanged?.call(slug);
_ensureBackEntry();
_closeDrawerIfOpen();
return;
}
unawaited(AppRoutes.openExternalUrl(href));
@@ -233,19 +323,45 @@ class _TickerScaffoldState extends State<TickerScaffold> {
if (!didPop) _selectHome();
},
child: Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
actions: [
if (slug != null)
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: 'Aktuelles',
onPressed: _selectHome,
// 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(
icon: const Icon(Icons.home_outlined),
tooltip: 'Aktuelles',
onPressed: slug == null ? null : _selectHome,
),
],
),
drawer: wide ? null : Drawer(child: SafeArea(child: nav)),
// Phones open the page nav via a FAB (bottom sheet); tablets show the
// permanent sidebar instead, so no FAB there.
floatingActionButton: wide
? null
: FloatingActionButton(
heroTag: 'tickerNav',
backgroundColor: Theme.of(context).primaryColor,
tooltip: 'Seiten',
onPressed: _openNavSheet,
child: const Icon(Icons.toc),
),
body: wide
? Row(
children: [
@@ -324,6 +440,46 @@ 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;
@@ -4,10 +4,10 @@ import '../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_re
import '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../../theming/app_theme.dart';
/// Shared navigation list for the ticker module, used both as the phone drawer
/// and as the permanent tablet sidebar. Lists the "Aktuelles" home entry
/// followed by the section-grouped pages. [selectedSlug] is null while the home
/// surface is shown.
/// Shared navigation for the ticker module: the "Aktuelles" home entry followed
/// by the section-grouped pages. Used as the permanent tablet sidebar
/// ([TickerNavList]) and, via [tickerNavItems], inside the phone bottom sheet.
/// [selectedSlug] is null while the home surface is shown.
class TickerNavList extends StatelessWidget {
final List<TickerNavSection> sections;
final String? selectedSlug;
@@ -25,9 +25,9 @@ class TickerNavList extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final children = <Widget>[
Widget build(BuildContext context) => ListView(
padding: EdgeInsets.zero,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
@@ -35,24 +35,41 @@ class TickerNavList extends StatelessWidget {
AppSpacing.md,
AppSpacing.sm,
),
child: Text('Ticker', style: theme.textTheme.titleLarge),
child: Text('Ticker', style: Theme.of(context).textTheme.titleLarge),
),
ListTile(
leading: const Icon(Icons.campaign_outlined),
title: const Text('Aktuelles'),
selected: selectedSlug == null,
selectedTileColor: theme.colorScheme.secondaryContainer,
onTap: onSelectHome,
...tickerNavItems(
context,
sections: sections,
selectedSlug: selectedSlug,
onSelectHome: onSelectHome,
onSelectPage: onSelectPage,
onRedirect: onRedirect,
),
for (final section in sections) ..._section(context, section),
];
],
);
}
return ListView(padding: EdgeInsets.zero, children: children);
}
List<Widget> _section(BuildContext context, TickerNavSection section) {
final theme = Theme.of(context);
return [
/// The ticker nav rows (Aktuelles home entry + section-grouped pages) as a flat
/// widget list, so they render both inside the sidebar's [ListView] and inside
/// the bottom sheet's shared [Column] without a bounded-height wrapper.
List<Widget> tickerNavItems(
BuildContext context, {
required List<TickerNavSection> sections,
required String? selectedSlug,
required VoidCallback onSelectHome,
required void Function(TickerNavPage page) onSelectPage,
required void Function(TickerNavPage page) onRedirect,
}) {
final theme = Theme.of(context);
return [
ListTile(
leading: const Icon(Icons.campaign_outlined),
title: const Text('Aktuelles'),
selected: selectedSlug == null,
selectedTileColor: theme.colorScheme.secondaryContainer,
onTap: onSelectHome,
),
for (final section in sections) ...[
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
@@ -80,17 +97,17 @@ class TickerNavList extends StatelessWidget {
? onRedirect(page)
: onSelectPage(page),
),
];
}
],
];
}
IconData _iconFor(String kind) {
switch (kind) {
case TickerPageKind.redirect:
return Icons.link;
case TickerPageKind.proxiedFile:
return Icons.picture_as_pdf_outlined;
default:
return Icons.article_outlined;
}
IconData _iconFor(String kind) {
switch (kind) {
case TickerPageKind.redirect:
return Icons.link;
case TickerPageKind.proxiedFile:
return Icons.picture_as_pdf_outlined;
default:
return Icons.article_outlined;
}
}