add ticker page bloc and avatar disk cache

This commit is contained in:
2026-07-12 23:18:53 +02:00
parent 94794ff092
commit 9b5198c6db
18 changed files with 944 additions and 233 deletions
@@ -39,6 +39,11 @@ class TickerPageResponse {
final String? hash;
final String? webUrl;
/// ISO timestamp the page was last published/updated (`ticker_pages.published_at`),
/// shown as "Aktualisiert am …" — the same date the web view displays. Null
/// when the page has never been published.
final String? publishedAt;
TickerPageResponse({
required this.schemaVersion,
this.slug,
@@ -52,6 +57,7 @@ class TickerPageResponse {
this.filename,
this.hash,
this.webUrl,
this.publishedAt,
});
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
@@ -20,6 +20,7 @@ TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
filename: json['filename'] as String?,
hash: json['hash'] as String?,
webUrl: json['webUrl'] as String?,
publishedAt: json['publishedAt'] as String?,
);
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
@@ -36,4 +37,5 @@ Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
'filename': instance.filename,
'hash': instance.hash,
'webUrl': instance.webUrl,
'publishedAt': instance.publishedAt,
};
+6
View File
@@ -51,6 +51,7 @@ import 'utils/downloads/download_manager.dart';
import 'view/login/login.dart';
import 'view/login/post_login_splash.dart';
import 'widget/app_progress_indicator.dart';
import 'widget/avatar_disk_cache.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/cache_view.dart';
import 'widget/downloads/download_tray.dart';
@@ -150,6 +151,11 @@ Future<void> main() async {
);
}
// Resolve the avatar cache directory ahead of the first avatar render so the
// synchronous disk read hits and cold-start avatars appear without a blank
// placeholder flash.
AvatarDiskCache.instance.warmUp();
if (kReleaseMode) {
ErrorWidget.builder = (error) => Material(
color: Colors.white,
@@ -0,0 +1,60 @@
import '../../../../../api/errors/ticker_content_unavailable_exception.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/ticker_page_repository.dart';
import 'ticker_page_event.dart';
import 'ticker_page_state.dart';
/// Per-slug loadable bloc for a single ticker page; [id] is the slug so each
/// page keeps its own hydrated cache entry.
class TickerPageBloc
extends
LoadableHydratedBloc<
TickerPageEvent,
TickerPageState,
TickerPageRepository
> {
final String slug;
TickerPageBloc(this.slug);
@override
String get id => slug;
@override
Future<void> gatherData() async {
try {
final page = await repo.getPage(slug);
add(DataGathered((state) => state.copyWith(page: page)));
} on TickerContentUnavailableException catch (e) {
// Content, not error: a content-less page keeps the "open in browser"
// branch and stays cached offline.
add(
DataGathered(
(state) => state.copyWith(
page: TickerPageResponse(
schemaVersion: 1,
slug: slug,
kind: TickerPageKind.content,
webUrl: e.webUrl,
),
),
),
);
}
}
@override
TickerPageRepository repository() => TickerPageRepository();
@override
TickerPageState fromNothing() => const TickerPageState();
@override
TickerPageState fromStorage(Map<String, dynamic> json) =>
TickerPageState.fromJson(json);
@override
Map<String, dynamic>? toStorage(TickerPageState state) => state.toJson();
}
@@ -0,0 +1,6 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'ticker_page_state.dart';
sealed class TickerPageEvent extends LoadableHydratedBlocEvent<TickerPageState> {}
class TickerPageLoadEvent extends TickerPageEvent {}
@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
part 'ticker_page_state.freezed.dart';
part 'ticker_page_state.g.dart';
/// Hydrated per-slug state of a single ticker page. PROXIED_FILE bytes are not
/// cached here — only [page] metadata persists; the PDF is fetched live.
@freezed
abstract class TickerPageState with _$TickerPageState {
const factory TickerPageState({TickerPageResponse? page}) = _TickerPageState;
factory TickerPageState.fromJson(Map<String, dynamic> json) =>
_$TickerPageStateFromJson(json);
}
@@ -0,0 +1,277 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'ticker_page_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$TickerPageState {
TickerPageResponse? get page;
/// Create a copy of TickerPageState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$TickerPageStateCopyWith<TickerPageState> get copyWith => _$TickerPageStateCopyWithImpl<TickerPageState>(this as TickerPageState, _$identity);
/// Serializes this TickerPageState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is TickerPageState&&(identical(other.page, page) || other.page == page));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,page);
@override
String toString() {
return 'TickerPageState(page: $page)';
}
}
/// @nodoc
abstract mixin class $TickerPageStateCopyWith<$Res> {
factory $TickerPageStateCopyWith(TickerPageState value, $Res Function(TickerPageState) _then) = _$TickerPageStateCopyWithImpl;
@useResult
$Res call({
TickerPageResponse? page
});
}
/// @nodoc
class _$TickerPageStateCopyWithImpl<$Res>
implements $TickerPageStateCopyWith<$Res> {
_$TickerPageStateCopyWithImpl(this._self, this._then);
final TickerPageState _self;
final $Res Function(TickerPageState) _then;
/// Create a copy of TickerPageState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? page = freezed,}) {
return _then(_self.copyWith(
page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
as TickerPageResponse?,
));
}
}
/// Adds pattern-matching-related methods to [TickerPageState].
extension TickerPageStatePatterns on TickerPageState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _TickerPageState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _TickerPageState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _TickerPageState value) $default,){
final _that = this;
switch (_that) {
case _TickerPageState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _TickerPageState value)? $default,){
final _that = this;
switch (_that) {
case _TickerPageState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( TickerPageResponse? page)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _TickerPageState() when $default != null:
return $default(_that.page);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( TickerPageResponse? page) $default,) {final _that = this;
switch (_that) {
case _TickerPageState():
return $default(_that.page);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( TickerPageResponse? page)? $default,) {final _that = this;
switch (_that) {
case _TickerPageState() when $default != null:
return $default(_that.page);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _TickerPageState implements TickerPageState {
const _TickerPageState({this.page});
factory _TickerPageState.fromJson(Map<String, dynamic> json) => _$TickerPageStateFromJson(json);
@override final TickerPageResponse? page;
/// Create a copy of TickerPageState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$TickerPageStateCopyWith<_TickerPageState> get copyWith => __$TickerPageStateCopyWithImpl<_TickerPageState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$TickerPageStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TickerPageState&&(identical(other.page, page) || other.page == page));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,page);
@override
String toString() {
return 'TickerPageState(page: $page)';
}
}
/// @nodoc
abstract mixin class _$TickerPageStateCopyWith<$Res> implements $TickerPageStateCopyWith<$Res> {
factory _$TickerPageStateCopyWith(_TickerPageState value, $Res Function(_TickerPageState) _then) = __$TickerPageStateCopyWithImpl;
@override @useResult
$Res call({
TickerPageResponse? page
});
}
/// @nodoc
class __$TickerPageStateCopyWithImpl<$Res>
implements _$TickerPageStateCopyWith<$Res> {
__$TickerPageStateCopyWithImpl(this._self, this._then);
final _TickerPageState _self;
final $Res Function(_TickerPageState) _then;
/// Create a copy of TickerPageState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? page = freezed,}) {
return _then(_TickerPageState(
page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
as TickerPageResponse?,
));
}
}
// dart format on
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ticker_page_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_TickerPageState _$TickerPageStateFromJson(Map<String, dynamic> json) =>
_TickerPageState(
page: json['page'] == null
? null
: TickerPageResponse.fromJson(json['page'] as Map<String, dynamic>),
);
Map<String, dynamic> _$TickerPageStateToJson(_TickerPageState instance) =>
<String, dynamic>{'page': instance.page};
@@ -0,0 +1,23 @@
import 'dart:typed_data';
import '../../../../../api/demo/data/demo_ticker.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/ticker_page_state.dart';
/// Split from [TickerRepository] because the loadable base binds a repository
/// to its state type, and the per-page bloc is typed on [TickerPageState].
class TickerPageRepository extends Repository<TickerPageState> {
Future<TickerPageResponse> getPage(String slug) {
if (DemoMode.active) return Future.value(DemoTicker.page(slug));
return GetTickerPage(slug).run();
}
Future<Uint8List> getPageFile(String slug) {
if (DemoMode.active) return Future.value(Uint8List(0));
return GetTickerPageFile(slug).run();
}
}
@@ -1,14 +1,9 @@
import 'dart:typed_data';
import '../../../../../api/demo/data/demo_ticker.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker.dart';
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/ticker_state.dart';
@@ -22,14 +17,4 @@ class TickerRepository extends Repository<TickerState> {
if (DemoMode.active) return Future.value(DemoTicker.nav());
return GetTickerNav().run();
}
Future<TickerPageResponse> getPage(String slug) {
if (DemoMode.active) return Future.value(DemoTicker.page(slug));
return GetTickerPage(slug).run();
}
Future<Uint8List> getPageFile(String slug) {
if (DemoMode.active) return Future.value(Uint8List(0));
return GetTickerPageFile(slug).run();
}
}
+6 -70
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
@@ -21,6 +20,7 @@ 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';
import 'widgets/ticker_updated_bar.dart';
/// Ticker module entry. Wires the [TickerBloc] to the presentation
/// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable
@@ -46,8 +46,6 @@ class TickerView extends StatelessWidget {
onSelectionChanged: (slug) => bloc.add(
Emit<TickerState>((state) => state.copyWith(selectedSlug: slug)),
),
homePublishedAt: data?.ticker?.publishedAt,
onRefreshHome: bloc.retry,
homeBuilder: (context, onLinkTap) =>
LoadableStateConsumer<TickerBloc, TickerState>(
child: (state, loading) => _TickerHome(
@@ -93,14 +91,6 @@ class TickerScaffold extends StatefulWidget {
/// it (null = home). Also fired when a stale [initialSlug] is discarded.
final void Function(String? slug)? onSelectionChanged;
/// ISO publish timestamp of the current "Aktuelles" post, shown as a dated
/// refresh button in the app bar while the home surface is open. Null hides
/// the button (no post / no date / on a page, which carries no date).
final String? homePublishedAt;
/// Tapped from the app bar's dated button to reload the ticker.
final VoidCallback? onRefreshHome;
const TickerScaffold({
super.key,
required this.sections,
@@ -108,8 +98,6 @@ class TickerScaffold extends StatefulWidget {
this.pageBuilder,
this.initialSlug,
this.onSelectionChanged,
this.homePublishedAt,
this.onRefreshHome,
});
static const double sidebarBreakpoint = 900;
@@ -326,22 +314,6 @@ class _TickerScaffoldState extends State<TickerScaffold> {
appBar: AppBar(
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
actions: [
// The "Aktuelles" post's date, shown only on the home surface
// (pages carry no date). Tapping reloads the ticker.
if (slug == null)
Builder(
builder: (context) {
final updatedAt = _formatPublishedAt(
context,
widget.homePublishedAt,
);
if (updatedAt == null) return const SizedBox.shrink();
return _UpdatedAtButton(
text: updatedAt,
onTap: widget.onRefreshHome,
);
},
),
// Always present so "Aktuelles" is a fixed anchor; greyed out
// (disabled) while it is the current surface.
IconButton(
@@ -415,8 +387,12 @@ class _TickerHome extends StatelessWidget {
}
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
children: [_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap)],
children: [
TickerUpdatedBar(publishedAt: ticker.publishedAt),
_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap),
],
);
}
}
@@ -440,46 +416,6 @@ class _CurrentTickerCard extends StatelessWidget {
}
}
/// App bar button showing when the "Aktuelles" post was last updated; tapping
/// reloads the ticker. Styled to sit on the app bar (onSurface foreground) with
/// a clock icon and the full date in a tooltip.
class _UpdatedAtButton extends StatelessWidget {
final String text;
final VoidCallback? onTap;
const _UpdatedAtButton({required this.text, this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Tooltip(
message: 'Aktualisiert am $text',
child: TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.schedule, size: 16),
label: Text(text),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.onSurface,
textStyle: theme.textTheme.labelMedium,
),
),
),
);
}
}
/// Formats the ISO `publishedAt` as `dd.MM.yyyy, HH:mm` in the device locale, or
/// null when it is missing/unparseable so callers can drop the line entirely.
String? _formatPublishedAt(BuildContext context, String? iso) {
if (iso == null || iso.isEmpty) return null;
final parsed = DateTime.tryParse(iso);
if (parsed == null) return null;
final locale = Localizations.localeOf(context).toString();
return DateFormat('dd.MM.yyyy, HH:mm', locale).format(parsed.toLocal());
}
class _UnavailableHint extends StatelessWidget {
final String webUrl;
@@ -1,28 +1,31 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../../../../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_page_bloc.dart';
import '../../../../state/app/modules/ticker/bloc/ticker_page_state.dart';
import '../../../../state/app/modules/ticker/repository/ticker_page_repository.dart';
import '../../../../theming/app_theme.dart';
import '../../../../widget/placeholder_view.dart';
import '../../../../widget/prosemirror/pm_json_view.dart';
import 'ticker_content_card.dart';
import 'ticker_updated_bar.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 {
/// Embeddable renderer for a single ticker page: drives a per-slug
/// [TickerPageBloc] through [LoadableStateConsumer], so pages behave like the
/// home surface (cache, background refresh, offline banner, pull-to-refresh).
/// Carries no Scaffold so it works both in-place in [TickerView] and in the
/// standalone `TickerPageView`. A REDIRECT opens the browser and invokes
/// [onRedirect] so the host can leave this page.
class TickerPageBody extends StatelessWidget {
final String slug;
final void Function(String href) onLinkTap;
final VoidCallback? onRedirect;
@@ -35,75 +38,70 @@ class TickerPageBody extends StatefulWidget {
});
@override
State<TickerPageBody> createState() => _TickerPageBodyState();
Widget build(BuildContext context) =>
BlocModule<TickerPageBloc, LoadableState<TickerPageState>>(
// A slug switch must rebuild the provider with a fresh bloc.
key: ValueKey(slug),
create: (context) => TickerPageBloc(slug),
child: (context, bloc, _) =>
LoadableStateConsumer<TickerPageBloc, TickerPageState>(
isReady: (state) => state.page != null,
child: (state, loading) => _TickerPageContent(
page: state.page!,
onLinkTap: onLinkTap,
onRedirect: onRedirect,
),
),
);
}
class _TickerPageBodyState extends State<TickerPageBody> {
final TickerRepository _repo = TickerRepository();
late Future<TickerPageResponse> _future;
/// Renders a resolved [TickerPageResponse] by kind. Stateful so a REDIRECT
/// fires only once across the consumer's background-refresh rebuilds.
class _TickerPageContent extends StatefulWidget {
final TickerPageResponse page;
final void Function(String href) onLinkTap;
final VoidCallback? onRedirect;
const _TickerPageContent({
required this.page,
required this.onLinkTap,
this.onRedirect,
});
@override
void initState() {
super.initState();
_future = _repo.getPage(widget.slug);
}
State<_TickerPageContent> createState() => _TickerPageContentState();
}
void _reload() {
setState(() => _future = _repo.getPage(widget.slug));
}
class _TickerPageContentState extends State<_TickerPageContent> {
bool _redirected = false;
@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) {
Widget build(BuildContext context) {
final page = widget.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();
});
if (!_redirected) {
_redirected = true;
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);
return Column(
children: [
TickerUpdatedBar(publishedAt: page.publishedAt),
Expanded(
child: _ProxiedFileView(
repo: context.read<TickerPageBloc>().repo,
slug: page.slug ?? context.read<TickerPageBloc>().slug,
),
),
],
);
default:
final content = page.content;
if (content == null) {
@@ -120,9 +118,17 @@ class _TickerPageBodyState extends State<TickerPageBody> {
);
}
return SingleChildScrollView(
// Pull-to-refresh must trigger even when content fits the viewport.
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
child: TickerContentCard(
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TickerUpdatedBar(publishedAt: page.publishedAt),
TickerContentCard(
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
),
],
),
);
}
@@ -130,7 +136,7 @@ class _TickerPageBodyState extends State<TickerPageBody> {
}
class _ProxiedFileView extends StatefulWidget {
final TickerRepository repo;
final TickerPageRepository repo;
final String slug;
const _ProxiedFileView({required this.repo, required this.slug});
@@ -162,9 +168,9 @@ class _ProxiedFileViewState extends State<_ProxiedFileView> {
text: errorToUserMessage(error),
button: errorAllowsRetry(error)
? ElevatedButton.icon(
onPressed: () => setState(
() => _bytes = widget.repo.getPageFile(widget.slug),
),
onPressed: () => setState(() {
_bytes = widget.repo.getPageFile(widget.slug);
}),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
)
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../../../../extensions/date_time.dart';
import '../../../../theming/app_theme.dart';
/// Non-interactive "Aktualisiert am …" mini heading above the content card
/// (refresh is pull-to-refresh). Left-aligned with the card's content indent.
/// Renders nothing when [publishedAt] is missing/unparseable.
class TickerUpdatedBar extends StatelessWidget {
final String? publishedAt;
const TickerUpdatedBar({super.key, this.publishedAt});
@override
Widget build(BuildContext context) {
final iso = publishedAt;
final parsed = iso == null || iso.isEmpty ? null : DateTime.tryParse(iso);
if (parsed == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.sm + AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
AppSpacing.xs,
),
child: Row(
children: [
Icon(Icons.schedule, size: 13, color: muted),
const SizedBox(width: AppSpacing.xs),
Text(
'Aktualisiert am ${parsed.toLocal().formatDateTime()}',
style: theme.textTheme.labelSmall?.copyWith(color: muted),
),
],
),
);
}
}
+187
View File
@@ -0,0 +1,187 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
/// Persistent disk cache for the in-app [UserAvatar] widget.
///
/// The widget keeps a session-scoped in-memory LRU; this store survives app
/// restarts so a cold start paints the last-known picture on the first frame
/// instead of a blank placeholder while the network request is in flight.
/// Bytes are stored raw (PNG/JPEG/WEBP/SVG) — the widget re-detects SVG from
/// the bytes on read, so no content-type sidecar is needed.
///
/// Distinct from [PushAvatarStore], which caches PRE-MASKED round PNGs for the
/// FCM background isolate and only handles room avatars.
class AvatarDiskCache {
AvatarDiskCache._();
static final AvatarDiskCache instance = AvatarDiskCache._();
static const _subDirectory = 'avatar_cache';
/// Files older than this are pruned. Only bounds disk for subjects that are
/// no longer seen — freshness within a session is handled by the widget's
/// background refresh, which always re-fetches over the network.
static const Duration maxAge = Duration(days: 30);
// Memoized so the async directory lookup runs once, and its resolved path is
// exposed for the synchronous read path (zero-flash on warm sessions).
Future<Directory>? _dirFuture;
static String? _dirPath;
// Prune runs once per session after the first successful write, so a
// cold-start burst of avatar fetches doesn't re-list the directory per file.
bool _pruned = false;
/// Kicks off directory resolution so [readSync] can hit on the very first
/// avatar of a session. Fire-and-forget from app start; safe to call twice.
void warmUp() => unawaited(_directory());
Future<Directory> _directory() {
return _dirFuture ??= _resolveDirectory();
}
Future<Directory> _resolveDirectory() async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$_subDirectory');
await dir.create(recursive: true);
_dirPath = dir.path;
return dir;
}
/// File-safe, prefix-evictable name. The subject id is hex-encoded so it can
/// only contain `[0-9a-f]`, which keeps the `_` separator unambiguous: the
/// user prefix `u_<hex>_` never matches a longer id's file.
static String fileName({
required String id,
required bool isGroup,
required int size,
}) {
final hex = _hex(id);
// Group avatars are served at one fixed size (no size in the URL).
return isGroup ? 'g_$hex' : 'u_${hex}_$size';
}
static String _hex(String value) {
final buffer = StringBuffer();
for (final b in utf8.encode(value)) {
buffer.write(b.toRadixString(16).padLeft(2, '0'));
}
return buffer.toString();
}
/// Synchronous read for warm sessions (cache directory already resolved).
/// Returns null when the directory isn't known yet — the caller falls back
/// to [read]. Returns null on any error so a corrupt file never throws into
/// a build.
Uint8List? readSync({
required String id,
required bool isGroup,
required int size,
}) {
final path = _dirPath;
if (path == null) return null;
try {
final file = File(
'$path/${fileName(id: id, isGroup: isGroup, size: size)}',
);
if (!file.existsSync()) return null;
final bytes = file.readAsBytesSync();
return bytes.isEmpty ? null : bytes;
} on Object {
return null;
}
}
Future<Uint8List?> read({
required String id,
required bool isGroup,
required int size,
}) async {
try {
final dir = await _directory();
final file = File(
'${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}',
);
if (!file.existsSync()) return null;
final bytes = await file.readAsBytes();
return bytes.isEmpty ? null : bytes;
} on Object {
return null;
}
}
Future<void> write({
required String id,
required bool isGroup,
required int size,
required Uint8List bytes,
}) async {
try {
final dir = await _directory();
final file = File(
'${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}',
);
await file.writeAsBytes(bytes, flush: true);
if (!_pruned) {
_pruned = true;
unawaited(_prune(dir));
}
} on Object {
// Best effort — a failed write just means the next launch re-fetches.
}
}
/// Drops every cached size for a user (or the single file for a group).
/// Called from `invalidateAvatarCache` after an upload/removal or a 404.
Future<void> evict({required String id, required bool isGroup}) async {
try {
final dir = await _directory();
if (isGroup) {
final file = File('${dir.path}/${fileName(id: id, isGroup: true, size: 0)}');
if (file.existsSync()) await file.delete();
return;
}
final prefix = 'u_${_hex(id)}_';
await for (final entry in dir.list()) {
if (entry is! File) continue;
if (entry.uri.pathSegments.last.startsWith(prefix)) {
await entry.delete();
}
}
} on Object {
// Best effort — the 30-day max age catches stragglers.
}
}
/// Wipes the whole cache — used by the argument-less `invalidateAvatarCache`
/// (e.g. on logout).
Future<void> clear() async {
try {
final dir = await _directory();
if (dir.existsSync()) {
await for (final entry in dir.list()) {
if (entry is File) await entry.delete();
}
}
} on Object {
// Best effort.
}
}
Future<void> _prune(Directory dir) async {
try {
final now = DateTime.now();
await for (final entry in dir.list()) {
if (entry is! File) continue;
if (now.difference(entry.lastModifiedSync()) > maxAge) {
await entry.delete();
}
}
} on Object {
// Best effort.
}
}
}
+128 -25
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
@@ -10,6 +10,7 @@ import 'package:http/http.dart' as http;
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import '../push/push_avatar.dart';
import 'avatar_disk_cache.dart';
class UserAvatar extends StatefulWidget {
final String id;
@@ -84,10 +85,12 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
if (id == null) {
_resolvedAvatars.clear();
_pendingAvatars.clear();
unawaited(AvatarDiskCache.instance.clear());
} else if (isGroup == true) {
final url = avatarUrl(id: id, isGroup: true);
_resolvedAvatars.remove(url);
_pendingAvatars.remove(url);
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: true));
// Keep the push-notification disk cache in sync — it serves the same
// room avatar to the FCM background isolate.
unawaited(PushAvatarStore.evict(id));
@@ -97,6 +100,7 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
final prefix = 'https://$host/avatar/$id/';
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false));
}
_avatarCacheGeneration.value++;
}
@@ -170,34 +174,133 @@ class _UserAvatarState extends State<UserAvatar> {
_payload = cached.payload;
return;
}
_payload = null;
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
pending.then((p) {
_writeAvatarCache(url, p);
_pendingAvatars.remove(url);
if (!mounted || _url() != url) return;
setState(() => _payload = p);
});
// Capture the subject once — later async steps must not read widget.* since
// the widget may have been recycled onto a different id by then.
final id = widget.id;
final isGroup = widget.isGroup;
final size = _resolvedRequestSize();
// Persistent disk cache: on a warm session (cache directory already known)
// this hits synchronously, so a cold app start paints the last-known
// picture on the first frame instead of a blank placeholder.
final diskBytes = AvatarDiskCache.instance.readSync(
id: id,
isGroup: isGroup,
size: size,
);
if (diskBytes != null) {
final payload = _payloadFromBytes(diskBytes);
_payload = payload;
_writeAvatarCache(url, payload);
} else {
_payload = null;
}
unawaited(_resolve(url, id, isGroup, size, haveBytes: _payload != null));
}
Future<_AvatarPayload?> _fetch(String url) async {
try {
final response = await http.get(
Uri.parse(url),
headers: {
'Authorization': AccountData().getBasicAuthHeader(),
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
},
/// Fills the placeholder from disk (async path, for the first avatar of a
/// session), then always refreshes over the network so a changed server-side
/// picture replaces the cached one. Network work is deduped across every
/// widget showing the same avatar via [_pendingAvatars].
Future<void> _resolve(
String url,
String id,
bool isGroup,
int size, {
required bool haveBytes,
}) async {
if (!haveBytes) {
final diskBytes = await AvatarDiskCache.instance.read(
id: id,
isGroup: isGroup,
size: size,
);
if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null;
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
return _AvatarPayload(bytes, isSvg);
} catch (_) {
return null;
if (diskBytes != null && mounted && _url() == url && _payload == null) {
final payload = _payloadFromBytes(diskBytes);
_writeAvatarCache(url, payload);
setState(() => _payload = payload);
}
}
final pending = _pendingAvatars.putIfAbsent(url, () {
final future = _fetch(url);
future.whenComplete(() {
if (identical(_pendingAvatars[url], future)) _pendingAvatars.remove(url);
});
return future;
});
_AvatarPayload? fresh;
try {
fresh = await pending;
} on Object {
// Transient failure (offline, 5xx). Keep showing the cached picture; the
// next mount retries. Deliberately no null-cache so we don't mask it.
return;
}
_commit(url, id, isGroup, size, fresh);
if (!mounted || _url() != url) return;
if (fresh == null) {
// HTTP 404 — the avatar was removed server-side. Fall back to the icon.
if (_payload != null) setState(() => _payload = null);
} else if (!_sameBytes(_payload, fresh)) {
setState(() => _payload = fresh);
}
}
// Persists a resolved result to the in-memory and disk caches. Uses the
// captured subject (not widget.*) so a recycled widget can't misfile bytes.
void _commit(
String url,
String id,
bool isGroup,
int size,
_AvatarPayload? payload,
) {
_writeAvatarCache(url, payload);
if (payload != null) {
unawaited(
AvatarDiskCache.instance.write(
id: id,
isGroup: isGroup,
size: size,
bytes: payload.bytes,
),
);
} else {
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: isGroup));
}
}
static _AvatarPayload _payloadFromBytes(Uint8List bytes) =>
_AvatarPayload(bytes, _looksLikeSvg(bytes));
static bool _sameBytes(_AvatarPayload? a, _AvatarPayload? b) {
if (a == null || b == null) return a == b;
return listEquals(a.bytes, b.bytes);
}
/// Returns the avatar bytes, `null` for a definitive miss (HTTP 404 — no
/// avatar exists), or throws on a transient error (offline, non-200) so the
/// caller keeps the cached picture instead of blanking it.
Future<_AvatarPayload?> _fetch(String url) async {
final response = await http.get(
Uri.parse(url),
headers: {
'Authorization': AccountData().getBasicAuthHeader(),
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
},
);
if (response.statusCode == 404) return null;
if (response.statusCode != 200 || response.bodyBytes.isEmpty) {
throw Exception('avatar fetch failed: HTTP ${response.statusCode}');
}
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
return _AvatarPayload(bytes, isSvg);
}
static bool _looksLikeSvg(Uint8List bytes) {
@@ -23,15 +23,11 @@ Widget _host({
List<TickerNavSection>? sections,
String? initialSlug,
void Function(String? slug)? onSelectionChanged,
String? homePublishedAt,
VoidCallback? onRefreshHome,
}) => MaterialApp(
home: TickerScaffold(
sections: sections ?? _sections(),
initialSlug: initialSlug,
onSelectionChanged: onSelectionChanged,
homePublishedAt: homePublishedAt,
onRefreshHome: onRefreshHome,
homeBuilder: (context, onLinkTap) => const Text('HOME'),
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
),
@@ -231,50 +227,6 @@ void main() {
});
});
group('home date button', () {
testWidgets('shows the publish date on home and refreshes on tap', (
tester,
) async {
var refreshed = 0;
await tester.pumpWidget(
_host(
homePublishedAt: '2026-02-17T14:30:00',
onRefreshHome: () => refreshed++,
),
);
await tester.pumpAndSettle();
expect(find.text('17.02.2026, 14:30'), findsOneWidget);
await tester.tap(find.text('17.02.2026, 14:30'));
await tester.pumpAndSettle();
expect(refreshed, 1);
});
testWidgets('hides the date button when there is no date', (tester) async {
await tester.pumpWidget(_host());
await tester.pumpAndSettle();
expect(find.byIcon(Icons.schedule), findsNothing);
});
testWidgets('hides the date button once a page is open', (tester) async {
tester.view.physicalSize = const Size(1200, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host(homePublishedAt: '2026-02-17T14:30:00'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.schedule), findsOneWidget);
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.schedule), findsNothing);
});
});
group('back gesture', () {
testWidgets('pops from a sub-page back to home via the tab navigator', (
tester,
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jiffy/jiffy.dart';
import 'package:marianum_mobile/view/pages/ticker/widgets/ticker_updated_bar.dart';
Widget _host(String? publishedAt) =>
MaterialApp(home: Scaffold(body: TickerUpdatedBar(publishedAt: publishedAt)));
void main() {
setUpAll(() async {
await Jiffy.setLocale('de');
});
testWidgets('renders the formatted publish date', (tester) async {
await tester.pumpWidget(_host('2026-02-17T14:30:00'));
await tester.pumpAndSettle();
expect(find.text('Aktualisiert am 17.02.2026 14:30'), findsOneWidget);
expect(find.byIcon(Icons.schedule), findsOneWidget);
});
testWidgets('renders nothing without a date', (tester) async {
await tester.pumpWidget(_host(null));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.schedule), findsNothing);
expect(find.textContaining('Aktualisiert'), findsNothing);
});
testWidgets('renders nothing for an unparseable date', (tester) async {
await tester.pumpWidget(_host('not-a-date'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.schedule), findsNothing);
});
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/widget/avatar_disk_cache.dart';
void main() {
group('AvatarDiskCache.fileName', () {
test('user files carry the size, group files do not', () {
expect(
AvatarDiskCache.fileName(id: 'alice', isGroup: false, size: 256),
'u_616c696365_256',
);
expect(
AvatarDiskCache.fileName(id: 'alice', isGroup: false, size: 64),
'u_616c696365_64',
);
// Groups are served at one fixed size — the size argument is ignored.
expect(
AvatarDiskCache.fileName(id: 'room1', isGroup: true, size: 512),
AvatarDiskCache.fileName(id: 'room1', isGroup: true, size: 64),
);
expect(
AvatarDiskCache.fileName(id: 'room1', isGroup: true, size: 0),
startsWith('g_'),
);
});
test('names are file-safe even for exotic ids', () {
final name = AvatarDiskCache.fileName(
id: 'a/b c@d',
isGroup: false,
size: 128,
);
expect(name, matches(RegExp(r'^u_[0-9a-f]+_128$')));
});
test(
'a short id evict prefix never matches a longer id file (hex + _ '
'separator keeps the boundary unambiguous)',
() {
// Eviction deletes files starting with `u_<hex(id)>_`.
final shortPrefix =
'u_${AvatarDiskCache.fileName(id: 'a', isGroup: false, size: 1).split('_')[1]}_';
final longFile = AvatarDiskCache.fileName(
id: 'a_b',
isGroup: false,
size: 256,
);
expect(longFile.startsWith(shortPrefix), isFalse);
},
);
});
}