add ticker page bloc and avatar disk cache
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user