implemented Ticker page selection persistence and enhanced ProseMirror table display modes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user