diff --git a/lib/core/utils/image_url_resolver.dart b/lib/core/utils/image_url_resolver.dart index dd01041..b0ebe98 100644 --- a/lib/core/utils/image_url_resolver.dart +++ b/lib/core/utils/image_url_resolver.dart @@ -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 _cache = {}; + final Map _cache = {}; final Map> _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); diff --git a/lib/core/widgets/s3_image.dart b/lib/core/widgets/s3_image.dart index c392f88..efa266b 100644 --- a/lib/core/widgets/s3_image.dart +++ b/lib/core/widgets/s3_image.dart @@ -33,6 +33,7 @@ class _S3ImageState extends State { String? _resolvedUrl; bool _loading = true; bool _failed = false; + bool _retried = false; @override void initState() { @@ -106,13 +107,22 @@ class _S3ImageState extends State { 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); + }, ); } } diff --git a/lib/main.dart b/lib/main.dart index 049e6b8..62cf111 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 mainCommon(Flavor flavor) async { @@ -12,6 +13,11 @@ Future 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())); }