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.freezed.dart';
|
||||||
part 'ticker_state.g.dart';
|
part 'ticker_state.g.dart';
|
||||||
|
|
||||||
/// Hydrated ticker module state: the current "Aktuelles" post plus the filtered
|
/// Hydrated ticker module state: the current "Aktuelles" post, the filtered
|
||||||
/// page tree. Page contents are loaded on demand in the detail screen and are
|
/// page tree and the slug of the page the user last had open ([selectedSlug],
|
||||||
/// deliberately not cached here.
|
/// 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
|
@freezed
|
||||||
abstract class TickerState with _$TickerState {
|
abstract class TickerState with _$TickerState {
|
||||||
const factory TickerState({TickerResponse? ticker, TickerNavResponse? nav}) =
|
const factory TickerState({
|
||||||
_TickerState;
|
TickerResponse? ticker,
|
||||||
|
TickerNavResponse? nav,
|
||||||
|
String? selectedSlug,
|
||||||
|
}) = _TickerState;
|
||||||
|
|
||||||
factory TickerState.fromJson(Map<String, dynamic> json) =>
|
factory TickerState.fromJson(Map<String, dynamic> json) =>
|
||||||
_$TickerStateFromJson(json);
|
_$TickerStateFromJson(json);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$TickerState {
|
mixin _$TickerState {
|
||||||
|
|
||||||
TickerResponse? get ticker; TickerNavResponse? get nav;
|
TickerResponse? get ticker; TickerNavResponse? get nav; String? get selectedSlug;
|
||||||
/// Create a copy of TickerState
|
/// Create a copy of TickerState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -28,16 +28,16 @@ $TickerStateCopyWith<TickerState> get copyWith => _$TickerStateCopyWithImpl<Tick
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,ticker,nav);
|
int get hashCode => Object.hash(runtimeType,ticker,nav,selectedSlug);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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;
|
factory $TickerStateCopyWith(TickerState value, $Res Function(TickerState) _then) = _$TickerStateCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
TickerResponse? ticker, TickerNavResponse? nav
|
TickerResponse? ticker, TickerNavResponse? nav, String? selectedSlug
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -65,11 +65,12 @@ class _$TickerStateCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of TickerState
|
/// Create a copy of TickerState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_self.copyWith(
|
||||||
ticker: freezed == ticker ? _self.ticker : ticker // ignore: cast_nullable_to_non_nullable
|
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 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) {
|
switch (_that) {
|
||||||
case _TickerState() when $default != null:
|
case _TickerState() when $default != null:
|
||||||
return $default(_that.ticker,_that.nav);case _:
|
return $default(_that.ticker,_that.nav,_that.selectedSlug);case _:
|
||||||
return orElse();
|
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) {
|
switch (_that) {
|
||||||
case _TickerState():
|
case _TickerState():
|
||||||
return $default(_that.ticker,_that.nav);case _:
|
return $default(_that.ticker,_that.nav,_that.selectedSlug);case _:
|
||||||
throw StateError('Unexpected subclass');
|
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) {
|
switch (_that) {
|
||||||
case _TickerState() when $default != null:
|
case _TickerState() when $default != null:
|
||||||
return $default(_that.ticker,_that.nav);case _:
|
return $default(_that.ticker,_that.nav,_that.selectedSlug);case _:
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -210,11 +211,12 @@ return $default(_that.ticker,_that.nav);case _:
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _TickerState implements TickerState {
|
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);
|
factory _TickerState.fromJson(Map<String, dynamic> json) => _$TickerStateFromJson(json);
|
||||||
|
|
||||||
@override final TickerResponse? ticker;
|
@override final TickerResponse? ticker;
|
||||||
@override final TickerNavResponse? nav;
|
@override final TickerNavResponse? nav;
|
||||||
|
@override final String? selectedSlug;
|
||||||
|
|
||||||
/// Create a copy of TickerState
|
/// Create a copy of TickerState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -229,16 +231,16 @@ Map<String, dynamic> toJson() {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,ticker,nav);
|
int get hashCode => Object.hash(runtimeType,ticker,nav,selectedSlug);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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;
|
factory _$TickerStateCopyWith(_TickerState value, $Res Function(_TickerState) _then) = __$TickerStateCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
TickerResponse? ticker, TickerNavResponse? nav
|
TickerResponse? ticker, TickerNavResponse? nav, String? selectedSlug
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -266,11 +268,12 @@ class __$TickerStateCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of TickerState
|
/// Create a copy of TickerState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_TickerState(
|
||||||
ticker: freezed == ticker ? _self.ticker : ticker // ignore: cast_nullable_to_non_nullable
|
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 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
|
nav: json['nav'] == null
|
||||||
? null
|
? null
|
||||||
: TickerNavResponse.fromJson(json['nav'] as Map<String, dynamic>),
|
: TickerNavResponse.fromJson(json['nav'] as Map<String, dynamic>),
|
||||||
|
selectedSlug: json['selectedSlug'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerStateToJson(_TickerState instance) =>
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/get_ticker_response.dart';
|
||||||
import '../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_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 '../../../routing/app_routes.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.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/loadable_state/view/loadable_state_consumer.dart';
|
||||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.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_bloc.dart';
|
||||||
import '../../../state/app/modules/ticker/bloc/ticker_state.dart';
|
import '../../../state/app/modules/ticker/bloc/ticker_state.dart';
|
||||||
import '../../../theming/app_theme.dart';
|
import '../../../theming/app_theme.dart';
|
||||||
|
import '../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import '../../../widget/prosemirror/pm_json_view.dart';
|
import '../../../widget/prosemirror/pm_json_view.dart';
|
||||||
import 'widgets/ticker_content_card.dart';
|
import 'widgets/ticker_content_card.dart';
|
||||||
@@ -29,19 +33,21 @@ class TickerView extends StatelessWidget {
|
|||||||
BlocModule<TickerBloc, LoadableState<TickerState>>(
|
BlocModule<TickerBloc, LoadableState<TickerState>>(
|
||||||
create: (context) => TickerBloc(),
|
create: (context) => TickerBloc(),
|
||||||
child: (context, bloc, _) {
|
child: (context, bloc, _) {
|
||||||
|
final data = context.watch<TickerBloc>().state.data;
|
||||||
final sections =
|
final sections =
|
||||||
context
|
data?.nav?.sections
|
||||||
.watch<TickerBloc>()
|
|
||||||
.state
|
|
||||||
.data
|
|
||||||
?.nav
|
|
||||||
?.sections
|
|
||||||
.where((section) => section.pages.isNotEmpty)
|
.where((section) => section.pages.isNotEmpty)
|
||||||
.toList() ??
|
.toList() ??
|
||||||
const <TickerNavSection>[];
|
const <TickerNavSection>[];
|
||||||
|
|
||||||
return TickerScaffold(
|
return TickerScaffold(
|
||||||
sections: sections,
|
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) =>
|
homeBuilder: (context, onLinkTap) =>
|
||||||
LoadableStateConsumer<TickerBloc, TickerState>(
|
LoadableStateConsumer<TickerBloc, TickerState>(
|
||||||
child: (state, loading) => _TickerHome(
|
child: (state, loading) => _TickerHome(
|
||||||
@@ -56,8 +62,8 @@ class TickerView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Bloc-free presentation shell: owns the in-place selection state, renders the
|
/// Bloc-free presentation shell: owns the in-place selection state, renders the
|
||||||
/// phone drawer / tablet sidebar navigation, the app bar (with home action) and
|
/// phone bottom-sheet nav / tablet sidebar navigation, the app bar (with home
|
||||||
/// swaps the content region between the home surface and a single page.
|
/// action) and swaps the content region between the home surface and a page.
|
||||||
class TickerScaffold extends StatefulWidget {
|
class TickerScaffold extends StatefulWidget {
|
||||||
final List<TickerNavSection> sections;
|
final List<TickerNavSection> sections;
|
||||||
|
|
||||||
@@ -78,11 +84,32 @@ class TickerScaffold extends StatefulWidget {
|
|||||||
)?
|
)?
|
||||||
pageBuilder;
|
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({
|
const TickerScaffold({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sections,
|
required this.sections,
|
||||||
required this.homeBuilder,
|
required this.homeBuilder,
|
||||||
this.pageBuilder,
|
this.pageBuilder,
|
||||||
|
this.initialSlug,
|
||||||
|
this.onSelectionChanged,
|
||||||
|
this.homePublishedAt,
|
||||||
|
this.onRefreshHome,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const double sidebarBreakpoint = 900;
|
static const double sidebarBreakpoint = 900;
|
||||||
@@ -93,8 +120,6 @@ class TickerScaffold extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TickerScaffoldState extends State<TickerScaffold> {
|
class _TickerScaffoldState extends State<TickerScaffold> {
|
||||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
|
||||||
|
|
||||||
String? _selectedSlug;
|
String? _selectedSlug;
|
||||||
String? _selectedTitle;
|
String? _selectedTitle;
|
||||||
|
|
||||||
@@ -109,6 +134,41 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
LocalHistoryEntry? _backEntry;
|
LocalHistoryEntry? _backEntry;
|
||||||
bool _disposing = false;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposing = true;
|
_disposing = true;
|
||||||
@@ -116,6 +176,15 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
super.dispose();
|
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() {
|
void _ensureBackEntry() {
|
||||||
if (_backEntry != null) return;
|
if (_backEntry != null) return;
|
||||||
final route = ModalRoute.of(context);
|
final route = ModalRoute.of(context);
|
||||||
@@ -143,17 +212,39 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
_selectedSlug = null;
|
_selectedSlug = null;
|
||||||
_selectedTitle = null;
|
_selectedTitle = null;
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _closeDrawerIfOpen() {
|
// On phones the section/page navigation lives in a modal bottom sheet opened
|
||||||
final scaffold = _scaffoldKey.currentState;
|
// from an app bar action (right), matching the app's actions-on-the-right and
|
||||||
if (scaffold != null && scaffold.isDrawerOpen) scaffold.closeDrawer();
|
// 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() {
|
void _selectHome() {
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
_removeBackEntry();
|
_removeBackEntry();
|
||||||
_closeDrawerIfOpen();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _selectPage(TickerNavPage page) {
|
void _selectPage(TickerNavPage page) {
|
||||||
@@ -161,8 +252,8 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
_selectedSlug = page.slug;
|
_selectedSlug = page.slug;
|
||||||
_selectedTitle = page.title;
|
_selectedTitle = page.title;
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(page.slug);
|
||||||
_ensureBackEntry();
|
_ensureBackEntry();
|
||||||
_closeDrawerIfOpen();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openRedirect(TickerNavPage page) {
|
void _openRedirect(TickerNavPage page) {
|
||||||
@@ -170,7 +261,6 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
if (url != null && url.isNotEmpty) {
|
if (url != null && url.isNotEmpty) {
|
||||||
unawaited(AppRoutes.openExternalUrl(url));
|
unawaited(AppRoutes.openExternalUrl(url));
|
||||||
}
|
}
|
||||||
_closeDrawerIfOpen();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLinkTap(String href) {
|
void _onLinkTap(String href) {
|
||||||
@@ -180,8 +270,8 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
_selectedSlug = slug;
|
_selectedSlug = slug;
|
||||||
_selectedTitle = _titleForSlug(slug);
|
_selectedTitle = _titleForSlug(slug);
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(slug);
|
||||||
_ensureBackEntry();
|
_ensureBackEntry();
|
||||||
_closeDrawerIfOpen();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(AppRoutes.openExternalUrl(href));
|
unawaited(AppRoutes.openExternalUrl(href));
|
||||||
@@ -233,19 +323,45 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
if (!didPop) _selectHome();
|
if (!didPop) _selectHome();
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: _scaffoldKey,
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
|
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
|
||||||
actions: [
|
actions: [
|
||||||
if (slug != null)
|
// The "Aktuelles" post's date, shown only on the home surface
|
||||||
IconButton(
|
// (pages carry no date). Tapping reloads the ticker.
|
||||||
icon: const Icon(Icons.home_outlined),
|
if (slug == null)
|
||||||
tooltip: 'Aktuelles',
|
Builder(
|
||||||
onPressed: _selectHome,
|
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
|
body: wide
|
||||||
? Row(
|
? Row(
|
||||||
children: [
|
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 {
|
class _UnavailableHint extends StatelessWidget {
|
||||||
final String webUrl;
|
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 '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||||
import '../../../../theming/app_theme.dart';
|
import '../../../../theming/app_theme.dart';
|
||||||
|
|
||||||
/// Shared navigation list for the ticker module, used both as the phone drawer
|
/// Shared navigation for the ticker module: the "Aktuelles" home entry followed
|
||||||
/// and as the permanent tablet sidebar. Lists the "Aktuelles" home entry
|
/// by the section-grouped pages. Used as the permanent tablet sidebar
|
||||||
/// followed by the section-grouped pages. [selectedSlug] is null while the home
|
/// ([TickerNavList]) and, via [tickerNavItems], inside the phone bottom sheet.
|
||||||
/// surface is shown.
|
/// [selectedSlug] is null while the home surface is shown.
|
||||||
class TickerNavList extends StatelessWidget {
|
class TickerNavList extends StatelessWidget {
|
||||||
final List<TickerNavSection> sections;
|
final List<TickerNavSection> sections;
|
||||||
final String? selectedSlug;
|
final String? selectedSlug;
|
||||||
@@ -25,9 +25,9 @@ class TickerNavList extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => ListView(
|
||||||
final theme = Theme.of(context);
|
padding: EdgeInsets.zero,
|
||||||
final children = <Widget>[
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.md,
|
AppSpacing.md,
|
||||||
@@ -35,24 +35,41 @@ class TickerNavList extends StatelessWidget {
|
|||||||
AppSpacing.md,
|
AppSpacing.md,
|
||||||
AppSpacing.sm,
|
AppSpacing.sm,
|
||||||
),
|
),
|
||||||
child: Text('Ticker', style: theme.textTheme.titleLarge),
|
child: Text('Ticker', style: Theme.of(context).textTheme.titleLarge),
|
||||||
),
|
),
|
||||||
ListTile(
|
...tickerNavItems(
|
||||||
leading: const Icon(Icons.campaign_outlined),
|
context,
|
||||||
title: const Text('Aktuelles'),
|
sections: sections,
|
||||||
selected: selectedSlug == null,
|
selectedSlug: selectedSlug,
|
||||||
selectedTileColor: theme.colorScheme.secondaryContainer,
|
onSelectHome: onSelectHome,
|
||||||
onTap: onSelectHome,
|
onSelectPage: onSelectPage,
|
||||||
|
onRedirect: onRedirect,
|
||||||
),
|
),
|
||||||
for (final section in sections) ..._section(context, section),
|
],
|
||||||
];
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ListView(padding: EdgeInsets.zero, children: children);
|
/// 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> _section(BuildContext context, TickerNavSection section) {
|
List<Widget> tickerNavItems(
|
||||||
final theme = Theme.of(context);
|
BuildContext context, {
|
||||||
return [
|
required List<TickerNavSection> sections,
|
||||||
|
required String? selectedSlug,
|
||||||
|
required VoidCallback onSelectHome,
|
||||||
|
required void Function(TickerNavPage page) onSelectPage,
|
||||||
|
required void Function(TickerNavPage page) onRedirect,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.campaign_outlined),
|
||||||
|
title: const Text('Aktuelles'),
|
||||||
|
selected: selectedSlug == null,
|
||||||
|
selectedTileColor: theme.colorScheme.secondaryContainer,
|
||||||
|
onTap: onSelectHome,
|
||||||
|
),
|
||||||
|
for (final section in sections) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.md,
|
AppSpacing.md,
|
||||||
@@ -80,17 +97,17 @@ class TickerNavList extends StatelessWidget {
|
|||||||
? onRedirect(page)
|
? onRedirect(page)
|
||||||
: onSelectPage(page),
|
: onSelectPage(page),
|
||||||
),
|
),
|
||||||
];
|
],
|
||||||
}
|
];
|
||||||
|
}
|
||||||
|
|
||||||
IconData _iconFor(String kind) {
|
IconData _iconFor(String kind) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case TickerPageKind.redirect:
|
case TickerPageKind.redirect:
|
||||||
return Icons.link;
|
return Icons.link;
|
||||||
case TickerPageKind.proxiedFile:
|
case TickerPageKind.proxiedFile:
|
||||||
return Icons.picture_as_pdf_outlined;
|
return Icons.picture_as_pdf_outlined;
|
||||||
default:
|
default:
|
||||||
return Icons.article_outlined;
|
return Icons.article_outlined;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,3 +20,24 @@ class PmRenderScope extends InheritedWidget {
|
|||||||
bool updateShouldNotify(PmRenderScope oldWidget) =>
|
bool updateShouldNotify(PmRenderScope oldWidget) =>
|
||||||
oldWidget.onLinkTap != onLinkTap;
|
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
|
@override
|
||||||
Widget build(BuildContext context) =>
|
Widget build(BuildContext context) {
|
||||||
Text.rich(_span, textAlign: widget.textAlign);
|
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.
|
/// 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 '../../theming/app_theme.dart';
|
||||||
import 'pm_document_view.dart';
|
import 'pm_document_view.dart';
|
||||||
import 'pm_node.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,
|
/// Renders a ProseMirror table. Flutter's built-in `Table` cannot span cells,
|
||||||
/// so `flutter_layout_grid` places each cell explicitly, honouring
|
/// so `flutter_layout_grid` places each cell explicitly, honouring
|
||||||
/// colspan/rowspan via a simple HTML-style occupancy scan.
|
/// 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;
|
final PmTable node;
|
||||||
|
|
||||||
static const double _minColumnWidth = 140;
|
|
||||||
|
|
||||||
const PmTableView({required this.node, super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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();
|
if (rows.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
final occupied = <int, Set<int>>{};
|
final occupied = <int, Set<int>>{};
|
||||||
@@ -38,7 +66,7 @@ class PmTableView extends StatelessWidget {
|
|||||||
columnSpan: cell.colspan,
|
columnSpan: cell.colspan,
|
||||||
rowStart: r,
|
rowStart: r,
|
||||||
rowSpan: cell.rowspan,
|
rowSpan: cell.rowspan,
|
||||||
child: _cell(context, cell),
|
child: _cell(context, cell, r),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
for (var dr = 0; dr < cell.rowspan; dr++) {
|
for (var dr = 0; dr < cell.rowspan; dr++) {
|
||||||
@@ -53,42 +81,228 @@ class PmTableView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
if (columnCount == 0) return const SizedBox.shrink();
|
if (columnCount == 0) return const SizedBox.shrink();
|
||||||
|
|
||||||
return LayoutBuilder(
|
return ValueListenableBuilder<PmTableMode>(
|
||||||
builder: (context, constraints) {
|
valueListenable: pmTableMode,
|
||||||
final available = constraints.maxWidth.isFinite
|
builder: (context, mode, _) => Column(
|
||||||
? constraints.maxWidth
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
: _minColumnWidth * columnCount;
|
mainAxisSize: MainAxisSize.min,
|
||||||
final columnWidth = max(_minColumnWidth, available / columnCount);
|
children: [
|
||||||
final totalWidth = columnWidth * columnCount;
|
_Toolbar(mode: mode),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
final grid = SizedBox(
|
_body(context, mode, rows.length, columnCount, placements),
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _cell(BuildContext context, PmTableCell cell) {
|
Widget _body(
|
||||||
|
BuildContext context,
|
||||||
|
PmTableMode mode,
|
||||||
|
int rowCount,
|
||||||
|
int columnCount,
|
||||||
|
List<Widget> placements,
|
||||||
|
) {
|
||||||
|
switch (mode) {
|
||||||
|
case PmTableMode.wrap:
|
||||||
|
return PmCellTextFlow(
|
||||||
|
softWrap: true,
|
||||||
|
child: LayoutGrid(
|
||||||
|
columnSizes: List.filled(columnCount, flex(1)),
|
||||||
|
rowSizes: List.filled(rowCount, auto),
|
||||||
|
children: placements,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case PmTableMode.zoom:
|
||||||
|
final grid = _naturalGrid(rowCount, columnCount, placements);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => SizedBox(
|
||||||
|
width: constraints.maxWidth,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: grid,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case PmTableMode.scroll:
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateShadows());
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (_) {
|
||||||
|
_updateShadows();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _scroll,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: _naturalGrid(rowCount, columnCount, placements),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_edgeShadow(context, left: true, visible: _showLeftShadow),
|
||||||
|
_edgeShadow(context, left: false, visible: _showRightShadow),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content-sized, single-line grid shared by the scroll and zoom modes.
|
||||||
|
Widget _naturalGrid(int rowCount, int columnCount, List<Widget> placements) =>
|
||||||
|
PmCellTextFlow(
|
||||||
|
softWrap: false,
|
||||||
|
child: LayoutGrid(
|
||||||
|
columnSizes: List.filled(columnCount, auto),
|
||||||
|
rowSizes: List.filled(rowCount, auto),
|
||||||
|
children: placements,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void _updateShadows() {
|
||||||
|
if (!_scroll.hasClients) return;
|
||||||
|
final pos = _scroll.position;
|
||||||
|
final canScroll = pos.maxScrollExtent > 0.5;
|
||||||
|
final showLeft = canScroll && pos.pixels > 0.5;
|
||||||
|
final showRight = canScroll && pos.pixels < pos.maxScrollExtent - 0.5;
|
||||||
|
if (showLeft != _showLeftShadow || showRight != _showRightShadow) {
|
||||||
|
setState(() {
|
||||||
|
_showLeftShadow = showLeft;
|
||||||
|
_showRightShadow = showRight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _edgeShadow(
|
||||||
|
BuildContext context, {
|
||||||
|
required bool left,
|
||||||
|
required bool visible,
|
||||||
|
}) {
|
||||||
|
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final color = Colors.black.withValues(alpha: dark ? 0.28 : 0.12);
|
||||||
|
return Positioned(
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: left ? 0 : null,
|
||||||
|
right: left ? null : 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: Container(
|
||||||
|
width: 22,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: left ? Alignment.centerLeft : Alignment.centerRight,
|
||||||
|
end: left ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
colors: [color, color.withValues(alpha: 0)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cell(BuildContext context, PmTableCell cell, int rowIndex) {
|
||||||
final theme = Theme.of(context);
|
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(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cell.header ? theme.colorScheme.surfaceContainerHighest : null,
|
color: background,
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Padding(
|
// The grid lays cells out with loose constraints, so a shorter cell would
|
||||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
// shrink below its row's height (set by the tallest cell) and its border
|
||||||
child: pmBlocks(cell.children, gap: AppSpacing.xs),
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
home: TickerScaffold(
|
||||||
sections: _sections(),
|
sections: sections ?? _sections(),
|
||||||
|
initialSlug: initialSlug,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
|
homePublishedAt: homePublishedAt,
|
||||||
|
onRefreshHome: onRefreshHome,
|
||||||
homeBuilder: (context, onLinkTap) => const Text('HOME'),
|
homeBuilder: (context, onLinkTap) => const Text('HOME'),
|
||||||
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
|
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
|
||||||
),
|
),
|
||||||
@@ -30,29 +40,50 @@ Widget _host() => MaterialApp(
|
|||||||
Finder _appBarText(String text) =>
|
Finder _appBarText(String text) =>
|
||||||
find.descendant(of: find.byType(AppBar), matching: find.text(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
|
// The home surface stays mounted (IndexedStack) while a page is open, so
|
||||||
// "which content is shown" is the stack index, not widget presence.
|
// "which content is shown" is the stack index, not widget presence.
|
||||||
int _shownIndex(WidgetTester tester) =>
|
int _shownIndex(WidgetTester tester) =>
|
||||||
tester.widget<IndexedStack>(find.byType(IndexedStack)).index!;
|
tester.widget<IndexedStack>(find.byType(IndexedStack)).index!;
|
||||||
|
|
||||||
const String _menuTooltip = 'Open navigation menu';
|
const String _navTooltip = 'Seiten';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('narrow layout (< 900)', () {
|
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.pumpWidget(_host());
|
||||||
await tester.pumpAndSettle();
|
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.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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(Drawer), findsOneWidget);
|
// Sheet shows the section-grouped nav.
|
||||||
expect(find.text('Über uns'), findsOneWidget);
|
|
||||||
expect(find.text('Aktuelles'), findsOneWidget);
|
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);
|
await pumpWide(tester);
|
||||||
|
|
||||||
expect(find.byType(Drawer), findsNothing);
|
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.
|
// Sidebar nav item is visible without any interaction.
|
||||||
expect(find.text('Über uns'), findsOneWidget);
|
expect(find.text('Über uns'), findsOneWidget);
|
||||||
});
|
});
|
||||||
@@ -83,7 +115,9 @@ void main() {
|
|||||||
expect(_shownIndex(tester), 0);
|
expect(_shownIndex(tester), 0);
|
||||||
expect(find.text('PAGE:about'), findsNothing);
|
expect(find.text('PAGE:about'), findsNothing);
|
||||||
expect(_appBarText('Ticker'), findsOneWidget);
|
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.tap(find.text('Über uns'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -91,7 +125,7 @@ void main() {
|
|||||||
expect(_shownIndex(tester), 1);
|
expect(_shownIndex(tester), 1);
|
||||||
expect(find.text('PAGE:about'), findsOneWidget);
|
expect(find.text('PAGE:about'), findsOneWidget);
|
||||||
expect(_appBarText('Über uns'), 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.tap(find.byIcon(Icons.home_outlined));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -99,7 +133,145 @@ void main() {
|
|||||||
expect(_shownIndex(tester), 0);
|
expect(_shownIndex(tester), 0);
|
||||||
expect(find.text('PAGE:about'), findsNothing);
|
expect(find.text('PAGE:about'), findsNothing);
|
||||||
expect(_appBarText('Ticker'), findsOneWidget);
|
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