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
@@ -6,13 +6,19 @@ import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav
part 'ticker_state.freezed.dart';
part 'ticker_state.g.dart';
/// Hydrated ticker module state: the current "Aktuelles" post plus the filtered
/// page tree. Page contents are loaded on demand in the detail screen and are
/// deliberately not cached here.
/// Hydrated ticker module state: the current "Aktuelles" post, the filtered
/// page tree and the slug of the page the user last had open ([selectedSlug],
/// null = "Aktuelles" home). Persisting the slug lets the ticker reopen on the
/// last page instead of always falling back to home; a slug that is no longer
/// in [nav] (deleted/hidden) is discarded by the view. Page contents are loaded
/// on demand in the detail screen and are deliberately not cached here.
@freezed
abstract class TickerState with _$TickerState {
const factory TickerState({TickerResponse? ticker, TickerNavResponse? nav}) =
_TickerState;
const factory TickerState({
TickerResponse? ticker,
TickerNavResponse? nav,
String? selectedSlug,
}) = _TickerState;
factory TickerState.fromJson(Map<String, dynamic> json) =>
_$TickerStateFromJson(json);
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$TickerState {
TickerResponse? get ticker; TickerNavResponse? get nav;
TickerResponse? get ticker; TickerNavResponse? get nav; String? get selectedSlug;
/// Create a copy of TickerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $TickerStateCopyWith<TickerState> get copyWith => _$TickerStateCopyWithImpl<Tick
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is TickerState&&(identical(other.ticker, ticker) || other.ticker == ticker)&&(identical(other.nav, nav) || other.nav == nav));
return identical(this, other) || (other.runtimeType == runtimeType&&other is TickerState&&(identical(other.ticker, ticker) || other.ticker == ticker)&&(identical(other.nav, nav) || other.nav == nav)&&(identical(other.selectedSlug, selectedSlug) || other.selectedSlug == selectedSlug));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,ticker,nav);
int get hashCode => Object.hash(runtimeType,ticker,nav,selectedSlug);
@override
String toString() {
return 'TickerState(ticker: $ticker, nav: $nav)';
return 'TickerState(ticker: $ticker, nav: $nav, selectedSlug: $selectedSlug)';
}
@@ -48,7 +48,7 @@ abstract mixin class $TickerStateCopyWith<$Res> {
factory $TickerStateCopyWith(TickerState value, $Res Function(TickerState) _then) = _$TickerStateCopyWithImpl;
@useResult
$Res call({
TickerResponse? ticker, TickerNavResponse? nav
TickerResponse? ticker, TickerNavResponse? nav, String? selectedSlug
});
@@ -65,11 +65,12 @@ class _$TickerStateCopyWithImpl<$Res>
/// Create a copy of TickerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? ticker = freezed,Object? nav = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? ticker = freezed,Object? nav = freezed,Object? selectedSlug = freezed,}) {
return _then(_self.copyWith(
ticker: freezed == ticker ? _self.ticker : ticker // ignore: cast_nullable_to_non_nullable
as TickerResponse?,nav: freezed == nav ? _self.nav : nav // ignore: cast_nullable_to_non_nullable
as TickerNavResponse?,
as TickerNavResponse?,selectedSlug: freezed == selectedSlug ? _self.selectedSlug : selectedSlug // ignore: cast_nullable_to_non_nullable
as String?,
));
}
@@ -154,10 +155,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( TickerResponse? ticker, TickerNavResponse? nav)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( TickerResponse? ticker, TickerNavResponse? nav, String? selectedSlug)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _TickerState() when $default != null:
return $default(_that.ticker,_that.nav);case _:
return $default(_that.ticker,_that.nav,_that.selectedSlug);case _:
return orElse();
}
@@ -175,10 +176,10 @@ return $default(_that.ticker,_that.nav);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( TickerResponse? ticker, TickerNavResponse? nav) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( TickerResponse? ticker, TickerNavResponse? nav, String? selectedSlug) $default,) {final _that = this;
switch (_that) {
case _TickerState():
return $default(_that.ticker,_that.nav);case _:
return $default(_that.ticker,_that.nav,_that.selectedSlug);case _:
throw StateError('Unexpected subclass');
}
@@ -195,10 +196,10 @@ return $default(_that.ticker,_that.nav);case _:
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( TickerResponse? ticker, TickerNavResponse? nav)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( TickerResponse? ticker, TickerNavResponse? nav, String? selectedSlug)? $default,) {final _that = this;
switch (_that) {
case _TickerState() when $default != null:
return $default(_that.ticker,_that.nav);case _:
return $default(_that.ticker,_that.nav,_that.selectedSlug);case _:
return null;
}
@@ -210,11 +211,12 @@ return $default(_that.ticker,_that.nav);case _:
@JsonSerializable()
class _TickerState implements TickerState {
const _TickerState({this.ticker, this.nav});
const _TickerState({this.ticker, this.nav, this.selectedSlug});
factory _TickerState.fromJson(Map<String, dynamic> json) => _$TickerStateFromJson(json);
@override final TickerResponse? ticker;
@override final TickerNavResponse? nav;
@override final String? selectedSlug;
/// Create a copy of TickerState
/// with the given fields replaced by the non-null parameter values.
@@ -229,16 +231,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TickerState&&(identical(other.ticker, ticker) || other.ticker == ticker)&&(identical(other.nav, nav) || other.nav == nav));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TickerState&&(identical(other.ticker, ticker) || other.ticker == ticker)&&(identical(other.nav, nav) || other.nav == nav)&&(identical(other.selectedSlug, selectedSlug) || other.selectedSlug == selectedSlug));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,ticker,nav);
int get hashCode => Object.hash(runtimeType,ticker,nav,selectedSlug);
@override
String toString() {
return 'TickerState(ticker: $ticker, nav: $nav)';
return 'TickerState(ticker: $ticker, nav: $nav, selectedSlug: $selectedSlug)';
}
@@ -249,7 +251,7 @@ abstract mixin class _$TickerStateCopyWith<$Res> implements $TickerStateCopyWith
factory _$TickerStateCopyWith(_TickerState value, $Res Function(_TickerState) _then) = __$TickerStateCopyWithImpl;
@override @useResult
$Res call({
TickerResponse? ticker, TickerNavResponse? nav
TickerResponse? ticker, TickerNavResponse? nav, String? selectedSlug
});
@@ -266,11 +268,12 @@ class __$TickerStateCopyWithImpl<$Res>
/// Create a copy of TickerState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? ticker = freezed,Object? nav = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? ticker = freezed,Object? nav = freezed,Object? selectedSlug = freezed,}) {
return _then(_TickerState(
ticker: freezed == ticker ? _self.ticker : ticker // ignore: cast_nullable_to_non_nullable
as TickerResponse?,nav: freezed == nav ? _self.nav : nav // ignore: cast_nullable_to_non_nullable
as TickerNavResponse?,
as TickerNavResponse?,selectedSlug: freezed == selectedSlug ? _self.selectedSlug : selectedSlug // ignore: cast_nullable_to_non_nullable
as String?,
));
}
@@ -13,7 +13,12 @@ _TickerState _$TickerStateFromJson(Map<String, dynamic> json) => _TickerState(
nav: json['nav'] == null
? null
: TickerNavResponse.fromJson(json['nav'] as Map<String, dynamic>),
selectedSlug: json['selectedSlug'] as String?,
);
Map<String, dynamic> _$TickerStateToJson(_TickerState instance) =>
<String, dynamic>{'ticker': instance.ticker, 'nav': instance.nav};
<String, dynamic>{
'ticker': instance.ticker,
'nav': instance.nav,
'selectedSlug': instance.selectedSlug,
};
+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;
}
}
@@ -20,3 +20,24 @@ class PmRenderScope extends InheritedWidget {
bool updateShouldNotify(PmRenderScope oldWidget) =>
oldWidget.onLinkTap != onLinkTap;
}
/// Controls whether inline text below it may wrap. Table cells disable wrapping
/// in the scroll/zoom modes so every row stays a single line (bound height),
/// matching the web ticker; the default outside tables is to wrap.
class PmCellTextFlow extends InheritedWidget {
final bool softWrap;
const PmCellTextFlow({
required this.softWrap,
required super.child,
super.key,
});
static bool softWrapOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<PmCellTextFlow>()?.softWrap ??
true;
@override
bool updateShouldNotify(PmCellTextFlow oldWidget) =>
oldWidget.softWrap != softWrap;
}
+9 -2
View File
@@ -172,8 +172,15 @@ class _PmRichTextState extends State<PmRichText> {
}
@override
Widget build(BuildContext context) =>
Text.rich(_span, textAlign: widget.textAlign);
Widget build(BuildContext context) {
final softWrap = PmCellTextFlow.softWrapOf(context);
return Text.rich(
_span,
textAlign: widget.textAlign,
softWrap: softWrap,
overflow: softWrap ? TextOverflow.clip : TextOverflow.visible,
);
}
}
/// Parses a CSS `#rgb`/`#rrggbb`/`#rrggbbaa` hex or a small set of named colors.
+247 -33
View File
@@ -6,20 +6,48 @@ import 'package:flutter_layout_grid/flutter_layout_grid.dart';
import '../../theming/app_theme.dart';
import 'pm_document_view.dart';
import 'pm_node.dart';
import 'pm_render_scope.dart';
/// Table display mode, mirroring the web ticker's three-way toggle. Switching
/// one table switches all of them at once — the choice is shared, exactly like
/// the web view (which persists it in `localStorage`; here it lives for the
/// duration of the app session).
enum PmTableMode { scroll, wrap, zoom }
final ValueNotifier<PmTableMode> pmTableMode = ValueNotifier(PmTableMode.scroll);
/// Renders a ProseMirror table. Flutter's built-in `Table` cannot span cells,
/// so `flutter_layout_grid` places each cell explicitly, honouring
/// colspan/rowspan via a simple HTML-style occupancy scan.
class PmTableView extends StatelessWidget {
///
/// Three display modes match the web ticker:
/// - `scroll`: content-sized columns, single-line rows, horizontal scroll with
/// edge shadows that hint the overflow.
/// - `wrap`: columns share the width and text wraps — no scrolling.
/// - `zoom`: the full table is scaled down to fit on screen at a glance.
class PmTableView extends StatefulWidget {
final PmTable node;
static const double _minColumnWidth = 140;
const PmTableView({required this.node, super.key});
@override
State<PmTableView> createState() => _PmTableViewState();
}
class _PmTableViewState extends State<PmTableView> {
final ScrollController _scroll = ScrollController();
bool _showLeftShadow = false;
bool _showRightShadow = false;
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final rows = node.children.whereType<PmTableRow>().toList();
final rows = widget.node.children.whereType<PmTableRow>().toList();
if (rows.isEmpty) return const SizedBox.shrink();
final occupied = <int, Set<int>>{};
@@ -38,7 +66,7 @@ class PmTableView extends StatelessWidget {
columnSpan: cell.colspan,
rowStart: r,
rowSpan: cell.rowspan,
child: _cell(context, cell),
child: _cell(context, cell, r),
),
);
for (var dr = 0; dr < cell.rowspan; dr++) {
@@ -53,42 +81,228 @@ class PmTableView extends StatelessWidget {
}
if (columnCount == 0) return const SizedBox.shrink();
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth.isFinite
? constraints.maxWidth
: _minColumnWidth * columnCount;
final columnWidth = max(_minColumnWidth, available / columnCount);
final totalWidth = columnWidth * columnCount;
final grid = SizedBox(
width: totalWidth,
child: LayoutGrid(
columnSizes: List.filled(columnCount, fixed(columnWidth)),
rowSizes: List.filled(rows.length, auto),
children: placements,
),
);
if (totalWidth <= available) return grid;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: grid,
);
},
return ValueListenableBuilder<PmTableMode>(
valueListenable: pmTableMode,
builder: (context, mode, _) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_Toolbar(mode: mode),
const SizedBox(height: AppSpacing.xs),
_body(context, mode, rows.length, columnCount, placements),
],
),
);
}
Widget _cell(BuildContext context, PmTableCell cell) {
Widget _body(
BuildContext context,
PmTableMode mode,
int rowCount,
int columnCount,
List<Widget> placements,
) {
switch (mode) {
case PmTableMode.wrap:
return PmCellTextFlow(
softWrap: true,
child: LayoutGrid(
columnSizes: List.filled(columnCount, flex(1)),
rowSizes: List.filled(rowCount, auto),
children: placements,
),
);
case PmTableMode.zoom:
final grid = _naturalGrid(rowCount, columnCount, placements);
return LayoutBuilder(
builder: (context, constraints) => SizedBox(
width: constraints.maxWidth,
child: Align(
alignment: Alignment.topLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: grid,
),
),
),
);
case PmTableMode.scroll:
WidgetsBinding.instance.addPostFrameCallback((_) => _updateShadows());
return Stack(
children: [
NotificationListener<ScrollNotification>(
onNotification: (_) {
_updateShadows();
return false;
},
child: SingleChildScrollView(
controller: _scroll,
scrollDirection: Axis.horizontal,
child: _naturalGrid(rowCount, columnCount, placements),
),
),
_edgeShadow(context, left: true, visible: _showLeftShadow),
_edgeShadow(context, left: false, visible: _showRightShadow),
],
);
}
}
/// Content-sized, single-line grid shared by the scroll and zoom modes.
Widget _naturalGrid(int rowCount, int columnCount, List<Widget> placements) =>
PmCellTextFlow(
softWrap: false,
child: LayoutGrid(
columnSizes: List.filled(columnCount, auto),
rowSizes: List.filled(rowCount, auto),
children: placements,
),
);
void _updateShadows() {
if (!_scroll.hasClients) return;
final pos = _scroll.position;
final canScroll = pos.maxScrollExtent > 0.5;
final showLeft = canScroll && pos.pixels > 0.5;
final showRight = canScroll && pos.pixels < pos.maxScrollExtent - 0.5;
if (showLeft != _showLeftShadow || showRight != _showRightShadow) {
setState(() {
_showLeftShadow = showLeft;
_showRightShadow = showRight;
});
}
}
Widget _edgeShadow(
BuildContext context, {
required bool left,
required bool visible,
}) {
final dark = Theme.of(context).brightness == Brightness.dark;
final color = Colors.black.withValues(alpha: dark ? 0.28 : 0.12);
return Positioned(
top: 0,
bottom: 0,
left: left ? 0 : null,
right: left ? null : 0,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: Container(
width: 22,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: left ? Alignment.centerLeft : Alignment.centerRight,
end: left ? Alignment.centerRight : Alignment.centerLeft,
colors: [color, color.withValues(alpha: 0)],
),
),
),
),
),
);
}
Widget _cell(BuildContext context, PmTableCell cell, int rowIndex) {
final theme = Theme.of(context);
Color? background;
if (cell.header) {
background = theme.colorScheme.surfaceContainerHighest;
} else if (rowIndex.isOdd) {
background = theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.4,
);
}
return DecoratedBox(
decoration: BoxDecoration(
color: cell.header ? theme.colorScheme.surfaceContainerHighest : null,
color: background,
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: pmBlocks(cell.children, gap: AppSpacing.xs),
// The grid lays cells out with loose constraints, so a shorter cell would
// shrink below its row's height (set by the tallest cell) and its border
// would not line up. Align stretches the box to fill the whole cell area
// while still reporting the content height for the row's intrinsic size.
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: pmBlocks(cell.children, gap: AppSpacing.xs),
),
),
);
}
}
/// Compact segmented control that toggles [pmTableMode]. Always visible so the
/// modes are discoverable; a tap switches every table on screen at once.
class _Toolbar extends StatelessWidget {
final PmTableMode mode;
const _Toolbar({required this.mode});
static const List<(PmTableMode, IconData, String)> _modes = [
(PmTableMode.scroll, Icons.swap_horiz, 'Originalbreite (seitlich scrollen)'),
(PmTableMode.wrap, Icons.wrap_text, 'Spalten umbrechen'),
(PmTableMode.zoom, Icons.fit_screen, 'Verkleinern (alles auf einen Blick)'),
];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.centerRight,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border.all(color: theme.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _modes.length; i++) ...[
if (i > 0)
Container(
width: 1,
height: 26,
color: theme.colorScheme.outlineVariant,
),
_button(context, _modes[i].$1, _modes[i].$2, _modes[i].$3),
],
],
),
),
);
}
Widget _button(
BuildContext context,
PmTableMode target,
IconData icon,
String tooltip,
) {
final theme = Theme.of(context);
final active = target == mode;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: () => pmTableMode.value = target,
child: Container(
width: 34,
height: 30,
alignment: Alignment.center,
color: active ? theme.colorScheme.primaryContainer : Colors.transparent,
child: Icon(
icon,
size: 16,
color: active
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
+185 -13
View File
@@ -19,9 +19,19 @@ List<TickerNavSection> _sections() => [
),
];
Widget _host() => MaterialApp(
Widget _host({
List<TickerNavSection>? sections,
String? initialSlug,
void Function(String? slug)? onSelectionChanged,
String? homePublishedAt,
VoidCallback? onRefreshHome,
}) => MaterialApp(
home: TickerScaffold(
sections: _sections(),
sections: sections ?? _sections(),
initialSlug: initialSlug,
onSelectionChanged: onSelectionChanged,
homePublishedAt: homePublishedAt,
onRefreshHome: onRefreshHome,
homeBuilder: (context, onLinkTap) => const Text('HOME'),
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
),
@@ -30,29 +40,50 @@ Widget _host() => MaterialApp(
Finder _appBarText(String text) =>
find.descendant(of: find.byType(AppBar), matching: find.text(text));
// The "Aktuelles" home action is always present; it is greyed out (disabled)
// while home is the current surface, active while a page is open.
bool _homeEnabled(WidgetTester tester) =>
tester
.widget<IconButton>(
find.widgetWithIcon(IconButton, Icons.home_outlined),
)
.onPressed !=
null;
// The home surface stays mounted (IndexedStack) while a page is open, so
// "which content is shown" is the stack index, not widget presence.
int _shownIndex(WidgetTester tester) =>
tester.widget<IndexedStack>(find.byType(IndexedStack)).index!;
const String _menuTooltip = 'Open navigation menu';
const String _navTooltip = 'Seiten';
void main() {
group('narrow layout (< 900)', () {
testWidgets('uses a drawer reachable via the burger button', (tester) async {
testWidgets('opens the nav in a bottom sheet from the floating button', (
tester,
) async {
await tester.pumpWidget(_host());
await tester.pumpAndSettle();
// Nav is hidden behind the drawer, not shown as a sidebar.
// No drawer, and the nav is hidden until the sheet is opened.
expect(find.byType(Drawer), findsNothing);
expect(find.text('Über uns'), findsNothing);
expect(find.byTooltip(_menuTooltip), findsOneWidget);
expect(find.byTooltip(_navTooltip), findsOneWidget);
await tester.tap(find.byTooltip(_menuTooltip));
await tester.tap(find.byTooltip(_navTooltip));
await tester.pumpAndSettle();
expect(find.byType(Drawer), findsOneWidget);
expect(find.text('Über uns'), findsOneWidget);
// Sheet shows the section-grouped nav.
expect(find.text('Aktuelles'), findsOneWidget);
expect(find.text('Über uns'), findsOneWidget);
// Selecting a page closes the sheet and switches the content in place.
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
expect(find.text('Aktuelles'), findsNothing);
expect(_shownIndex(tester), 1);
expect(_appBarText('Über uns'), findsOneWidget);
});
});
@@ -70,7 +101,8 @@ void main() {
await pumpWide(tester);
expect(find.byType(Drawer), findsNothing);
expect(find.byTooltip(_menuTooltip), findsNothing);
// No bottom-sheet trigger on tablets — the sidebar is permanent.
expect(find.byTooltip(_navTooltip), findsNothing);
// Sidebar nav item is visible without any interaction.
expect(find.text('Über uns'), findsOneWidget);
});
@@ -83,7 +115,9 @@ void main() {
expect(_shownIndex(tester), 0);
expect(find.text('PAGE:about'), findsNothing);
expect(_appBarText('Ticker'), findsOneWidget);
expect(find.byIcon(Icons.home_outlined), findsNothing);
// Home action is shown but greyed out on the home surface.
expect(find.byIcon(Icons.home_outlined), findsOneWidget);
expect(_homeEnabled(tester), isFalse);
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
@@ -91,7 +125,7 @@ void main() {
expect(_shownIndex(tester), 1);
expect(find.text('PAGE:about'), findsOneWidget);
expect(_appBarText('Über uns'), findsOneWidget);
expect(find.byIcon(Icons.home_outlined), findsOneWidget);
expect(_homeEnabled(tester), isTrue);
await tester.tap(find.byIcon(Icons.home_outlined));
await tester.pumpAndSettle();
@@ -99,7 +133,145 @@ void main() {
expect(_shownIndex(tester), 0);
expect(find.text('PAGE:about'), findsNothing);
expect(_appBarText('Ticker'), findsOneWidget);
expect(find.byIcon(Icons.home_outlined), findsNothing);
expect(find.byIcon(Icons.home_outlined), findsOneWidget);
expect(_homeEnabled(tester), isFalse);
});
});
group('remembered selection', () {
Future<void> pumpWide(WidgetTester tester, Widget host) async {
tester.view.physicalSize = const Size(1200, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(host);
await tester.pumpAndSettle();
}
testWidgets('reopens the persisted page on mount', (tester) async {
await tester.pumpWidget(_host(initialSlug: 'about'));
await tester.pumpAndSettle();
expect(_shownIndex(tester), 1);
expect(find.text('PAGE:about'), findsOneWidget);
expect(_appBarText('Über uns'), findsOneWidget);
expect(_homeEnabled(tester), isTrue);
});
testWidgets('falls back to home and drops a slug that is gone', (
tester,
) async {
String? persisted = 'ghost';
await tester.pumpWidget(
_host(initialSlug: 'ghost', onSelectionChanged: (slug) => persisted = slug),
);
await tester.pumpAndSettle();
expect(_shownIndex(tester), 0);
expect(_appBarText('Ticker'), findsOneWidget);
expect(persisted, isNull);
});
testWidgets('does not restore a redirect slug', (tester) async {
await tester.pumpWidget(_host(initialSlug: 'web'));
await tester.pumpAndSettle();
expect(_shownIndex(tester), 0);
});
testWidgets('reports selection changes so the host can persist them', (
tester,
) async {
final changes = <String?>[];
await pumpWide(tester, _host(onSelectionChanged: changes.add));
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.home_outlined));
await tester.pumpAndSettle();
expect(changes, ['about', null]);
});
testWidgets('drops the open page when a fresh nav no longer lists it', (
tester,
) async {
String? persisted;
await pumpWide(
tester,
_host(onSelectionChanged: (slug) => persisted = slug),
);
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
expect(_shownIndex(tester), 1);
await tester.pumpWidget(
_host(
sections: [
TickerNavSection(
title: 'Infos',
pages: [
TickerNavPage(
title: 'Anderes',
slug: 'other',
kind: TickerPageKind.content,
),
],
),
],
onSelectionChanged: (slug) => persisted = slug,
),
);
await tester.pumpAndSettle();
expect(_shownIndex(tester), 0);
expect(_appBarText('Ticker'), findsOneWidget);
expect(persisted, isNull);
});
});
group('home date button', () {
testWidgets('shows the publish date on home and refreshes on tap', (
tester,
) async {
var refreshed = 0;
await tester.pumpWidget(
_host(
homePublishedAt: '2026-02-17T14:30:00',
onRefreshHome: () => refreshed++,
),
);
await tester.pumpAndSettle();
expect(find.text('17.02.2026, 14:30'), findsOneWidget);
await tester.tap(find.text('17.02.2026, 14:30'));
await tester.pumpAndSettle();
expect(refreshed, 1);
});
testWidgets('hides the date button when there is no date', (tester) async {
await tester.pumpWidget(_host());
await tester.pumpAndSettle();
expect(find.byIcon(Icons.schedule), findsNothing);
});
testWidgets('hides the date button once a page is open', (tester) async {
tester.view.physicalSize = const Size(1200, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host(homePublishedAt: '2026-02-17T14:30:00'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.schedule), findsOneWidget);
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.schedule), findsNothing);
});
});
@@ -0,0 +1,95 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_layout_grid/flutter_layout_grid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/widget/prosemirror/pm_document_view.dart';
import 'package:marianum_mobile/widget/prosemirror/pm_node.dart';
import 'package:marianum_mobile/widget/prosemirror/pm_table_view.dart';
PmNode _tableDoc() {
final raw = File(
'test/widget/prosemirror/fixtures/table.json',
).readAsStringSync();
return PmNode.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
Widget _host(PmNode doc) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(child: PmDocumentView(doc: doc)),
),
);
PmTableCell _textCell(String text) => PmTableCell(
children: [
PmParagraph(children: [PmText(text: text)]),
],
);
void main() {
setUp(() => pmTableMode.value = PmTableMode.scroll);
tearDown(() => pmTableMode.value = PmTableMode.scroll);
testWidgets('renders in every display mode without exception', (tester) async {
await tester.pumpWidget(_host(_tableDoc()));
for (final mode in PmTableMode.values) {
pmTableMode.value = mode;
await tester.pumpAndSettle();
expect(tester.takeException(), isNull, reason: 'mode $mode threw');
}
});
testWidgets('cells in a row share one height when wrapping', (tester) async {
// One row, two cells: a short one and one long enough to wrap at the
// constrained width. Both cell boxes must end up the same height.
final doc = PmTable(
children: [
PmTableRow(
children: [
_textCell('kurz'),
_textCell('ein deutlich laengerer Text der ganz sicher umbricht'),
],
),
],
);
pmTableMode.value = PmTableMode.wrap;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(width: 200, child: PmDocumentView(doc: doc)),
),
),
),
);
await tester.pumpAndSettle();
final boxes = find.descendant(
of: find.byType(LayoutGrid),
matching: find.byType(DecoratedBox),
);
expect(boxes, findsNWidgets(2));
final first = tester.getSize(boxes.at(0)).height;
final second = tester.getSize(boxes.at(1)).height;
expect(first, greaterThan(0));
expect(first, moreOrLessEquals(second, epsilon: 0.5));
});
testWidgets('tapping a toolbar button switches the shared mode', (
tester,
) async {
await tester.pumpWidget(_host(_tableDoc()));
expect(pmTableMode.value, PmTableMode.scroll);
await tester.tap(find.byTooltip('Spalten umbrechen').first);
await tester.pumpAndSettle();
expect(pmTableMode.value, PmTableMode.wrap);
await tester.tap(find.byTooltip('Verkleinern (alles auf einen Blick)').first);
await tester.pumpAndSettle();
expect(pmTableMode.value, PmTableMode.zoom);
});
}