diff --git a/.env.demo b/.env.demo index 19c5530..ad8fb56 100644 --- a/.env.demo +++ b/.env.demo @@ -1,2 +1,3 @@ # Demo Environment Configuration API_BASE_URL=https://demo-api.requestn.com +STORAGE_BASE_URL=https://sin1.contabostorage.com/request diff --git a/.env.dev b/.env.dev index 70782b3..623f3f2 100644 --- a/.env.dev +++ b/.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 diff --git a/.env.local b/.env.local index c2f6e67..cc0a811 100644 --- a/.env.local +++ b/.env.local @@ -1,2 +1,3 @@ # Local Environment Configuration API_BASE_URL=http://localhost:3001/api/v1 +STORAGE_BASE_URL=https://sin1.contabostorage.com/request diff --git a/assets/icons/nav_messages_icon.svg b/assets/icons/nav_messages_icon.svg index db2c060..38c5da2 100644 --- a/assets/icons/nav_messages_icon.svg +++ b/assets/icons/nav_messages_icon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index ec35109..464e1c1 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -13,4 +13,5 @@ class AppConfig { } static String get apiBaseUrl => dotenv.env['API_BASE_URL'] ?? ''; + static String get storageBaseUrl => dotenv.env['STORAGE_BASE_URL'] ?? ''; } diff --git a/lib/core/utils/image_url_resolver.dart b/lib/core/utils/image_url_resolver.dart new file mode 100644 index 0000000..d51d2aa --- /dev/null +++ b/lib/core/utils/image_url_resolver.dart @@ -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 _cache = {}; + final Map> _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 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 _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) { + final payload = data['data']; + if (payload is Map) { + 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(); +} diff --git a/lib/core/widgets/s3_image.dart b/lib/core/widgets/s3_image.dart new file mode 100644 index 0000000..abf7b7f --- /dev/null +++ b/lib/core/widgets/s3_image.dart @@ -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 createState() => _S3ImageState(); +} + +class _S3ImageState extends State { + 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 _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), + ); + } +} diff --git a/lib/features/agents/data/models/agent_profile.dart b/lib/features/agents/data/models/agent_profile.dart index d422256..06bf89d 100644 --- a/lib/features/agents/data/models/agent_profile.dart +++ b/lib/features/agents/data/models/agent_profile.dart @@ -22,6 +22,7 @@ class AgentProfile { final AgentType? agentType; final AgentUser? user; final List 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)) .toList() ?? const [], + createdAt: json['createdAt'] as String?, ); } } diff --git a/lib/features/agents/presentation/screens/agent_search_screen.dart b/lib/features/agents/presentation/screens/agent_search_screen.dart index dc93348..58d301b 100644 --- a/lib/features/agents/presentation/screens/agent_search_screen.dart +++ b/lib/features/agents/presentation/screens/agent_search_screen.dart @@ -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 { 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( diff --git a/lib/features/home/presentation/widgets/featured_professionals_section.dart b/lib/features/home/presentation/widgets/featured_professionals_section.dart index 695818f..20c19d6 100644 --- a/lib/features/home/presentation/widgets/featured_professionals_section.dart +++ b/lib/features/home/presentation/widgets/featured_professionals_section.dart @@ -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) { diff --git a/lib/features/home/presentation/widgets/top_professionals_section.dart b/lib/features/home/presentation/widgets/top_professionals_section.dart index 01882be..380817d 100644 --- a/lib/features/home/presentation/widgets/top_professionals_section.dart +++ b/lib/features/home/presentation/widgets/top_professionals_section.dart @@ -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) {