352 lines
10 KiB
Dart
352 lines
10 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 '../../../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/modules/ticker/bloc/ticker_bloc.dart';
|
|
import '../../../state/app/modules/ticker/bloc/ticker_state.dart';
|
|
import '../../../theming/app_theme.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';
|
|
|
|
/// 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 sections =
|
|
context
|
|
.watch<TickerBloc>()
|
|
.state
|
|
.data
|
|
?.nav
|
|
?.sections
|
|
.where((section) => section.pages.isNotEmpty)
|
|
.toList() ??
|
|
const <TickerNavSection>[];
|
|
|
|
return TickerScaffold(
|
|
sections: sections,
|
|
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 drawer / tablet sidebar navigation, the app bar (with home action) and
|
|
/// swaps the content region between the home surface and a single 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;
|
|
|
|
const TickerScaffold({
|
|
super.key,
|
|
required this.sections,
|
|
required this.homeBuilder,
|
|
this.pageBuilder,
|
|
});
|
|
|
|
static const double sidebarBreakpoint = 900;
|
|
static const double sidebarWidth = 300;
|
|
|
|
@override
|
|
State<TickerScaffold> createState() => _TickerScaffoldState();
|
|
}
|
|
|
|
class _TickerScaffoldState extends State<TickerScaffold> {
|
|
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
|
|
|
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 dispose() {
|
|
_disposing = true;
|
|
_removeBackEntry();
|
|
super.dispose();
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
void _closeDrawerIfOpen() {
|
|
final scaffold = _scaffoldKey.currentState;
|
|
if (scaffold != null && scaffold.isDrawerOpen) scaffold.closeDrawer();
|
|
}
|
|
|
|
void _selectHome() {
|
|
_clearSelection();
|
|
_removeBackEntry();
|
|
_closeDrawerIfOpen();
|
|
}
|
|
|
|
void _selectPage(TickerNavPage page) {
|
|
setState(() {
|
|
_selectedSlug = page.slug;
|
|
_selectedTitle = page.title;
|
|
});
|
|
_ensureBackEntry();
|
|
_closeDrawerIfOpen();
|
|
}
|
|
|
|
void _openRedirect(TickerNavPage page) {
|
|
final url = page.externalUrl;
|
|
if (url != null && url.isNotEmpty) {
|
|
unawaited(AppRoutes.openExternalUrl(url));
|
|
}
|
|
_closeDrawerIfOpen();
|
|
}
|
|
|
|
void _onLinkTap(String href) {
|
|
final slug = AppRoutes.tickerSlugOf(href);
|
|
if (slug != null && slug.isNotEmpty) {
|
|
setState(() {
|
|
_selectedSlug = slug;
|
|
_selectedTitle = _titleForSlug(slug);
|
|
});
|
|
_ensureBackEntry();
|
|
_closeDrawerIfOpen();
|
|
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(
|
|
key: _scaffoldKey,
|
|
appBar: AppBar(
|
|
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
|
|
actions: [
|
|
if (slug != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.home_outlined),
|
|
tooltip: 'Aktuelles',
|
|
onPressed: _selectHome,
|
|
),
|
|
],
|
|
),
|
|
drawer: wide ? null : Drawer(child: SafeArea(child: nav)),
|
|
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(
|
|
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
|
children: [_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'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|