fix(mobile): face thumbnail stuck blank after renaming a person (#30769)

* fix(mobile): face thumbnail stuck blank after renaming a person

* flatten the codec error guard and rework the tests around framework driven reuse

* trim the image provider tests and note the per load state rule

* keep two small image provider tests: the renamed face and a late failure of a cancelled load

* clearer image provider tests: return the created completer, comment the steps, drop the unused listener, rename to thumbWithChecksum

* use the provider constructor with a lint ignore in the cache key test
This commit is contained in:
Santo Shakil
2026-09-30 00:43:21 +06:00
committed by GitHub
parent a6aa9fc253
commit a29153e90e
7 changed files with 185 additions and 101 deletions
@@ -12,20 +12,21 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:logging/logging.dart';
abstract class CancellableImageProvider<T extends Object> extends ImageProvider<T> {
void cancel();
}
mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvider<T> {
static final _log = Logger('CancellableImageProviderMixin');
// The provider is also the cache key and can be resolved more than once.
// Keep mutable state per load so cancelling one cannot affect the next load.
class ImageLoader {
static final _log = Logger('ImageLoader');
final ImageProvider key;
bool isCancelled = false;
bool isFinished = false;
ImageRequest? request;
CancelableOperation<ImageInfo?>? cachedOperation;
ImageInfo? getInitialImage(CancellableImageProvider provider) {
final completer = CancelableCompleter<ImageInfo?>(onCancel: provider.cancel);
ImageLoader(this.key);
ImageInfo? getInitialImage(ImageProvider provider) {
final completer = CancelableCompleter<ImageInfo?>();
final cachedStream = provider.resolve(ImageConfiguration.empty);
ImageInfo? cachedImage;
final listener = ImageStreamListener((image, synchronousCall) {
@@ -73,7 +74,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
if (isFinal) {
isFinished = true;
PaintingBinding.instance.imageCache.evict(this);
PaintingBinding.instance.imageCache.evict(key);
rethrow;
}
_log.warning('Non-fatal image load error', e, stack);
@@ -102,7 +103,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
if (isFinal) {
isFinished = true;
PaintingBinding.instance.imageCache.evict(this);
PaintingBinding.instance.imageCache.evict(key);
rethrow;
}
return null;
@@ -133,7 +134,6 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
@override
void cancel() {
isCancelled = true;
final hasActiveWork = !isFinished;
@@ -151,7 +151,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
if (hasActiveWork) {
PaintingBinding.instance.imageCache.evict(this);
PaintingBinding.instance.imageCache.evict(key);
}
}
}
@@ -13,8 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
// iOS GPU textures max out at 16384px; larger images squish.
const _kMaxPixelSize = 16384;
class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
with CancellableImageProviderMixin<LocalThumbProvider> {
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final String id;
final Size size;
final AssetType assetType;
@@ -22,7 +21,12 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
// an edit on the device keeps the id and changes the bytes, so the checksum is what separates two renders
final String? checksum;
LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution});
const LocalThumbProvider({
required this.id,
required this.assetType,
this.checksum,
this.size = kThumbnailResolution,
});
@override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -31,19 +35,20 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
@override
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
final loader = ImageLoader(key);
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
_codec(loader, key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
return loadRequest(request, decode, isFinal: true);
Stream<ImageInfo> _codec(ImageLoader loader, LocalThumbProvider key, ImageDecoderCallback decode) {
final request = loader.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
return loader.loadRequest(request, decode, isFinal: true);
}
@override
@@ -61,8 +66,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
int get hashCode => Object.hash(id, checksum);
}
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
with CancellableImageProviderMixin<LocalFullImageProvider> {
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final String id;
final Size size;
final AssetType assetType;
@@ -71,7 +75,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final int? height;
final String? checksum;
LocalFullImageProvider({
const LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
@@ -107,87 +111,96 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
@override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
final loader = ImageLoader(key);
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
stream: _animatedCodec(loader, key, decode),
scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
initialImage: loader.getInitialImage(
LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
_codec(loader, key, decode),
initialImage: loader.getInitialImage(
LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
Stream<ImageInfo> _codec(ImageLoader loader, LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* loader.initialImageStream();
if (isCancelled) {
if (loader.isCancelled) {
return;
}
final loadOriginal = SettingsRepository.instance.appConfig.image.loadOriginal;
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest(
var request = loader.request = LocalImageRequest(
localId: key.id,
size: _previewTarget(devicePixelRatio, !loadOriginal),
assetType: key.assetType,
);
yield* loadRequest(request, decode, isFinal: !loadOriginal);
yield* loader.loadRequest(request, decode, isFinal: !loadOriginal);
if (!loadOriginal) {
return;
}
if (isCancelled) {
if (loader.isCancelled) {
return;
}
request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
request = loader.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
yield* loadRequest(request, decode, isFinal: true);
yield* loader.loadRequest(request, decode, isFinal: true);
}
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
Stream<Object> _animatedCodec(ImageLoader loader, LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* loader.initialImageStream();
if (isCancelled) {
if (loader.isCancelled) {
return;
}
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final previewRequest = request = LocalImageRequest(
final previewRequest = loader.request = LocalImageRequest(
localId: key.id,
size: _previewTarget(devicePixelRatio, false),
assetType: key.assetType,
);
yield* loadRequest(previewRequest, decode, isFinal: false);
yield* loader.loadRequest(previewRequest, decode, isFinal: false);
if (isCancelled) {
if (loader.isCancelled) {
return;
}
// always try original for animated, since previews don't support animation
final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType);
final codec = await loadCodecRequest(originalRequest, isFinal: true);
final originalRequest = loader.request = LocalImageRequest(
localId: key.id,
size: Size.zero,
assetType: key.assetType,
);
final codec = await loader.loadCodecRequest(originalRequest, isFinal: true);
if (codec == null) {
if (isCancelled) {
if (loader.isCancelled) {
return;
}
throw StateError('Failed to load animated codec for local asset ${key.id}');
@@ -9,15 +9,14 @@ import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
with CancellableImageProviderMixin<RemoteImageProvider> {
class RemoteImageProvider extends ImageProvider<RemoteImageProvider> {
final String url;
final bool edited;
/// Physical size to decode, or null for the source size.
final Size? decodeSize;
RemoteImageProvider({required this.url, this.edited = true, this.decodeSize});
const RemoteImageProvider({required this.url, this.edited = true, this.decodeSize});
RemoteImageProvider.thumbnail({
required String assetId,
@@ -33,19 +32,20 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
@override
ImageStreamCompleter loadImage(RemoteImageProvider key, ImageDecoderCallback decode) {
final loader = ImageLoader(key);
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
_codec(loader, key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('URL', key.url),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(uri: key.url, decodeSize: key.decodeSize);
return loadRequest(request, decode, isFinal: true);
Stream<ImageInfo> _codec(ImageLoader loader, RemoteImageProvider key, ImageDecoderCallback decode) {
final request = loader.request = RemoteImageRequest(uri: key.url, decodeSize: key.decodeSize);
return loader.loadRequest(request, decode, isFinal: true);
}
@override
@@ -63,8 +63,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
int get hashCode => url.hashCode ^ edited.hashCode ^ decodeSize.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
final String assetId;
final String thumbhash;
final AssetType assetType;
@@ -74,7 +73,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
/// Physical size of the thumbnail shown before the preview.
final Size? thumbnailSize;
RemoteFullImageProvider({
const RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
@@ -90,11 +89,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
@override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
final loader = ImageLoader(key);
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
stream: _animatedCodec(loader, key, decode),
scale: 1.0,
initialImage: getInitialImage(
initialImage: loader.getInitialImage(
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, decodeSize: key.thumbnailSize),
),
informationCollector: () => <DiagnosticsNode>[
@@ -102,13 +102,13 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(
_codec(loader, key, decode),
initialImage: loader.getInitialImage(
RemoteImageProvider.thumbnail(
assetId: key.assetId,
thumbhash: key.thumbhash,
@@ -121,18 +121,18 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
Stream<ImageInfo> _codec(ImageLoader loader, RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* loader.initialImageStream();
if (isCancelled) {
if (loader.isCancelled) {
return;
}
final previewRequest = request = RemoteImageRequest(
final previewRequest = loader.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(
key.assetId,
type: AssetMediaSize.preview,
@@ -141,30 +141,30 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
),
);
final loadOriginal = assetType == AssetType.image && SettingsRepository.instance.appConfig.image.loadOriginal;
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
yield* loader.loadRequest(previewRequest, decode, isFinal: !loadOriginal);
if (!loadOriginal) {
return;
}
if (isCancelled) {
if (loader.isCancelled) {
return;
}
final originalRequest = request = RemoteImageRequest(
final originalRequest = loader.request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
);
yield* loadRequest(originalRequest, decode, isFinal: true);
yield* loader.loadRequest(originalRequest, decode, isFinal: true);
}
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
Stream<Object> _animatedCodec(ImageLoader loader, RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* loader.initialImageStream();
if (isCancelled) {
if (loader.isCancelled) {
return;
}
final previewRequest = request = RemoteImageRequest(
final previewRequest = loader.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(
key.assetId,
type: AssetMediaSize.preview,
@@ -172,19 +172,19 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
edited: key.edited,
),
);
yield* loadRequest(previewRequest, decode, isFinal: false);
yield* loader.loadRequest(previewRequest, decode, isFinal: false);
if (isCancelled) {
if (loader.isCancelled) {
return;
}
// always try original for animated, since previews don't support animation
final originalRequest = request = RemoteImageRequest(
final originalRequest = loader.request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
);
final codec = await loadCodecRequest(originalRequest, isFinal: true);
final codec = await loader.loadCodecRequest(originalRequest, isFinal: true);
if (codec == null) {
if (isCancelled) {
if (loader.isCancelled) {
return;
}
throw StateError('Failed to load animated codec for asset ${key.assetId}');
@@ -4,11 +4,10 @@ import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
with CancellableImageProviderMixin<ThumbHashProvider> {
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
final String thumbHash;
ThumbHashProvider({required this.thumbHash});
const ThumbHashProvider({required this.thumbHash});
@override
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
@@ -17,12 +16,16 @@ class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
@override
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onLastListenerRemoved: cancel);
final loader = ImageLoader(key);
return OneFramePlaceholderImageStreamCompleter(
_loadCodec(loader, key, decode),
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) {
final request = this.request = ThumbhashImageRequest(thumbhash: key.thumbHash);
return loadRequest(request, decode, isFinal: true);
Stream<ImageInfo> _loadCodec(ImageLoader loader, ThumbHashProvider key, ImageDecoderCallback decode) {
final request = loader.request = ThumbhashImageRequest(thumbhash: key.thumbHash);
return loader.loadRequest(request, decode, isFinal: true);
}
@override
@@ -101,14 +101,16 @@ void main() {
});
test('uses the decode size in the provider cache key', () {
final small = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: const ui.Size.square(160));
final large = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: const ui.Size.square(320));
const small = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: ui.Size.square(160));
const large = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: ui.Size.square(320));
expect(small, isNot(large));
});
test('shares the cache key when no decode size is set', () {
// ignore: prefer_const_constructors
final first = RemoteImageProvider(url: 'https://example.test/thumbnail');
// ignore: prefer_const_constructors
final second = RemoteImageProvider(url: 'https://example.test/thumbnail');
expect(first, second);
@@ -0,0 +1,63 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
void main() {
const api = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi';
const requestImage = BasicMessageChannel<Object?>('$api.requestImage', RemoteImageApi.pigeonChannelCodec);
const cancelRequest = BasicMessageChannel<Object?>('$api.cancelRequest', RemoteImageApi.pigeonChannelCodec);
testWidgets('a face url that changes while the avatar animates still loads', (tester) async {
final inFlight = <Object?, Object?>{};
tester.binding.defaultBinaryMessenger
..setMockDecodedMessageHandler<Object?>(requestImage, (message) {
final [url, id, ...] = message! as List<Object?>;
inFlight[id] = url;
return Completer<Object?>().future;
})
..setMockDecodedMessageHandler<Object?>(cancelRequest, (message) async {
inFlight.remove((message! as List<Object?>).first);
return const <Object?>[null];
});
Widget avatar(String url) => MaterialApp(
home: CircleAvatar(backgroundImage: RemoteImageProvider(url: url)),
);
await tester.pumpWidget(avatar('https://example.test/before'));
await tester.pumpWidget(avatar('https://example.test/after'));
await tester.pumpAndSettle();
expect(inFlight.values, ['https://example.test/after']);
});
testWidgets('a cancelled load that fails late keeps the next load of the same image', (tester) async {
final replies = <Completer<Object?>>[];
tester.binding.defaultBinaryMessenger
..setMockDecodedMessageHandler<Object?>(requestImage, (_) {
final reply = Completer<Object?>();
replies.add(reply);
return reply.future;
})
..setMockDecodedMessageHandler<Object?>(cancelRequest, (_) async => const <Object?>[null]);
const provider = RemoteImageProvider(url: 'https://example.test/face');
final listener = ImageStreamListener((_, _) {});
// Show the image and hide it again before it loads, which cancels the first load
final first = provider.resolve(ImageConfiguration.empty)..addListener(listener);
await tester.pump();
first.removeListener(listener);
// Showing the same image again has to start a second load
provider.resolve(ImageConfiguration.empty);
await tester.pump();
expect(replies, hasLength(2));
// The cancelled request only fails now, and that must not throw away the second load
replies.first.complete(const <Object?>['error', 'late failure', null]);
await tester.pump();
expect(PaintingBinding.instance.imageCache.statusForKey(provider).pending, isTrue);
});
}
@@ -71,16 +71,19 @@ void main() {
});
group('LocalThumbProvider caching', () {
LocalThumbProvider thumbWithChecksum(String? checksum) =>
LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: checksum);
test('editing on device re-renders the thumbnail', () {
cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load);
cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'after'), load);
cache.putIfAbsent(const LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load);
cache.putIfAbsent(const LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'after'), load);
expect(loads, 2);
});
test('an unchanged thumbnail still comes from the cache', () {
cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load);
cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load);
cache.putIfAbsent(thumbWithChecksum('same'), load);
cache.putIfAbsent(thumbWithChecksum('same'), load);
expect(loads, 1);
});
@@ -88,15 +91,15 @@ void main() {
// The rehash clears the checksum before writing the new one, so the tile has to
// follow that step too or it waits for the hash to land before showing the edit.
test('re-renders while the checksum is still being recomputed', () {
cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load);
cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
cache.putIfAbsent(const LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load);
cache.putIfAbsent(const LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
expect(loads, 2);
});
test('stays cached while the checksum is missing', () {
cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
cache.putIfAbsent(thumbWithChecksum(null), load);
cache.putIfAbsent(thumbWithChecksum(null), load);
expect(loads, 1);
});
@@ -115,20 +118,20 @@ void main() {
group('LocalFullImageProvider caching', () {
test('editing on device re-renders the full image', () {
cache.putIfAbsent(
LocalFullImageProvider(
const LocalFullImageProvider(
id: 'asset-1',
assetType: AssetType.image,
size: const Size(100, 100),
size: Size(100, 100),
isAnimated: false,
checksum: 'before',
),
load,
);
cache.putIfAbsent(
LocalFullImageProvider(
const LocalFullImageProvider(
id: 'asset-1',
assetType: AssetType.image,
size: const Size(100, 100),
size: Size(100, 100),
isAnimated: false,
checksum: 'after',
),