feat: Implement S3 image handling with a new resolver and widget, update agent display, and configure storage base URL.
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
# Demo Environment Configuration
|
||||
API_BASE_URL=https://demo-api.requestn.com
|
||||
STORAGE_BASE_URL=https://sin1.contabostorage.com/request
|
||||
|
||||
1
.env.dev
1
.env.dev
@@ -1,2 +1,3 @@
|
||||
# Dev Environment Configuration
|
||||
API_BASE_URL=https://dev-api.requestn.com
|
||||
STORAGE_BASE_URL=https://sin1.contabostorage.com/request
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# Local Environment Configuration
|
||||
API_BASE_URL=http://localhost:3001/api/v1
|
||||
STORAGE_BASE_URL=https://sin1.contabostorage.com/request
|
||||
|
||||
@@ -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 |
@@ -13,4 +13,5 @@ class AppConfig {
|
||||
}
|
||||
|
||||
static String get apiBaseUrl => dotenv.env['API_BASE_URL'] ?? '';
|
||||
static String get storageBaseUrl => dotenv.env['STORAGE_BASE_URL'] ?? '';
|
||||
}
|
||||
|
||||
63
lib/core/utils/image_url_resolver.dart
Normal file
63
lib/core/utils/image_url_resolver.dart
Normal 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();
|
||||
}
|
||||
102
lib/core/widgets/s3_image.dart
Normal file
102
lib/core/widgets/s3_image.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user