add ticker page bloc and avatar disk cache
This commit is contained in:
@@ -39,6 +39,11 @@ class TickerPageResponse {
|
|||||||
final String? hash;
|
final String? hash;
|
||||||
final String? webUrl;
|
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({
|
TickerPageResponse({
|
||||||
required this.schemaVersion,
|
required this.schemaVersion,
|
||||||
this.slug,
|
this.slug,
|
||||||
@@ -52,6 +57,7 @@ class TickerPageResponse {
|
|||||||
this.filename,
|
this.filename,
|
||||||
this.hash,
|
this.hash,
|
||||||
this.webUrl,
|
this.webUrl,
|
||||||
|
this.publishedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
|
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
|
|||||||
filename: json['filename'] as String?,
|
filename: json['filename'] as String?,
|
||||||
hash: json['hash'] as String?,
|
hash: json['hash'] as String?,
|
||||||
webUrl: json['webUrl'] as String?,
|
webUrl: json['webUrl'] as String?,
|
||||||
|
publishedAt: json['publishedAt'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
|
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
|
||||||
@@ -36,4 +37,5 @@ Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
|
|||||||
'filename': instance.filename,
|
'filename': instance.filename,
|
||||||
'hash': instance.hash,
|
'hash': instance.hash,
|
||||||
'webUrl': instance.webUrl,
|
'webUrl': instance.webUrl,
|
||||||
|
'publishedAt': instance.publishedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import 'utils/downloads/download_manager.dart';
|
|||||||
import 'view/login/login.dart';
|
import 'view/login/login.dart';
|
||||||
import 'view/login/post_login_splash.dart';
|
import 'view/login/post_login_splash.dart';
|
||||||
import 'widget/app_progress_indicator.dart';
|
import 'widget/app_progress_indicator.dart';
|
||||||
|
import 'widget/avatar_disk_cache.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
import 'widget/downloads/download_tray.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) {
|
if (kReleaseMode) {
|
||||||
ErrorWidget.builder = (error) => Material(
|
ErrorWidget.builder = (error) => Material(
|
||||||
color: Colors.white,
|
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/data/demo_ticker.dart';
|
||||||
import '../../../../../api/demo/demo_mode.dart';
|
import '../../../../../api/demo/demo_mode.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker.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/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.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.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 '../../../infrastructure/repository/repository.dart';
|
||||||
import '../bloc/ticker_state.dart';
|
import '../bloc/ticker_state.dart';
|
||||||
|
|
||||||
@@ -22,14 +17,4 @@ class TickerRepository extends Repository<TickerState> {
|
|||||||
if (DemoMode.active) return Future.value(DemoTicker.nav());
|
if (DemoMode.active) return Future.value(DemoTicker.nav());
|
||||||
return GetTickerNav().run();
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/get_ticker_response.dart';
|
||||||
import '../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_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_content_card.dart';
|
||||||
import 'widgets/ticker_nav_list.dart';
|
import 'widgets/ticker_nav_list.dart';
|
||||||
import 'widgets/ticker_page_body.dart';
|
import 'widgets/ticker_page_body.dart';
|
||||||
|
import 'widgets/ticker_updated_bar.dart';
|
||||||
|
|
||||||
/// Ticker module entry. Wires the [TickerBloc] to the presentation
|
/// Ticker module entry. Wires the [TickerBloc] to the presentation
|
||||||
/// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable
|
/// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable
|
||||||
@@ -46,8 +46,6 @@ class TickerView extends StatelessWidget {
|
|||||||
onSelectionChanged: (slug) => bloc.add(
|
onSelectionChanged: (slug) => bloc.add(
|
||||||
Emit<TickerState>((state) => state.copyWith(selectedSlug: slug)),
|
Emit<TickerState>((state) => state.copyWith(selectedSlug: slug)),
|
||||||
),
|
),
|
||||||
homePublishedAt: data?.ticker?.publishedAt,
|
|
||||||
onRefreshHome: bloc.retry,
|
|
||||||
homeBuilder: (context, onLinkTap) =>
|
homeBuilder: (context, onLinkTap) =>
|
||||||
LoadableStateConsumer<TickerBloc, TickerState>(
|
LoadableStateConsumer<TickerBloc, TickerState>(
|
||||||
child: (state, loading) => _TickerHome(
|
child: (state, loading) => _TickerHome(
|
||||||
@@ -93,14 +91,6 @@ class TickerScaffold extends StatefulWidget {
|
|||||||
/// it (null = home). Also fired when a stale [initialSlug] is discarded.
|
/// it (null = home). Also fired when a stale [initialSlug] is discarded.
|
||||||
final void Function(String? slug)? onSelectionChanged;
|
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({
|
const TickerScaffold({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sections,
|
required this.sections,
|
||||||
@@ -108,8 +98,6 @@ class TickerScaffold extends StatefulWidget {
|
|||||||
this.pageBuilder,
|
this.pageBuilder,
|
||||||
this.initialSlug,
|
this.initialSlug,
|
||||||
this.onSelectionChanged,
|
this.onSelectionChanged,
|
||||||
this.homePublishedAt,
|
|
||||||
this.onRefreshHome,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static const double sidebarBreakpoint = 900;
|
static const double sidebarBreakpoint = 900;
|
||||||
@@ -326,22 +314,6 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
|
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
|
||||||
actions: [
|
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
|
// Always present so "Aktuelles" is a fixed anchor; greyed out
|
||||||
// (disabled) while it is the current surface.
|
// (disabled) while it is the current surface.
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -415,8 +387,12 @@ class _TickerHome extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
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 {
|
class _UnavailableHint extends StatelessWidget {
|
||||||
final String webUrl;
|
final String webUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
|
||||||
import '../../../../api/errors/error_mapper.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 '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||||
import '../../../../routing/app_routes.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 '../../../../theming/app_theme.dart';
|
||||||
import '../../../../widget/placeholder_view.dart';
|
import '../../../../widget/placeholder_view.dart';
|
||||||
import '../../../../widget/prosemirror/pm_json_view.dart';
|
import '../../../../widget/prosemirror/pm_json_view.dart';
|
||||||
import 'ticker_content_card.dart';
|
import 'ticker_content_card.dart';
|
||||||
|
import 'ticker_updated_bar.dart';
|
||||||
|
|
||||||
/// Embeddable renderer for a single ticker page. Loads the page on demand and
|
/// Embeddable renderer for a single ticker page: drives a per-slug
|
||||||
/// renders it by kind: CONTENT via the native ProseMirror renderer,
|
/// [TickerPageBloc] through [LoadableStateConsumer], so pages behave like the
|
||||||
/// PROXIED_FILE as a PDF. It carries no Scaffold/AppBar so it can live both
|
/// home surface (cache, background refresh, offline banner, pull-to-refresh).
|
||||||
/// in-place inside [TickerView] and inside the standalone `TickerPageView`
|
/// Carries no Scaffold so it works both in-place in [TickerView] and in the
|
||||||
/// (deep links from outside the ticker module).
|
/// standalone `TickerPageView`. A REDIRECT opens the browser and invokes
|
||||||
///
|
/// [onRedirect] so the host can leave this page.
|
||||||
/// Reaching a REDIRECT here (e.g. via an internal content link whose slug turns
|
class TickerPageBody extends StatelessWidget {
|
||||||
/// out to be a redirect) opens the browser and, if given, invokes [onRedirect]
|
|
||||||
/// so the host can leave this page.
|
|
||||||
class TickerPageBody extends StatefulWidget {
|
|
||||||
final String slug;
|
final String slug;
|
||||||
final void Function(String href) onLinkTap;
|
final void Function(String href) onLinkTap;
|
||||||
final VoidCallback? onRedirect;
|
final VoidCallback? onRedirect;
|
||||||
@@ -35,75 +38,70 @@ class TickerPageBody extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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> {
|
/// Renders a resolved [TickerPageResponse] by kind. Stateful so a REDIRECT
|
||||||
final TickerRepository _repo = TickerRepository();
|
/// fires only once across the consumer's background-refresh rebuilds.
|
||||||
late Future<TickerPageResponse> _future;
|
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
|
@override
|
||||||
void initState() {
|
State<_TickerPageContent> createState() => _TickerPageContentState();
|
||||||
super.initState();
|
}
|
||||||
_future = _repo.getPage(widget.slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _reload() {
|
class _TickerPageContentState extends State<_TickerPageContent> {
|
||||||
setState(() => _future = _repo.getPage(widget.slug));
|
bool _redirected = false;
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => FutureBuilder<TickerPageResponse>(
|
Widget build(BuildContext context) {
|
||||||
future: _future,
|
final page = widget.page;
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
final error = snapshot.error;
|
|
||||||
if (error != null) return _buildError(context, error);
|
|
||||||
return _buildContent(context, snapshot.data!);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _buildError(BuildContext context, Object error) {
|
|
||||||
if (error is TickerContentUnavailableException) {
|
|
||||||
return PlaceholderView(
|
|
||||||
icon: Icons.public_off_outlined,
|
|
||||||
text: error.userMessage,
|
|
||||||
button: error.webUrl == null
|
|
||||||
? null
|
|
||||||
: ElevatedButton.icon(
|
|
||||||
onPressed: () => AppRoutes.openWebUrl(error.webUrl!),
|
|
||||||
icon: const Icon(Icons.open_in_new),
|
|
||||||
label: const Text('Im Browser öffnen'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return PlaceholderView(
|
|
||||||
icon: Icons.error_outline,
|
|
||||||
text: errorToUserMessage(error),
|
|
||||||
button: errorAllowsRetry(error)
|
|
||||||
? ElevatedButton.icon(
|
|
||||||
onPressed: _reload,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Erneut versuchen'),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContent(BuildContext context, TickerPageResponse page) {
|
|
||||||
switch (page.kind) {
|
switch (page.kind) {
|
||||||
case TickerPageKind.redirect:
|
case TickerPageKind.redirect:
|
||||||
final url = page.externalUrl;
|
final url = page.externalUrl;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
if (!_redirected) {
|
||||||
if (!mounted) return;
|
_redirected = true;
|
||||||
if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url);
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
widget.onRedirect?.call();
|
if (!mounted) return;
|
||||||
});
|
if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url);
|
||||||
|
widget.onRedirect?.call();
|
||||||
|
});
|
||||||
|
}
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
case TickerPageKind.proxiedFile:
|
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:
|
default:
|
||||||
final content = page.content;
|
final content = page.content;
|
||||||
if (content == null) {
|
if (content == null) {
|
||||||
@@ -120,9 +118,17 @@ class _TickerPageBodyState extends State<TickerPageBody> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
// Pull-to-refresh must trigger even when content fits the viewport.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||||||
child: TickerContentCard(
|
child: Column(
|
||||||
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
|
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 {
|
class _ProxiedFileView extends StatefulWidget {
|
||||||
final TickerRepository repo;
|
final TickerPageRepository repo;
|
||||||
final String slug;
|
final String slug;
|
||||||
|
|
||||||
const _ProxiedFileView({required this.repo, required this.slug});
|
const _ProxiedFileView({required this.repo, required this.slug});
|
||||||
@@ -162,9 +168,9 @@ class _ProxiedFileViewState extends State<_ProxiedFileView> {
|
|||||||
text: errorToUserMessage(error),
|
text: errorToUserMessage(error),
|
||||||
button: errorAllowsRetry(error)
|
button: errorAllowsRetry(error)
|
||||||
? ElevatedButton.icon(
|
? ElevatedButton.icon(
|
||||||
onPressed: () => setState(
|
onPressed: () => setState(() {
|
||||||
() => _bytes = widget.repo.getPageFile(widget.slug),
|
_bytes = widget.repo.getPageFile(widget.slug);
|
||||||
),
|
}),
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Erneut versuchen'),
|
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:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:http/http.dart' as http;
|
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/account_data.dart';
|
||||||
import '../model/endpoint_data.dart';
|
import '../model/endpoint_data.dart';
|
||||||
import '../push/push_avatar.dart';
|
import '../push/push_avatar.dart';
|
||||||
|
import 'avatar_disk_cache.dart';
|
||||||
|
|
||||||
class UserAvatar extends StatefulWidget {
|
class UserAvatar extends StatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -84,10 +85,12 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
|
|||||||
if (id == null) {
|
if (id == null) {
|
||||||
_resolvedAvatars.clear();
|
_resolvedAvatars.clear();
|
||||||
_pendingAvatars.clear();
|
_pendingAvatars.clear();
|
||||||
|
unawaited(AvatarDiskCache.instance.clear());
|
||||||
} else if (isGroup == true) {
|
} else if (isGroup == true) {
|
||||||
final url = avatarUrl(id: id, isGroup: true);
|
final url = avatarUrl(id: id, isGroup: true);
|
||||||
_resolvedAvatars.remove(url);
|
_resolvedAvatars.remove(url);
|
||||||
_pendingAvatars.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
|
// Keep the push-notification disk cache in sync — it serves the same
|
||||||
// room avatar to the FCM background isolate.
|
// room avatar to the FCM background isolate.
|
||||||
unawaited(PushAvatarStore.evict(id));
|
unawaited(PushAvatarStore.evict(id));
|
||||||
@@ -97,6 +100,7 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
|
|||||||
final prefix = 'https://$host/avatar/$id/';
|
final prefix = 'https://$host/avatar/$id/';
|
||||||
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
|
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
|
||||||
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
|
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
|
||||||
|
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false));
|
||||||
}
|
}
|
||||||
_avatarCacheGeneration.value++;
|
_avatarCacheGeneration.value++;
|
||||||
}
|
}
|
||||||
@@ -170,34 +174,133 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
_payload = cached.payload;
|
_payload = cached.payload;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_payload = null;
|
// Capture the subject once — later async steps must not read widget.* since
|
||||||
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
|
// the widget may have been recycled onto a different id by then.
|
||||||
pending.then((p) {
|
final id = widget.id;
|
||||||
_writeAvatarCache(url, p);
|
final isGroup = widget.isGroup;
|
||||||
_pendingAvatars.remove(url);
|
final size = _resolvedRequestSize();
|
||||||
if (!mounted || _url() != url) return;
|
|
||||||
setState(() => _payload = p);
|
// 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 {
|
/// Fills the placeholder from disk (async path, for the first avatar of a
|
||||||
try {
|
/// session), then always refreshes over the network so a changed server-side
|
||||||
final response = await http.get(
|
/// picture replaces the cached one. Network work is deduped across every
|
||||||
Uri.parse(url),
|
/// widget showing the same avatar via [_pendingAvatars].
|
||||||
headers: {
|
Future<void> _resolve(
|
||||||
'Authorization': AccountData().getBasicAuthHeader(),
|
String url,
|
||||||
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
|
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;
|
if (diskBytes != null && mounted && _url() == url && _payload == null) {
|
||||||
|
final payload = _payloadFromBytes(diskBytes);
|
||||||
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
|
_writeAvatarCache(url, payload);
|
||||||
final bytes = response.bodyBytes;
|
setState(() => _payload = payload);
|
||||||
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
|
}
|
||||||
return _AvatarPayload(bytes, isSvg);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
static bool _looksLikeSvg(Uint8List bytes) {
|
||||||
|
|||||||
@@ -23,15 +23,11 @@ Widget _host({
|
|||||||
List<TickerNavSection>? sections,
|
List<TickerNavSection>? sections,
|
||||||
String? initialSlug,
|
String? initialSlug,
|
||||||
void Function(String? slug)? onSelectionChanged,
|
void Function(String? slug)? onSelectionChanged,
|
||||||
String? homePublishedAt,
|
|
||||||
VoidCallback? onRefreshHome,
|
|
||||||
}) => MaterialApp(
|
}) => MaterialApp(
|
||||||
home: TickerScaffold(
|
home: TickerScaffold(
|
||||||
sections: sections ?? _sections(),
|
sections: sections ?? _sections(),
|
||||||
initialSlug: initialSlug,
|
initialSlug: initialSlug,
|
||||||
onSelectionChanged: onSelectionChanged,
|
onSelectionChanged: onSelectionChanged,
|
||||||
homePublishedAt: homePublishedAt,
|
|
||||||
onRefreshHome: onRefreshHome,
|
|
||||||
homeBuilder: (context, onLinkTap) => const Text('HOME'),
|
homeBuilder: (context, onLinkTap) => const Text('HOME'),
|
||||||
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
|
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', () {
|
group('back gesture', () {
|
||||||
testWidgets('pops from a sub-page back to home via the tab navigator', (
|
testWidgets('pops from a sub-page back to home via the tab navigator', (
|
||||||
tester,
|
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