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:
@@ -55,6 +55,8 @@ enum BreakerArea {
|
||||
files,
|
||||
@JsonValue('NEWS')
|
||||
news,
|
||||
@JsonValue('TICKER')
|
||||
ticker,
|
||||
@JsonValue('ROOMPLAN')
|
||||
roomPlan,
|
||||
@JsonValue('GRADES')
|
||||
|
||||
@@ -45,6 +45,7 @@ const _$BreakerAreaEnumMap = {
|
||||
BreakerArea.talk: 'TALK',
|
||||
BreakerArea.files: 'FILES',
|
||||
BreakerArea.news: 'NEWS',
|
||||
BreakerArea.ticker: 'TICKER',
|
||||
BreakerArea.roomPlan: 'ROOMPLAN',
|
||||
BreakerArea.grades: 'GRADES',
|
||||
BreakerArea.holidays: 'HOLIDAYS',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import 'get_ticker_response.dart';
|
||||
|
||||
/// Fetches the current "Aktuelles" ticker post from
|
||||
/// `GET /api/mobile/v1/ticker`. Bearer token is attached by the shared dio.
|
||||
class GetTicker {
|
||||
final Dio _dio;
|
||||
|
||||
GetTicker({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TickerResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('ticker'),
|
||||
);
|
||||
return TickerResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'get_ticker_response.g.dart';
|
||||
|
||||
/// The "Aktuelles" ticker post from `GET /api/mobile/v1/ticker`.
|
||||
///
|
||||
/// [available] is false when the current post has no ProseMirror JSON yet (a
|
||||
/// legacy post not re-saved since the dual-write rollout); the backend still
|
||||
/// returns 200 with `content: null` so the home surface degrades to a
|
||||
/// "open in browser" hint rather than an error. Unknown/missing flags default
|
||||
/// to a safe empty state.
|
||||
@JsonSerializable()
|
||||
class TickerResponse {
|
||||
@JsonKey(defaultValue: 1)
|
||||
final int schemaVersion;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool available;
|
||||
|
||||
final String? hash;
|
||||
final String? publishedAt;
|
||||
|
||||
/// Site-relative link to the web ticker (e.g. `/ticker`); the app prefixes
|
||||
/// the active Marianum-Connect base URL.
|
||||
@JsonKey(defaultValue: '/ticker')
|
||||
final String webUrl;
|
||||
|
||||
/// Nested ProseMirror document (`{ type: doc, content: [...] }`) or null when
|
||||
/// [available] is false. Parsed to a `PmNode` at render time.
|
||||
final Map<String, dynamic>? content;
|
||||
|
||||
TickerResponse({
|
||||
required this.schemaVersion,
|
||||
required this.available,
|
||||
this.hash,
|
||||
this.publishedAt,
|
||||
required this.webUrl,
|
||||
this.content,
|
||||
});
|
||||
|
||||
factory TickerResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$TickerResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TickerResponseToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'get_ticker_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) =>
|
||||
TickerResponse(
|
||||
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
|
||||
available: json['available'] as bool? ?? false,
|
||||
hash: json['hash'] as String?,
|
||||
publishedAt: json['publishedAt'] as String?,
|
||||
webUrl: json['webUrl'] as String? ?? '/ticker',
|
||||
content: json['content'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'schemaVersion': instance.schemaVersion,
|
||||
'available': instance.available,
|
||||
'hash': instance.hash,
|
||||
'publishedAt': instance.publishedAt,
|
||||
'webUrl': instance.webUrl,
|
||||
'content': instance.content,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import 'get_ticker_nav_response.dart';
|
||||
|
||||
/// Fetches the filtered ticker page tree from
|
||||
/// `GET /api/mobile/v1/ticker/pages`.
|
||||
class GetTickerNav {
|
||||
final Dio _dio;
|
||||
|
||||
GetTickerNav({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TickerNavResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('ticker/pages'),
|
||||
);
|
||||
return TickerNavResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'get_ticker_nav_response.g.dart';
|
||||
|
||||
/// The filtered ticker page tree from `GET /api/mobile/v1/ticker/pages`.
|
||||
///
|
||||
/// The backend already drops unpublished pages, pages the user may not see, and
|
||||
/// CONTENT pages without ProseMirror JSON — the app renders whatever arrives.
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class TickerNavResponse {
|
||||
@JsonKey(defaultValue: 1)
|
||||
final int schemaVersion;
|
||||
|
||||
@JsonKey(defaultValue: '')
|
||||
final String navHash;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
final List<TickerNavSection> sections;
|
||||
|
||||
TickerNavResponse({
|
||||
required this.schemaVersion,
|
||||
required this.navHash,
|
||||
required this.sections,
|
||||
});
|
||||
|
||||
factory TickerNavResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$TickerNavResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TickerNavResponseToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class TickerNavSection {
|
||||
@JsonKey(defaultValue: '')
|
||||
final String title;
|
||||
|
||||
@JsonKey(defaultValue: [])
|
||||
final List<TickerNavPage> pages;
|
||||
|
||||
TickerNavSection({required this.title, required this.pages});
|
||||
|
||||
factory TickerNavSection.fromJson(Map<String, dynamic> json) =>
|
||||
_$TickerNavSectionFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TickerNavSectionToJson(this);
|
||||
}
|
||||
|
||||
/// A single navigable page. [kind] is one of `CONTENT`, `REDIRECT`,
|
||||
/// `PROXIED_FILE` (see [TickerPageKind]); the extra fields are populated per
|
||||
/// kind (REDIRECT → [externalUrl], PROXIED_FILE → [fileUrl]).
|
||||
@JsonSerializable()
|
||||
class TickerNavPage {
|
||||
@JsonKey(defaultValue: '')
|
||||
final String title;
|
||||
|
||||
@JsonKey(defaultValue: '')
|
||||
final String slug;
|
||||
|
||||
@JsonKey(defaultValue: 'CONTENT')
|
||||
final String kind;
|
||||
|
||||
final String? hash;
|
||||
final String? externalUrl;
|
||||
final bool? externalUrlNewTab;
|
||||
final String? fileUrl;
|
||||
|
||||
TickerNavPage({
|
||||
required this.title,
|
||||
required this.slug,
|
||||
required this.kind,
|
||||
this.hash,
|
||||
this.externalUrl,
|
||||
this.externalUrlNewTab,
|
||||
this.fileUrl,
|
||||
});
|
||||
|
||||
factory TickerNavPage.fromJson(Map<String, dynamic> json) =>
|
||||
_$TickerNavPageFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TickerNavPageToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'get_ticker_nav_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) =>
|
||||
TickerNavResponse(
|
||||
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
|
||||
navHash: json['navHash'] as String? ?? '',
|
||||
sections:
|
||||
(json['sections'] as List<dynamic>?)
|
||||
?.map((e) => TickerNavSection.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'schemaVersion': instance.schemaVersion,
|
||||
'navHash': instance.navHash,
|
||||
'sections': instance.sections.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) =>
|
||||
TickerNavSection(
|
||||
title: json['title'] as String? ?? '',
|
||||
pages:
|
||||
(json['pages'] as List<dynamic>?)
|
||||
?.map((e) => TickerNavPage.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
|
||||
<String, dynamic>{
|
||||
'title': instance.title,
|
||||
'pages': instance.pages.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) =>
|
||||
TickerNavPage(
|
||||
title: json['title'] as String? ?? '',
|
||||
slug: json['slug'] as String? ?? '',
|
||||
kind: json['kind'] as String? ?? 'CONTENT',
|
||||
hash: json['hash'] as String?,
|
||||
externalUrl: json['externalUrl'] as String?,
|
||||
externalUrlNewTab: json['externalUrlNewTab'] as bool?,
|
||||
fileUrl: json['fileUrl'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) =>
|
||||
<String, dynamic>{
|
||||
'title': instance.title,
|
||||
'slug': instance.slug,
|
||||
'kind': instance.kind,
|
||||
'hash': instance.hash,
|
||||
'externalUrl': instance.externalUrl,
|
||||
'externalUrlNewTab': instance.externalUrlNewTab,
|
||||
'fileUrl': instance.fileUrl,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
|
||||
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
||||
/// `GET /api/mobile/v1/ticker/pages/{slug}/file`.
|
||||
///
|
||||
/// Goes through the shared MC dio so the bearer token is attached automatically
|
||||
/// (server enforces page visibility); the bytes are handed to `SfPdfViewer.memory`
|
||||
/// so no auth header has to be plumbed into the viewer itself.
|
||||
class GetTickerPageFile {
|
||||
final String slug;
|
||||
final Dio _dio;
|
||||
|
||||
GetTickerPageFile(this.slug, {Dio? dio})
|
||||
: _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<Uint8List> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<int>>(
|
||||
MarianumConnectEndpoint.resolve(
|
||||
'ticker/pages/${Uri.encodeComponent(slug)}/file',
|
||||
),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return Uint8List.fromList(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import 'get_ticker_sync_response.dart';
|
||||
|
||||
/// Fetches the ticker/nav change hashes from
|
||||
/// `GET /api/mobile/v1/ticker/sync`.
|
||||
class GetTickerSync {
|
||||
final Dio _dio;
|
||||
|
||||
GetTickerSync({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TickerSyncResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('ticker/sync'),
|
||||
);
|
||||
return TickerSyncResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'get_ticker_sync_response.g.dart';
|
||||
|
||||
/// Cheap change-detection poll from `GET /api/mobile/v1/ticker/sync`. Both
|
||||
/// hashes let the app decide whether the ticker post and/or the page tree need
|
||||
/// a full refetch without paying for the full payloads.
|
||||
@JsonSerializable()
|
||||
class TickerSyncResponse {
|
||||
final String? tickerHash;
|
||||
final String? navHash;
|
||||
|
||||
TickerSyncResponse({this.tickerHash, this.navHash});
|
||||
|
||||
factory TickerSyncResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$TickerSyncResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TickerSyncResponseToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'get_ticker_sync_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
TickerSyncResponse _$TickerSyncResponseFromJson(Map<String, dynamic> json) =>
|
||||
TickerSyncResponse(
|
||||
tickerHash: json['tickerHash'] as String?,
|
||||
navHash: json['navHash'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TickerSyncResponseToJson(TickerSyncResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'tickerHash': instance.tickerHash,
|
||||
'navHash': instance.navHash,
|
||||
};
|
||||
Reference in New Issue
Block a user