diff --git a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart index 4ada7b9..202a858 100644 --- a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart +++ b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart @@ -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 json) => diff --git a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.g.dart b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.g.dart index acdeccc..858da0a 100644 --- a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.g.dart +++ b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.g.dart @@ -20,6 +20,7 @@ TickerPageResponse _$TickerPageResponseFromJson(Map json) => filename: json['filename'] as String?, hash: json['hash'] as String?, webUrl: json['webUrl'] as String?, + publishedAt: json['publishedAt'] as String?, ); Map _$TickerPageResponseToJson(TickerPageResponse instance) => @@ -36,4 +37,5 @@ Map _$TickerPageResponseToJson(TickerPageResponse instance) => 'filename': instance.filename, 'hash': instance.hash, 'webUrl': instance.webUrl, + 'publishedAt': instance.publishedAt, }; diff --git a/lib/main.dart b/lib/main.dart index ee2880c..3035f41 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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, diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart b/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart new file mode 100644 index 0000000..48ae302 --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart @@ -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 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 json) => + TickerPageState.fromJson(json); + + @override + Map? toStorage(TickerPageState state) => state.toJson(); +} diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_event.dart b/lib/state/app/modules/ticker/bloc/ticker_page_event.dart new file mode 100644 index 0000000..6c0ae04 --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_page_event.dart @@ -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 {} + +class TickerPageLoadEvent extends TickerPageEvent {} diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_state.dart b/lib/state/app/modules/ticker/bloc/ticker_page_state.dart new file mode 100644 index 0000000..2031578 --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_page_state.dart @@ -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 json) => + _$TickerPageStateFromJson(json); +} diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_state.freezed.dart b/lib/state/app/modules/ticker/bloc/ticker_page_state.freezed.dart new file mode 100644 index 0000000..b1a6406 --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_page_state.freezed.dart @@ -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 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 get copyWith => _$TickerPageStateCopyWithImpl(this as TickerPageState, _$identity); + + /// Serializes this TickerPageState to a JSON map. + Map 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 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 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? 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 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 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? 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 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 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 diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_state.g.dart b/lib/state/app/modules/ticker/bloc/ticker_page_state.g.dart new file mode 100644 index 0000000..59bf6e5 --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_page_state.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ticker_page_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TickerPageState _$TickerPageStateFromJson(Map json) => + _TickerPageState( + page: json['page'] == null + ? null + : TickerPageResponse.fromJson(json['page'] as Map), + ); + +Map _$TickerPageStateToJson(_TickerPageState instance) => + {'page': instance.page}; diff --git a/lib/state/app/modules/ticker/repository/ticker_page_repository.dart b/lib/state/app/modules/ticker/repository/ticker_page_repository.dart new file mode 100644 index 0000000..24c5ff2 --- /dev/null +++ b/lib/state/app/modules/ticker/repository/ticker_page_repository.dart @@ -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 { + Future getPage(String slug) { + if (DemoMode.active) return Future.value(DemoTicker.page(slug)); + return GetTickerPage(slug).run(); + } + + Future getPageFile(String slug) { + if (DemoMode.active) return Future.value(Uint8List(0)); + return GetTickerPageFile(slug).run(); + } +} diff --git a/lib/state/app/modules/ticker/repository/ticker_repository.dart b/lib/state/app/modules/ticker/repository/ticker_repository.dart index 40d09d3..53f0e73 100644 --- a/lib/state/app/modules/ticker/repository/ticker_repository.dart +++ b/lib/state/app/modules/ticker/repository/ticker_repository.dart @@ -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 { if (DemoMode.active) return Future.value(DemoTicker.nav()); return GetTickerNav().run(); } - - Future getPage(String slug) { - if (DemoMode.active) return Future.value(DemoTicker.page(slug)); - return GetTickerPage(slug).run(); - } - - Future getPageFile(String slug) { - if (DemoMode.active) return Future.value(Uint8List(0)); - return GetTickerPageFile(slug).run(); - } } diff --git a/lib/view/pages/ticker/ticker_view.dart b/lib/view/pages/ticker/ticker_view.dart index c8432ca..1fec050 100644 --- a/lib/view/pages/ticker/ticker_view.dart +++ b/lib/view/pages/ticker/ticker_view.dart @@ -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((state) => state.copyWith(selectedSlug: slug)), ), - homePublishedAt: data?.ticker?.publishedAt, - onRefreshHome: bloc.retry, homeBuilder: (context, onLinkTap) => LoadableStateConsumer( 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 { 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; diff --git a/lib/view/pages/ticker/widgets/ticker_page_body.dart b/lib/view/pages/ticker/widgets/ticker_page_body.dart index c74c5ed..d4bda0c 100644 --- a/lib/view/pages/ticker/widgets/ticker_page_body.dart +++ b/lib/view/pages/ticker/widgets/ticker_page_body.dart @@ -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 createState() => _TickerPageBodyState(); + Widget build(BuildContext context) => + BlocModule>( + // A slug switch must rebuild the provider with a fresh bloc. + key: ValueKey(slug), + create: (context) => TickerPageBloc(slug), + child: (context, bloc, _) => + LoadableStateConsumer( + isReady: (state) => state.page != null, + child: (state, loading) => _TickerPageContent( + page: state.page!, + onLinkTap: onLinkTap, + onRedirect: onRedirect, + ), + ), + ); } -class _TickerPageBodyState extends State { - final TickerRepository _repo = TickerRepository(); - late Future _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( - 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().repo, + slug: page.slug ?? context.read().slug, + ), + ), + ], + ); default: final content = page.content; if (content == null) { @@ -120,9 +118,17 @@ class _TickerPageBodyState extends State { ); } 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 { } 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'), ) diff --git a/lib/view/pages/ticker/widgets/ticker_updated_bar.dart b/lib/view/pages/ticker/widgets/ticker_updated_bar.dart new file mode 100644 index 0000000..4ef31c9 --- /dev/null +++ b/lib/view/pages/ticker/widgets/ticker_updated_bar.dart @@ -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), + ), + ], + ), + ); + } +} diff --git a/lib/widget/avatar_disk_cache.dart b/lib/widget/avatar_disk_cache.dart new file mode 100644 index 0000000..14801b7 --- /dev/null +++ b/lib/widget/avatar_disk_cache.dart @@ -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? _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() { + return _dirFuture ??= _resolveDirectory(); + } + + Future _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__` 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 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 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 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 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 _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. + } + } +} diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 738ad48..696da73 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -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 { _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 _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) { diff --git a/test/view/ticker/ticker_scaffold_test.dart b/test/view/ticker/ticker_scaffold_test.dart index f37ddca..bc7b13f 100644 --- a/test/view/ticker/ticker_scaffold_test.dart +++ b/test/view/ticker/ticker_scaffold_test.dart @@ -23,15 +23,11 @@ Widget _host({ List? 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, diff --git a/test/view/ticker/ticker_updated_bar_test.dart b/test/view/ticker/ticker_updated_bar_test.dart new file mode 100644 index 0000000..1e69045 --- /dev/null +++ b/test/view/ticker/ticker_updated_bar_test.dart @@ -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); + }); +} diff --git a/test/widget/avatar_disk_cache_test.dart b/test/widget/avatar_disk_cache_test.dart new file mode 100644 index 0000000..b069a59 --- /dev/null +++ b/test/widget/avatar_disk_cache_test.dart @@ -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__`. + 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); + }, + ); + }); +}