feat: Implement expiring cache for S3 image URLs, add error retry logic, and clear image caches on app startup.
This commit is contained in:
@@ -1,12 +1,22 @@
|
||||
import 'package:real_estate_mobile/core/network/api_client.dart';
|
||||
|
||||
class _CachedUrl {
|
||||
final String url;
|
||||
final DateTime cachedAt;
|
||||
_CachedUrl(this.url) : cachedAt = DateTime.now();
|
||||
|
||||
/// Presigned URLs typically expire in 1 hour; refresh after 45 minutes.
|
||||
bool get isExpired =>
|
||||
DateTime.now().difference(cachedAt).inMinutes >= 45;
|
||||
}
|
||||
|
||||
/// Resolves S3 keys to presigned download URLs via the backend API.
|
||||
/// Caches results in memory to avoid repeated API calls.
|
||||
/// Caches results in memory with expiry to handle presigned URL expiration.
|
||||
class ImageUrlResolver {
|
||||
ImageUrlResolver._();
|
||||
static final ImageUrlResolver instance = ImageUrlResolver._();
|
||||
|
||||
final Map<String, String> _cache = {};
|
||||
final Map<String, _CachedUrl> _cache = {};
|
||||
final Map<String, Future<String?>> _pending = {};
|
||||
|
||||
/// Returns a direct image URL.
|
||||
@@ -18,8 +28,9 @@ class ImageUrlResolver {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (_cache.containsKey(imageUrl)) return _cache[imageUrl];
|
||||
// Check cache — only use if not expired
|
||||
final cached = _cache[imageUrl];
|
||||
if (cached != null && !cached.isExpired) return cached.url;
|
||||
|
||||
// Deduplicate in-flight requests for the same key
|
||||
if (_pending.containsKey(imageUrl)) return _pending[imageUrl];
|
||||
@@ -29,7 +40,7 @@ class ImageUrlResolver {
|
||||
|
||||
try {
|
||||
final result = await future;
|
||||
if (result != null) _cache[imageUrl] = result;
|
||||
if (result != null) _cache[imageUrl] = _CachedUrl(result);
|
||||
return result;
|
||||
} finally {
|
||||
_pending.remove(imageUrl);
|
||||
|
||||
@@ -33,6 +33,7 @@ class _S3ImageState extends State<S3Image> {
|
||||
String? _resolvedUrl;
|
||||
bool _loading = true;
|
||||
bool _failed = false;
|
||||
bool _retried = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -106,13 +107,22 @@ class _S3ImageState extends State<S3Image> {
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: _resolvedUrl!,
|
||||
cacheKey: widget.imageUrl,
|
||||
cacheKey: widget.imageUrl ?? _resolvedUrl!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: widget.fit,
|
||||
alignment: widget.alignment,
|
||||
placeholder: (ctx, _) => _buildPlaceholder(ctx),
|
||||
errorWidget: (ctx, _, _) => _buildError(ctx),
|
||||
errorWidget: (ctx, _, err) {
|
||||
// On error (expired URL), evict and retry once
|
||||
if (!_retried) {
|
||||
_retried = true;
|
||||
ImageUrlResolver.instance.evict(widget.imageUrl);
|
||||
_resolve();
|
||||
return _buildPlaceholder(ctx);
|
||||
}
|
||||
return _buildError(ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:real_estate_mobile/config/app_config.dart';
|
||||
import 'package:real_estate_mobile/config/firebase_config.dart';
|
||||
import 'package:real_estate_mobile/core/theme/app_theme.dart';
|
||||
import 'package:real_estate_mobile/core/utils/image_url_resolver.dart';
|
||||
import 'package:real_estate_mobile/routing/app_router.dart';
|
||||
|
||||
Future<void> mainCommon(Flavor flavor) async {
|
||||
@@ -12,6 +13,11 @@ Future<void> mainCommon(Flavor flavor) async {
|
||||
|
||||
await Firebase.initializeApp(options: FirebaseConfig.currentOptions);
|
||||
|
||||
// Clear stale image caches on app start to ensure fresh presigned URLs
|
||||
ImageUrlResolver.instance.clearCache();
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
|
||||
runApp(const ProviderScope(child: MyApp()));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user