feat: Implement expiring cache for S3 image URLs, add error retry logic, and clear image caches on app startup.

This commit is contained in:
pradeepkumar
2026-03-19 10:54:49 +05:30
parent 2494bdc868
commit 62a4a26e9a
3 changed files with 34 additions and 7 deletions

View File

@@ -1,12 +1,22 @@
import 'package:real_estate_mobile/core/network/api_client.dart'; 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. /// 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 { class ImageUrlResolver {
ImageUrlResolver._(); ImageUrlResolver._();
static final ImageUrlResolver instance = ImageUrlResolver._(); static final ImageUrlResolver instance = ImageUrlResolver._();
final Map<String, String> _cache = {}; final Map<String, _CachedUrl> _cache = {};
final Map<String, Future<String?>> _pending = {}; final Map<String, Future<String?>> _pending = {};
/// Returns a direct image URL. /// Returns a direct image URL.
@@ -18,8 +28,9 @@ class ImageUrlResolver {
return imageUrl; return imageUrl;
} }
// Check cache // Check cache — only use if not expired
if (_cache.containsKey(imageUrl)) return _cache[imageUrl]; final cached = _cache[imageUrl];
if (cached != null && !cached.isExpired) return cached.url;
// Deduplicate in-flight requests for the same key // Deduplicate in-flight requests for the same key
if (_pending.containsKey(imageUrl)) return _pending[imageUrl]; if (_pending.containsKey(imageUrl)) return _pending[imageUrl];
@@ -29,7 +40,7 @@ class ImageUrlResolver {
try { try {
final result = await future; final result = await future;
if (result != null) _cache[imageUrl] = result; if (result != null) _cache[imageUrl] = _CachedUrl(result);
return result; return result;
} finally { } finally {
_pending.remove(imageUrl); _pending.remove(imageUrl);

View File

@@ -33,6 +33,7 @@ class _S3ImageState extends State<S3Image> {
String? _resolvedUrl; String? _resolvedUrl;
bool _loading = true; bool _loading = true;
bool _failed = false; bool _failed = false;
bool _retried = false;
@override @override
void initState() { void initState() {
@@ -106,13 +107,22 @@ class _S3ImageState extends State<S3Image> {
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: _resolvedUrl!, imageUrl: _resolvedUrl!,
cacheKey: widget.imageUrl, cacheKey: widget.imageUrl ?? _resolvedUrl!,
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
fit: widget.fit, fit: widget.fit,
alignment: widget.alignment, alignment: widget.alignment,
placeholder: (ctx, _) => _buildPlaceholder(ctx), 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);
},
); );
} }
} }

View File

@@ -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/app_config.dart';
import 'package:real_estate_mobile/config/firebase_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/theme/app_theme.dart';
import 'package:real_estate_mobile/core/utils/image_url_resolver.dart';
import 'package:real_estate_mobile/routing/app_router.dart'; import 'package:real_estate_mobile/routing/app_router.dart';
Future<void> mainCommon(Flavor flavor) async { Future<void> mainCommon(Flavor flavor) async {
@@ -12,6 +13,11 @@ Future<void> mainCommon(Flavor flavor) async {
await Firebase.initializeApp(options: FirebaseConfig.currentOptions); 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())); runApp(const ProviderScope(child: MyApp()));
} }