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';
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user