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,65 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../errors/ticker_content_unavailable_exception.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'get_ticker_page_response.dart';
/// Fetches a single ticker page from
/// `GET /api/mobile/v1/ticker/pages/{slug}`.
///
/// A CONTENT page whose ProseMirror JSON hasn't been backfilled yet answers
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
/// to a dedicated [TickerContentUnavailableException] carrying the browser
/// fallback URL, so the detail screen can offer "open in browser" instead of a
/// generic error.
class GetTickerPage {
final String slug;
final Dio _dio;
GetTickerPage(this.slug, {Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TickerPageResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve(
'ticker/pages/${Uri.encodeComponent(slug)}',
),
);
return TickerPageResponse.fromJson(response.data!);
} on DioException catch (e) {
throw _mapError(e);
}
}
Object _mapError(DioException e) {
final response = e.response;
if (response?.statusCode == 404) {
final body = _asMap(response?.data);
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
final webUrl = body['webUrl'];
return TickerContentUnavailableException(
webUrl: webUrl is String ? webUrl : null,
technicalDetails: 'MC 404 CONTENT_UNAVAILABLE for slug=$slug',
);
}
}
return mapMarianumConnectError(e);
}
Map<String, dynamic>? _asMap(dynamic data) {
if (data is Map<String, dynamic>) return data;
if (data is String && data.isNotEmpty) {
try {
final decoded = jsonDecode(data);
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {
return null;
}
}
return null;
}
}
@@ -0,0 +1,60 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_page_response.g.dart';
/// Canonical ticker page kinds. Kept as plain strings on the wire so an unknown
/// future kind never breaks JSON parsing.
abstract class TickerPageKind {
static const String content = 'CONTENT';
static const String redirect = 'REDIRECT';
static const String proxiedFile = 'PROXIED_FILE';
}
/// A single ticker page from `GET /api/mobile/v1/ticker/pages/{slug}`.
///
/// The populated fields depend on [kind]:
/// - `CONTENT` → [content] (nested ProseMirror document)
/// - `REDIRECT` → [externalUrl]
/// - `PROXIED_FILE` → [fileUrl], [contentType], [filename]
@JsonSerializable()
class TickerPageResponse {
@JsonKey(defaultValue: 1)
final int schemaVersion;
final String? slug;
final String? title;
@JsonKey(defaultValue: 'CONTENT')
final String kind;
final Map<String, dynamic>? content;
final String? externalUrl;
final bool? externalUrlNewTab;
final String? fileUrl;
final String? contentType;
final String? filename;
final String? hash;
final String? webUrl;
TickerPageResponse({
required this.schemaVersion,
this.slug,
this.title,
required this.kind,
this.content,
this.externalUrl,
this.externalUrlNewTab,
this.fileUrl,
this.contentType,
this.filename,
this.hash,
this.webUrl,
});
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
_$TickerPageResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerPageResponseToJson(this);
}
@@ -0,0 +1,39 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_page_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
TickerPageResponse(
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
slug: json['slug'] as String?,
title: json['title'] as String?,
kind: json['kind'] as String? ?? 'CONTENT',
content: json['content'] as Map<String, dynamic>?,
externalUrl: json['externalUrl'] as String?,
externalUrlNewTab: json['externalUrlNewTab'] as bool?,
fileUrl: json['fileUrl'] as String?,
contentType: json['contentType'] as String?,
filename: json['filename'] as String?,
hash: json['hash'] as String?,
webUrl: json['webUrl'] as String?,
);
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
<String, dynamic>{
'schemaVersion': instance.schemaVersion,
'slug': instance.slug,
'title': instance.title,
'kind': instance.kind,
'content': instance.content,
'externalUrl': instance.externalUrl,
'externalUrlNewTab': instance.externalUrlNewTab,
'fileUrl': instance.fileUrl,
'contentType': instance.contentType,
'filename': instance.filename,
'hash': instance.hash,
'webUrl': instance.webUrl,
};