implemented Ticker page selection persistence and enhanced ProseMirror table display modes
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
// 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: _selectHome,
|
||||
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,8 +35,33 @@ class TickerNavList extends StatelessWidget {
|
||||
AppSpacing.md,
|
||||
AppSpacing.sm,
|
||||
),
|
||||
child: Text('Ticker', style: theme.textTheme.titleLarge),
|
||||
child: Text('Ticker', style: Theme.of(context).textTheme.titleLarge),
|
||||
),
|
||||
...tickerNavItems(
|
||||
context,
|
||||
sections: sections,
|
||||
selectedSlug: selectedSlug,
|
||||
onSelectHome: onSelectHome,
|
||||
onSelectPage: onSelectPage,
|
||||
onRedirect: onRedirect,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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'),
|
||||
@@ -44,15 +69,7 @@ class TickerNavList extends StatelessWidget {
|
||||
selectedTileColor: theme.colorScheme.secondaryContainer,
|
||||
onTap: onSelectHome,
|
||||
),
|
||||
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 [
|
||||
for (final section in sections) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.md,
|
||||
@@ -80,6 +97,7 @@ class TickerNavList extends StatelessWidget {
|
||||
? onRedirect(page)
|
||||
: onSelectPage(page),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -93,4 +111,3 @@ class TickerNavList extends StatelessWidget {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,43 +81,229 @@ 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;
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final grid = SizedBox(
|
||||
width: totalWidth,
|
||||
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, fixed(columnWidth)),
|
||||
rowSizes: List.filled(rows.length, auto),
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
if (totalWidth <= available) return grid;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: grid,
|
||||
);
|
||||
},
|
||||
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) {
|
||||
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),
|
||||
),
|
||||
// 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.all(AppSpacing.sm),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user