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:
2026-07-09 00:51:08 +02:00
parent 1114291313
commit cedeb06569
57 changed files with 4758 additions and 43 deletions
@@ -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);
},
);
}