feat: Implement S3 image handling with a new resolver and widget, update agent display, and configure storage base URL.

This commit is contained in:
pradeepkumar
2026-03-07 22:19:17 +05:30
parent aefe80253f
commit ee08e86449
11 changed files with 238 additions and 101 deletions

View File

@@ -0,0 +1,63 @@
import 'package:dio/dio.dart';
import 'package:real_estate_mobile/core/network/api_client.dart';
/// Resolves S3 keys to presigned download URLs via the backend API.
/// Caches results in memory to avoid repeated API calls.
class ImageUrlResolver {
ImageUrlResolver._();
static final ImageUrlResolver instance = ImageUrlResolver._();
final Map<String, String> _cache = {};
final Map<String, Future<String?>> _pending = {};
/// Returns a direct image URL.
/// - If [imageUrl] is already an http(s) URL, returns it as-is.
/// - If it's an S3 key, fetches a presigned download URL from the backend.
Future<String?> resolve(String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
// Check cache
if (_cache.containsKey(imageUrl)) return _cache[imageUrl];
// Deduplicate in-flight requests for the same key
if (_pending.containsKey(imageUrl)) return _pending[imageUrl];
final future = _fetchPresignedUrl(imageUrl);
_pending[imageUrl] = future;
try {
final result = await future;
if (result != null) _cache[imageUrl] = result;
return result;
} finally {
_pending.remove(imageUrl);
}
}
Future<String?> _fetchPresignedUrl(String key) async {
try {
final response = await ApiClient.instance.dio.get(
'/upload/presigned-download-url',
queryParameters: {'key': key},
);
final data = response.data;
// Backend wraps: { success: true, data: { url: "..." } }
if (data is Map<String, dynamic>) {
final payload = data['data'];
if (payload is Map<String, dynamic>) {
return payload['url'] as String?;
}
// In case response isn't wrapped (direct controller return)
return data['url'] as String?;
}
return null;
} on DioException {
return null;
}
}
void clearCache() => _cache.clear();
}

View File

@@ -0,0 +1,102 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:real_estate_mobile/core/utils/image_url_resolver.dart';
/// Displays an image from an S3 key or full URL.
/// Resolves S3 keys to presigned download URLs via the backend API.
class S3Image extends StatefulWidget {
final String? imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final Widget Function(BuildContext context)? placeholder;
final Widget Function(BuildContext context)? errorWidget;
const S3Image({
super.key,
required this.imageUrl,
this.width,
this.height,
this.fit = BoxFit.cover,
this.placeholder,
this.errorWidget,
});
@override
State<S3Image> createState() => _S3ImageState();
}
class _S3ImageState extends State<S3Image> {
String? _resolvedUrl;
bool _loading = true;
bool _failed = false;
@override
void initState() {
super.initState();
_resolve();
}
@override
void didUpdateWidget(S3Image oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) {
_resolve();
}
}
Future<void> _resolve() async {
if (widget.imageUrl == null || widget.imageUrl!.isEmpty) {
if (mounted) setState(() { _loading = false; _failed = true; });
return;
}
// If already a full URL, skip resolution
if (widget.imageUrl!.startsWith('http://') || widget.imageUrl!.startsWith('https://')) {
if (mounted) setState(() { _resolvedUrl = widget.imageUrl; _loading = false; });
return;
}
final url = await ImageUrlResolver.instance.resolve(widget.imageUrl);
if (mounted) {
setState(() {
_resolvedUrl = url;
_loading = false;
_failed = url == null;
});
}
}
Widget _buildPlaceholder(BuildContext context) {
return widget.placeholder?.call(context) ?? Container(
width: widget.width,
height: widget.height,
color: const Color(0xFFC4D9D4),
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
Widget _buildError(BuildContext context) {
return widget.errorWidget?.call(context) ?? Container(
width: widget.width,
height: widget.height,
color: const Color(0xFFC4D9D4),
child: const Icon(Icons.person, size: 60, color: Color(0xFF00293D)),
);
}
@override
Widget build(BuildContext context) {
if (_loading) return _buildPlaceholder(context);
if (_failed || _resolvedUrl == null) return _buildError(context);
return CachedNetworkImage(
imageUrl: _resolvedUrl!,
width: widget.width,
height: widget.height,
fit: widget.fit,
placeholder: (ctx, _) => _buildPlaceholder(ctx),
errorWidget: (ctx, _, __) => _buildError(ctx),
);
}
}