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

@@ -1,2 +1,3 @@
# Demo Environment Configuration
API_BASE_URL=https://demo-api.requestn.com
STORAGE_BASE_URL=https://sin1.contabostorage.com/request

View File

@@ -1,2 +1,3 @@
# Dev Environment Configuration
API_BASE_URL=https://dev-api.requestn.com
STORAGE_BASE_URL=https://sin1.contabostorage.com/request

View File

@@ -1,2 +1,3 @@
# Local Environment Configuration
API_BASE_URL=http://localhost:3001/api/v1
STORAGE_BASE_URL=https://sin1.contabostorage.com/request

View File

@@ -1,3 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 1H20C20.5523 1 21 1.44772 21 2V14C21 14.5523 20.5523 15 20 15H6L2 19V2C2 1.44772 2.44772 1 3 1H2Z" fill="#E58625"/>
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25 2.5H5C3.625 2.5 2.5125 3.625 2.5125 5L2.5 27.5L7.5 22.5H25C26.375 22.5 27.5 21.375 27.5 20V5C27.5 3.625 26.375 2.5 25 2.5ZM22.5 17.5H7.5V15H22.5V17.5ZM22.5 13.75H7.5V11.25H22.5V13.75ZM22.5 10H7.5V7.5H22.5V10Z" fill="#E58625"/>
</svg>

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -13,4 +13,5 @@ class AppConfig {
}
static String get apiBaseUrl => dotenv.env['API_BASE_URL'] ?? '';
static String get storageBaseUrl => dotenv.env['STORAGE_BASE_URL'] ?? '';
}

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),
);
}
}

View File

