implemented the Ticker module with a native ProseMirror document renderer and integrated API support for structured content, navigation trees, and proxied files
This commit is contained in:
@@ -25,6 +25,7 @@ class DefaultSettings {
|
||||
modulesSettings: ModulesSettings(
|
||||
moduleOrder: [
|
||||
Modules.timetable,
|
||||
Modules.ticker,
|
||||
Modules.talk,
|
||||
Modules.files,
|
||||
Modules.marianumMessage,
|
||||
|
||||
@@ -118,10 +118,19 @@ class ModuleSortBody extends StatelessWidget {
|
||||
.values
|
||||
.toList(),
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
var order = settings.val().modulesSettings.moduleOrder.toList();
|
||||
final movedModule = order.removeAt(oldIndex);
|
||||
order.insert(newIndex, movedModule);
|
||||
settings.val(write: true).modulesSettings.moduleOrder = order;
|
||||
final displayed = AppModule.modules(
|
||||
context,
|
||||
showFiltered: true,
|
||||
).keys.toList();
|
||||
settings.val(write: true).modulesSettings.moduleOrder =
|
||||
AppModule.reorderModuleOrder(
|
||||
displayed: displayed,
|
||||
effective: AppModule.effectiveModuleOrder(
|
||||
settings.val().modulesSettings,
|
||||
),
|
||||
oldIndex: oldIndex,
|
||||
newIndex: newIndex,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../routing/app_routes.dart';
|
||||
import 'widgets/ticker_page_body.dart';
|
||||
|
||||
/// Standalone detail screen for a single ticker page, used for deep links from
|
||||
/// outside the ticker module ([AppRoutes.openTickerPage]). Inside the ticker
|
||||
/// module itself pages render in-place via [TickerPageBody]. Internal content
|
||||
/// links keep the classic push behaviour here through [AppRoutes.openTickerLink].
|
||||
class TickerPageView extends StatelessWidget {
|
||||
final String slug;
|
||||
final String? title;
|
||||
|
||||
const TickerPageView({super.key, required this.slug, this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(title ?? 'Ticker')),
|
||||
body: TickerPageBody(
|
||||
slug: slug,
|
||||
onLinkTap: (href) => AppRoutes.openTickerLink(context, href),
|
||||
onRedirect: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../theming/app_theme.dart';
|
||||
|
||||
/// Shared surface for rendered ticker content so the "Aktuelles" home and the
|
||||
/// sub-pages get identical margin, padding and background tint.
|
||||
class TickerContentCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const TickerContentCard({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
0,
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(AppSpacing.md), child: child),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.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 '../../../../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.
|
||||
class TickerNavList extends StatelessWidget {
|
||||
final List<TickerNavSection> sections;
|
||||
final String? selectedSlug;
|
||||
final VoidCallback onSelectHome;
|
||||
final void Function(TickerNavPage page) onSelectPage;
|
||||
final void Function(TickerNavPage page) onRedirect;
|
||||
|
||||
const TickerNavList({
|
||||
super.key,
|
||||
required this.sections,
|
||||
required this.selectedSlug,
|
||||
required this.onSelectHome,
|
||||
required this.onSelectPage,
|
||||
required this.onRedirect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final children = <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.md,
|
||||
AppSpacing.md,
|
||||
AppSpacing.md,
|
||||
AppSpacing.sm,
|
||||
),
|
||||
child: Text('Ticker', style: theme.textTheme.titleLarge),
|
||||
),
|
||||
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) ..._section(context, section),
|
||||
];
|
||||
|
||||
return ListView(padding: EdgeInsets.zero, children: children);
|
||||
}
|
||||
|
||||
List<Widget> _section(BuildContext context, TickerNavSection section) {
|
||||
final theme = Theme.of(context);
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.md,
|
||||
AppSpacing.md,
|
||||
AppSpacing.md,
|
||||
AppSpacing.xs,
|
||||
),
|
||||
child: Text(
|
||||
section.title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final page in section.pages)
|
||||
ListTile(
|
||||
leading: Icon(_iconFor(page.kind)),
|
||||
title: Text(page.title, overflow: TextOverflow.ellipsis),
|
||||
trailing: page.kind == TickerPageKind.redirect
|
||||
? const Icon(Icons.open_in_new)
|
||||
: null,
|
||||
selected: page.slug == selectedSlug,
|
||||
selectedTileColor: theme.colorScheme.secondaryContainer,
|
||||
onTap: () => page.kind == TickerPageKind.redirect
|
||||
? onRedirect(page)
|
||||
: onSelectPage(page),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
IconData _iconFor(String kind) {
|
||||
switch (kind) {
|
||||
case TickerPageKind.redirect:
|
||||
return Icons.link;
|
||||
case TickerPageKind.proxiedFile:
|
||||
return Icons.picture_as_pdf_outlined;
|
||||
default:
|
||||
return Icons.article_outlined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../api/errors/ticker_content_unavailable_exception.dart';
|
||||
import '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/ticker/repository/ticker_repository.dart';
|
||||
import '../../../../theming/app_theme.dart';
|
||||
import '../../../../widget/placeholder_view.dart';
|
||||
import '../../../../widget/prosemirror/pm_json_view.dart';
|
||||
import 'ticker_content_card.dart';
|
||||
|
||||
/// Embeddable renderer for a single ticker page. Loads the page on demand and
|
||||
/// renders it by kind: CONTENT via the native ProseMirror renderer,
|
||||
/// PROXIED_FILE as a PDF. It carries no Scaffold/AppBar so it can live both
|
||||
/// in-place inside [TickerView] and inside the standalone `TickerPageView`
|
||||
/// (deep links from outside the ticker module).
|
||||
///
|
||||
/// Reaching a REDIRECT here (e.g. via an internal content link whose slug turns
|
||||
/// out to be a redirect) opens the browser and, if given, invokes [onRedirect]
|
||||
/// so the host can leave this page.
|
||||
class TickerPageBody extends StatefulWidget {
|
||||
final String slug;
|
||||
final void Function(String href) onLinkTap;
|
||||
final VoidCallback? onRedirect;
|
||||
|
||||
const TickerPageBody({
|
||||
super.key,
|
||||
required this.slug,
|
||||
required this.onLinkTap,
|
||||
this.onRedirect,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TickerPageBody> createState() => _TickerPageBodyState();
|
||||
}
|
||||
|
||||
class _TickerPageBodyState extends State<TickerPageBody> {
|
||||
final TickerRepository _repo = TickerRepository();
|
||||
late Future<TickerPageResponse> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _repo.getPage(widget.slug);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = _repo.getPage(widget.slug));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<TickerPageResponse>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final error = snapshot.error;
|
||||
if (error != null) return _buildError(context, error);
|
||||
return _buildContent(context, snapshot.data!);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildError(BuildContext context, Object error) {
|
||||
if (error is TickerContentUnavailableException) {
|
||||
return PlaceholderView(
|
||||
icon: Icons.public_off_outlined,
|
||||
text: error.userMessage,
|
||||
button: error.webUrl == null
|
||||
? null
|
||||
: ElevatedButton.icon(
|
||||
onPressed: () => AppRoutes.openWebUrl(error.webUrl!),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Im Browser öffnen'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return PlaceholderView(
|
||||
icon: Icons.error_outline,
|
||||
text: errorToUserMessage(error),
|
||||
button: errorAllowsRetry(error)
|
||||
? ElevatedButton.icon(
|
||||
onPressed: _reload,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut versuchen'),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, TickerPageResponse page) {
|
||||
switch (page.kind) {
|
||||
case TickerPageKind.redirect:
|
||||
final url = page.externalUrl;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url);
|
||||
widget.onRedirect?.call();
|
||||
});
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
case TickerPageKind.proxiedFile:
|
||||
return _ProxiedFileView(repo: _repo, slug: widget.slug);
|
||||
default:
|
||||
final content = page.content;
|
||||
if (content == null) {
|
||||
return PlaceholderView(
|
||||
icon: Icons.public_off_outlined,
|
||||
text: 'Dieser Inhalt ist in der App nicht verfügbar.',
|
||||
button: page.webUrl == null
|
||||
? null
|
||||
: ElevatedButton.icon(
|
||||
onPressed: () => AppRoutes.openWebUrl(page.webUrl!),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Im Browser öffnen'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||
child: TickerContentCard(
|
||||
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProxiedFileView extends StatefulWidget {
|
||||
final TickerRepository repo;
|
||||
final String slug;
|
||||
|
||||
const _ProxiedFileView({required this.repo, required this.slug});
|
||||
|
||||
@override
|
||||
State<_ProxiedFileView> createState() => _ProxiedFileViewState();
|
||||
}
|
||||
|
||||
class _ProxiedFileViewState extends State<_ProxiedFileView> {
|
||||
late Future<Uint8List> _bytes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bytes = widget.repo.getPageFile(widget.slug);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<Uint8List>(
|
||||
future: _bytes,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final error = snapshot.error;
|
||||
if (error != null) {
|
||||
return PlaceholderView(
|
||||
icon: Icons.error_outline,
|
||||
text: errorToUserMessage(error),
|
||||
button: errorAllowsRetry(error)
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => setState(
|
||||
() => _bytes = widget.repo.getPageFile(widget.slug),
|
||||
),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut versuchen'),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
final bytes = snapshot.data!;
|
||||
if (bytes.isEmpty) {
|
||||
return const PlaceholderView(
|
||||
icon: Icons.picture_as_pdf_outlined,
|
||||
text: 'Das Dokument konnte nicht geladen werden.',
|
||||
);
|
||||
}
|
||||
return SfPdfViewer.memory(bytes);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user