Files
Client/lib/view/pages/ticker/ticker_view.dart
T

444 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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';
import 'widgets/ticker_nav_list.dart';
import 'widgets/ticker_page_body.dart';
import 'widgets/ticker_updated_bar.dart';
/// Ticker module entry. Wires the [TickerBloc] to the presentation
/// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable
/// consumer (loading/error/pull-to-refresh), individual pages load themselves.
class TickerView extends StatelessWidget {
const TickerView({super.key});
@override
Widget build(BuildContext context) =>
BlocModule<TickerBloc, LoadableState<TickerState>>(
create: (context) => TickerBloc(),
child: (context, bloc, _) {
final data = context.watch<TickerBloc>().state.data;
final 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)),
),
homeBuilder: (context, onLinkTap) =>
LoadableStateConsumer<TickerBloc, TickerState>(
child: (state, loading) => _TickerHome(
ticker: state.ticker,
hasSections: sections.isNotEmpty,
onLinkTap: onLinkTap,
),
),
);
},
);
}
/// Bloc-free presentation shell: owns the in-place selection state, renders the
/// 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;
/// Builds the "Aktuelles" home surface. Receives the in-place link handler so
/// internal ticker links switch the selection instead of pushing.
final Widget Function(
BuildContext context,
void Function(String href) onLinkTap,
)
homeBuilder;
/// Test seam: overrides the per-page content (default renders
/// [TickerPageBody]).
final Widget Function(
BuildContext context,
String slug,
void Function(String href) onLinkTap,
)?
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;
const TickerScaffold({
super.key,
required this.sections,
required this.homeBuilder,
this.pageBuilder,
this.initialSlug,
this.onSelectionChanged,
});
static const double sidebarBreakpoint = 900;
static const double sidebarWidth = 300;
@override
State<TickerScaffold> createState() => _TickerScaffoldState();
}
class _TickerScaffoldState extends State<TickerScaffold> {
String? _selectedSlug;
String? _selectedTitle;
// Back-gesture handling needs two pieces to cooperate with
// PersistentTabView: a PopScope (canPop=false on a sub-page) emits the
// NavigationNotification that makes the tab shell intercept the system back
// instead of popping the root route, and this LocalHistoryEntry is what the
// shell then finds poppable on the tab navigator (same mechanism a Drawer
// uses) — popping it returns to "Aktuelles". Either piece alone fails: the
// entry emits no notification (back closes the app), the scope alone leaves
// the tab navigator unpoppable (back switches tabs).
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;
_removeBackEntry();
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);
if (route == null) return;
final entry = LocalHistoryEntry(
onRemove: () {
_backEntry = null;
if (_disposing || !mounted) return;
_clearSelection();
},
);
_backEntry = entry;
route.addLocalHistoryEntry(entry);
}
void _removeBackEntry() {
final entry = _backEntry;
_backEntry = null;
entry?.remove();
}
void _clearSelection() {
if (_selectedSlug == null) return;
setState(() {
_selectedSlug = null;
_selectedTitle = null;
});
widget.onSelectionChanged?.call(null);
}
// 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();
}
void _selectPage(TickerNavPage page) {
setState(() {
_selectedSlug = page.slug;
_selectedTitle = page.title;
});
widget.onSelectionChanged?.call(page.slug);
_ensureBackEntry();
}
void _openRedirect(TickerNavPage page) {
final url = page.externalUrl;
if (url != null && url.isNotEmpty) {
unawaited(AppRoutes.openExternalUrl(url));
}
}
void _onLinkTap(String href) {
final slug = AppRoutes.tickerSlugOf(href);
if (slug != null && slug.isNotEmpty) {
setState(() {
_selectedSlug = slug;
_selectedTitle = _titleForSlug(slug);
});
widget.onSelectionChanged?.call(slug);
_ensureBackEntry();
return;
}
unawaited(AppRoutes.openExternalUrl(href));
}
String? _titleForSlug(String slug) {
for (final section in widget.sections) {
for (final page in section.pages) {
if (page.slug == slug) return page.title;
}
}
return null;
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= TickerScaffold.sidebarBreakpoint;
final slug = _selectedSlug;
final nav = TickerNavList(
sections: widget.sections,
selectedSlug: slug,
onSelectHome: _selectHome,
onSelectPage: _selectPage,
onRedirect: _openRedirect,
);
// Keep the "Aktuelles" surface permanently mounted (offstage while a
// page is open) so returning to it is instant — a re-mount would re-run
// PmJsonView's image precache and flash its spinner for a frame.
final content = IndexedStack(
index: slug == null ? 0 : 1,
sizing: StackFit.expand,
children: [
widget.homeBuilder(context, _onLinkTap),
if (slug == null)
const SizedBox.shrink()
else
(widget.pageBuilder ?? _defaultPage)(context, slug, _onLinkTap),
],
);
return PopScope(
canPop: slug == null,
// Normally the pop lands on the LocalHistoryEntry, not here; this only
// fires if the entry is missing (no enclosing route) as a fallback.
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _selectHome();
},
child: Scaffold(
appBar: AppBar(
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
actions: [
// 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,
),
],
),
// 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: [
SizedBox(width: TickerScaffold.sidebarWidth, child: nav),
const VerticalDivider(width: 1),
Expanded(child: content),
],
)
: content,
),
);
},
);
Widget _defaultPage(
BuildContext context,
String slug,
void Function(String href) onLinkTap,
) => TickerPageBody(
key: ValueKey(slug),
slug: slug,
onLinkTap: onLinkTap,
onRedirect: _selectHome,
);
}
/// The "Aktuelles" home surface: the current ticker post, or a hint when there
/// is no post (yet). Scrollable so the loadable consumer's pull-to-refresh
/// works.
class _TickerHome extends StatelessWidget {
final TickerResponse? ticker;
final bool hasSections;
final void Function(String href) onLinkTap;
const _TickerHome({
required this.ticker,
required this.hasSections,
required this.onLinkTap,
});
@override
Widget build(BuildContext context) {
final ticker = this.ticker;
if (ticker == null) {
return PlaceholderView(
icon: hasSections ? Icons.campaign_outlined : Icons.feed_outlined,
text: hasSections
? 'Zurzeit gibt es keine aktuelle Meldung.'
: 'Zurzeit sind keine Ticker-Inhalte verfügbar.',
);
}
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
children: [
TickerUpdatedBar(publishedAt: ticker.publishedAt),
_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap),
],
);
}
}
class _CurrentTickerCard extends StatelessWidget {
final TickerResponse ticker;
final void Function(String href) onLinkTap;
const _CurrentTickerCard({required this.ticker, required this.onLinkTap});
@override
Widget build(BuildContext context) {
final content = ticker.content;
final hasContent = ticker.available && content != null;
return TickerContentCard(
child: hasContent
? PmJsonView(json: content, onLinkTap: onLinkTap)
: _UnavailableHint(webUrl: ticker.webUrl),
);
}
}
class _UnavailableHint extends StatelessWidget {
final String webUrl;
const _UnavailableHint({required this.webUrl});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Diese Meldung ist in der App nicht verfügbar.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.xs),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => AppRoutes.openWebUrl(webUrl),
icon: const Icon(Icons.open_in_new),
label: const Text('Im Browser öffnen'),
),
),
],
);
}