@@ -22,6 +22,7 @@ class AgentProfile {
final AgentType? agentType;
final AgentUser? user;
final List<AgentFieldValue> fieldValues;
final String? createdAt;
const AgentProfile({
required this.id,
@@ -45,10 +46,26 @@ class AgentProfile {
this.agentType,
this.user,
this.fieldValues = const [],
this.createdAt,
});
String get fullName => '$firstName $lastName'.trim();
/// Format "Member Since March 2024" from createdAt date string.
String get memberSinceText {
if (createdAt == null || createdAt!.isEmpty) return 'Member Since 2024';
try {
final date = DateTime.parse(createdAt!);
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
return 'Member Since ${months[date.month - 1]} ${date.year}';
} catch (_) {
return 'Member Since 2024';
}
}
/// Get avatar URL — check agent avatar first, then user avatar
String? get avatarUrl => avatar ?? user?.avatar;
@@ -175,6 +192,7 @@ class AgentProfile {
?.map((e) => AgentFieldValue.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
createdAt: json['createdAt'] as String?,
);
}
}

View File

@@ -1,10 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart';
import 'package:real_estate_mobile/features/agents/presentation/providers/search_agents_provider.dart';
@@ -110,6 +109,8 @@ class _AgentSearchScreenState extends ConsumerState<AgentSearchScreen> {
color: Colors.black.withValues(alpha: 0.5),
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
@@ -408,16 +409,6 @@ class _AgentCard extends StatefulWidget {
class _AgentCardState extends State<_AgentCard> {
bool _bioExpanded = false;
String? _resolveImageUrl(String? imageUrl) {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
final baseUrl = AppConfig.apiBaseUrl;
if (baseUrl.isNotEmpty) return '$baseUrl$imageUrl';
return null;
}
@override
Widget build(BuildContext context) {
final agent = widget.agent;
@@ -432,6 +423,13 @@ class _AgentCardState extends State<_AgentCard> {
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.primaryDark.withValues(alpha: 0.1)),
boxShadow: [
BoxShadow(
color: const Color(0xFFD9D9D9).withValues(alpha: 0.5),
offset: const Offset(0, 10),
blurRadius: 20,
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
@@ -440,7 +438,17 @@ class _AgentCardState extends State<_AgentCard> {
// Cover image with match badge
Stack(
children: [
_buildCoverImage(agent),
S3Image(
imageUrl: agent.avatarUrl,
width: double.infinity,
height: 265,
errorWidget: (_) => Container(
width: double.infinity,
height: 265,
color: AppColors.gradientStart,
child: const Icon(Icons.person, size: 80, color: AppColors.primaryDark),
),
),
if (matchPercent != null)
Positioned(
top: 0,
@@ -534,7 +542,7 @@ class _AgentCardState extends State<_AgentCard> {
// Headline / title
Text(
agent.headline ?? agent.agentType?.name ?? 'Licensed Real Estate Agent',
agent.agentType?.name ?? agent.headline ?? 'Professional',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
@@ -581,13 +589,16 @@ class _AgentCardState extends State<_AgentCard> {
),
),
const SizedBox(width: 4),
const Text(
'Member Since 2024',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
Flexible(
child: Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
],
@@ -652,31 +663,6 @@ class _AgentCardState extends State<_AgentCard> {
);
}
Widget _buildCoverImage(AgentProfile agent) {
final resolvedUrl = _resolveImageUrl(agent.avatarUrl);
if (resolvedUrl != null) {
return CachedNetworkImage(
imageUrl: resolvedUrl,
width: double.infinity,
height: 265,
fit: BoxFit.cover,
placeholder: (_, __) => _buildPlaceholder(),
errorWidget: (_, __, ___) => _buildPlaceholder(),
);
}
return _buildPlaceholder();
}
Widget _buildPlaceholder() {
return Container(
width: double.infinity,
height: 265,
color: AppColors.gradientStart,
child:
const Icon(Icons.person, size: 80, color: AppColors.primaryDark),
);
}
Widget _buildTag(String label) {
return Container(

View File

@@ -1,9 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
import 'package:real_estate_mobile/features/agents/presentation/providers/agents_provider.dart';
@@ -288,28 +287,13 @@ class _FeaturedProfessionalsSectionState
// -- Image helpers --
Widget _buildCardImage(AgentProfile agent, int index) {
final resolvedUrl = _resolveImageUrl(agent.avatarUrl);
if (resolvedUrl != null) {
return CachedNetworkImage(
imageUrl: resolvedUrl,
width: double.infinity,
height: 192,
fit: BoxFit.cover,
placeholder: (context, url) => _buildImagePlaceholder(index),
errorWidget: (context, url, error) => _buildImagePlaceholder(index),
);
}
return _buildImagePlaceholder(index);
}
String? _resolveImageUrl(String? imageUrl) {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
final baseUrl = AppConfig.apiBaseUrl;
if (baseUrl.isNotEmpty) return '$baseUrl$imageUrl';
return null;
return S3Image(
imageUrl: agent.avatarUrl,
width: double.infinity,
height: 192,
placeholder: (_) => _buildImagePlaceholder(index),
errorWidget: (_) => _buildImagePlaceholder(index),
);
}
Widget _buildImagePlaceholder(int index) {

View File

@@ -1,10 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
import 'package:real_estate_mobile/features/agents/presentation/providers/agents_provider.dart';
import 'package:real_estate_mobile/features/home/data/models/landing_page_content.dart';
@@ -271,34 +270,15 @@ class _TopProfessionalsSectionState
);
}
/// Resolves an image URL: full URL as-is, relative prepend base URL.
String? _resolveImageUrl(String? imageUrl) {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
final baseUrl = AppConfig.apiBaseUrl;
if (baseUrl.isNotEmpty) {
return '$baseUrl$imageUrl';
}
return null;
}
/// Builds the card image from AgentProfile avatar.
/// Builds the card image from AgentProfile avatar using presigned URL.
Widget _buildCardImage(AgentProfile agent, int index) {
final resolvedUrl = _resolveImageUrl(agent.avatarUrl);
if (resolvedUrl != null) {
return CachedNetworkImage(
imageUrl: resolvedUrl,
width: double.infinity,
height: 192,
fit: BoxFit.cover,
placeholder: (context, url) => _buildImagePlaceholder(index),
errorWidget: (context, url, error) => _buildImagePlaceholder(index),
);
}
return _buildImagePlaceholder(index);
return S3Image(
imageUrl: agent.avatarUrl,
width: double.infinity,
height: 192,
placeholder: (_) => _buildImagePlaceholder(index),
errorWidget: (_) => _buildImagePlaceholder(index),
);
}
Widget _buildImagePlaceholder(int index) {