diff --git a/lib/state/app/modules/ticker/bloc/ticker_state.dart b/lib/state/app/modules/ticker/bloc/ticker_state.dart index d00ab2b..49c5fd9 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_state.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_state.dart @@ -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 json) => _$TickerStateFromJson(json); diff --git a/lib/state/app/modules/ticker/bloc/ticker_state.freezed.dart b/lib/state/app/modules/ticker/bloc/ticker_state.freezed.dart index fecb91a..68e1d9c 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_state.freezed.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(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 get copyWith => _$TickerStateCopyWithImpl 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 Function( TickerResponse? ticker, TickerNavResponse? nav)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(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 Function( TickerResponse? ticker, TickerNavResponse? nav) $default,) {final _that = this; +@optionalTypeArgs TResult when(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? Function( TickerResponse? ticker, TickerNavResponse? nav)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(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 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 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?, )); } diff --git a/lib/state/app/modules/ticker/bloc/ticker_state.g.dart b/lib/state/app/modules/ticker/bloc/ticker_state.g.dart index 4726e1c..252682b 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_state.g.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_state.g.dart @@ -13,7 +13,12 @@ _TickerState _$TickerStateFromJson(Map json) => _TickerState( nav: json['nav'] == null ? null : TickerNavResponse.fromJson(json['nav'] as Map), + selectedSlug: json['selectedSlug'] as String?, ); Map _$TickerStateToJson(_TickerState instance) => - {'ticker': instance.ticker, 'nav': instance.nav}; + { + 'ticker': instance.ticker, + 'nav': instance.nav, + 'selectedSlug': instance.selectedSlug, + }; diff --git a/lib/view/pages/ticker/ticker_view.dart b/lib/view/pages/ticker/ticker_view.dart index b2200bf..c8432ca 100644 --- a/lib/view/pages/ticker/ticker_view.dart +++ b/lib/view/pages/ticker/ticker_view.dart @@ -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>( create: (context) => TickerBloc(), child: (context, bloc, _) { + final data = context.watch().state.data; final sections = - context - .watch() - .state - .data - ?.nav - ?.sections + data?.nav?.sections .where((section) => section.pages.isNotEmpty) .toList() ?? const []; return TickerScaffold( sections: sections, + initialSlug: data?.selectedSlug, + onSelectionChanged: (slug) => bloc.add( + Emit((state) => state.copyWith(selectedSlug: slug)), + ), + homePublishedAt: data?.ticker?.publishedAt, + onRefreshHome: bloc.retry, homeBuilder: (context, onLinkTap) => LoadableStateConsumer( 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 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 { - final GlobalKey _scaffoldKey = GlobalKey(); - String? _selectedSlug; String? _selectedTitle; @@ -109,6 +134,41 @@ class _TickerScaffoldState extends State { 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 { super.dispose(); } + bool _isSelectable(List 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 { _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 { _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 { if (url != null && url.isNotEmpty) { unawaited(AppRoutes.openExternalUrl(url)); } - _closeDrawerIfOpen(); } void _onLinkTap(String href) { @@ -180,8 +270,8 @@ class _TickerScaffoldState extends State { _selectedSlug = slug; _selectedTitle = _titleForSlug(slug); }); + widget.onSelectionChanged?.call(slug); _ensureBackEntry(); - _closeDrawerIfOpen(); return; } unawaited(AppRoutes.openExternalUrl(href)); @@ -233,19 +323,45 @@ class _TickerScaffoldState extends State { 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; diff --git a/lib/view/pages/ticker/widgets/ticker_nav_list.dart b/lib/view/pages/ticker/widgets/ticker_nav_list.dart index a9f38a2..997a3bb 100644 --- a/lib/view/pages/ticker/widgets/ticker_nav_list.dart +++ b/lib/view/pages/ticker/widgets/ticker_nav_list.dart @@ -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 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 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 _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 tickerNavItems( + BuildContext context, { + required List 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; } } diff --git a/lib/widget/prosemirror/pm_render_scope.dart b/lib/widget/prosemirror/pm_render_scope.dart index 7d360be..6070191 100644 --- a/lib/widget/prosemirror/pm_render_scope.dart +++ b/lib/widget/prosemirror/pm_render_scope.dart @@ -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()?.softWrap ?? + true; + + @override + bool updateShouldNotify(PmCellTextFlow oldWidget) => + oldWidget.softWrap != softWrap; +} diff --git a/lib/widget/prosemirror/pm_rich_text.dart b/lib/widget/prosemirror/pm_rich_text.dart index bb8d9d0..b989b7f 100644 --- a/lib/widget/prosemirror/pm_rich_text.dart +++ b/lib/widget/prosemirror/pm_rich_text.dart @@ -172,8 +172,15 @@ class _PmRichTextState extends State { } @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. diff --git a/lib/widget/prosemirror/pm_table_view.dart b/lib/widget/prosemirror/pm_table_view.dart index dcdc6cb..4a425fd 100644 --- a/lib/widget/prosemirror/pm_table_view.dart +++ b/lib/widget/prosemirror/pm_table_view.dart @@ -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 = 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 createState() => _PmTableViewState(); +} + +class _PmTableViewState extends State { + 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().toList(); + final rows = widget.node.children.whereType().toList(); if (rows.isEmpty) return const SizedBox.shrink(); final occupied = >{}; @@ -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( + 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 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( + 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 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, + ), + ), ), ); } diff --git a/test/view/ticker/ticker_scaffold_test.dart b/test/view/ticker/ticker_scaffold_test.dart index 67f03da..f37ddca 100644 --- a/test/view/ticker/ticker_scaffold_test.dart +++ b/test/view/ticker/ticker_scaffold_test.dart @@ -19,9 +19,19 @@ List _sections() => [ ), ]; -Widget _host() => MaterialApp( +Widget _host({ + List? 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( + 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(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 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 = []; + 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); }); }); diff --git a/test/widget/prosemirror/pm_table_view_test.dart b/test/widget/prosemirror/pm_table_view_test.dart new file mode 100644 index 0000000..12470b8 --- /dev/null +++ b/test/widget/prosemirror/pm_table_view_test.dart @@ -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); +} + +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); + }); +}