diff --git a/assets/icons/edit_icon.svg b/assets/icons/edit_icon.svg new file mode 100644 index 0000000..81cfb50 --- /dev/null +++ b/assets/icons/edit_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/about_cityscape.png b/assets/images/about_cityscape.png new file mode 100644 index 0000000..bda285b Binary files /dev/null and b/assets/images/about_cityscape.png differ diff --git a/assets/images/about_find_agent.jpg b/assets/images/about_find_agent.jpg new file mode 100644 index 0000000..4849bff Binary files /dev/null and b/assets/images/about_find_agent.jpg differ diff --git a/assets/images/about_team_andrew.jpg b/assets/images/about_team_andrew.jpg new file mode 100644 index 0000000..f3be51d Binary files /dev/null and b/assets/images/about_team_andrew.jpg differ diff --git a/lib/features/about/presentation/screens/about_screen.dart b/lib/features/about/presentation/screens/about_screen.dart new file mode 100644 index 0000000..898e2b2 --- /dev/null +++ b/lib/features/about/presentation/screens/about_screen.dart @@ -0,0 +1,970 @@ +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/core/constants/app_colors.dart'; +import 'package:real_estate_mobile/core/network/api_client.dart'; +import 'package:real_estate_mobile/core/widgets/s3_image.dart'; +import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart'; + +// ── Data models (matching web CMS types) ── + +class _AboutHero { + final String badge; + final String headline; + final String description; + final String ctaButtonText; + final String bannerImageUrl; + final String bannerOverlayText; + + const _AboutHero({ + this.badge = 'Our Mission', + this.headline = 'Connecting You with Trusted Agents.', + this.description = + 'We believe finding the right home shouldn\u2019t be complicated. Our platform bridges the gap between buyers, sellers, and verified real estate agents for a smooth and transparent experience.', + this.ctaButtonText = 'Find Your Agent', + this.bannerImageUrl = '', + this.bannerOverlayText = 'Building the future of property.', + }); + + factory _AboutHero.fromJson(Map json) { + return _AboutHero( + badge: json['badge'] as String? ?? 'Our Mission', + headline: json['headline'] as String? ?? 'Connecting You with Trusted Agents.', + description: json['description'] as String? ?? + 'We believe finding the right home shouldn\u2019t be complicated. Our platform bridges the gap between buyers, sellers, and verified real estate agents for a smooth and transparent experience.', + ctaButtonText: json['ctaButtonText'] as String? ?? 'Find Your Agent', + bannerImageUrl: json['bannerImageUrl'] as String? ?? '', + bannerOverlayText: json['bannerOverlayText'] as String? ?? 'Building the future of property.', + ); + } +} + +class _AboutStatItem { + final String value; + final String label; + + const _AboutStatItem({required this.value, required this.label}); + + factory _AboutStatItem.fromJson(Map json) { + return _AboutStatItem( + value: json['value'] as String? ?? '', + label: json['label'] as String? ?? '', + ); + } +} + +class _AboutFeatureItem { + final String iconPath; + final String title; + final String description; + + const _AboutFeatureItem({ + required this.iconPath, + required this.title, + required this.description, + }); + + factory _AboutFeatureItem.fromJson(Map json) { + return _AboutFeatureItem( + iconPath: json['iconPath'] as String? ?? '', + title: json['title'] as String? ?? '', + description: json['description'] as String? ?? '', + ); + } +} + +class _AboutTeamMember { + final String name; + final String role; + final String imageUrl; + + const _AboutTeamMember({ + required this.name, + required this.role, + required this.imageUrl, + }); + + factory _AboutTeamMember.fromJson(Map json) { + return _AboutTeamMember( + name: json['name'] as String? ?? '', + role: json['role'] as String? ?? '', + imageUrl: json['imageUrl'] as String? ?? '', + ); + } +} + +class _AboutCta { + final String title; + final String description; + final String buttonText; + + const _AboutCta({ + this.title = 'Ready to find a agent', + this.description = + 'Search and connect with verified real estate agents who match your needs, location, and goals.', + this.buttonText = 'Find Agents Now', + }); + + factory _AboutCta.fromJson(Map json) { + return _AboutCta( + title: json['title'] as String? ?? 'Ready to find a agent', + description: json['description'] as String? ?? + 'Search and connect with verified real estate agents who match your needs, location, and goals.', + buttonText: json['buttonText'] as String? ?? 'Find Agents Now', + ); + } +} + +// ── Defaults ── + +const _defaultStats = [ + _AboutStatItem(value: '15k+', label: 'Verified Agents'), + _AboutStatItem(value: '98%', label: 'Customer Satisfaction'), +]; + +const _defaultFeatures = [ + _AboutFeatureItem( + iconPath: '', + title: 'Verified Agents', + description: + 'All agents on our platform are carefully verified to ensure trust, proven expertise, and consistent service, helping users connect with reliable professionals and enjoy a smooth, confident, and transparent property journey', + ), + _AboutFeatureItem( + iconPath: '', + title: 'Total Transparency', + description: + 'We maintain complete transparency by clearly displaying agent details, reviews, and activity, allowing users to make informed decisions with confidence, clarity, and trust throughout their entire property journey.', + ), + _AboutFeatureItem( + iconPath: '', + title: 'Simple Journey', + description: + 'Our platform simplifies every step of the property process, from discovering the right agent to closing with confidence, making the experience smooth, fast, and stress-free for users at every stage.', + ), +]; + +const _defaultTeamMembers = [ + _AboutTeamMember(name: 'Andrew', role: 'Founder & CEO', imageUrl: 'asset://professional-1'), + _AboutTeamMember(name: 'Thomas', role: 'Co-Founder', imageUrl: 'asset://professional-2'), + _AboutTeamMember(name: 'Darren', role: 'Advisor', imageUrl: 'asset://professional-3'), +]; + +// ── State ── + +class _AboutState { + final bool isLoading; + final _AboutHero hero; + final List<_AboutStatItem> stats; + final String featuresBadge; + final List<_AboutFeatureItem> features; + final String teamTitle; + final String teamSubtitle; + final List<_AboutTeamMember> teamMembers; + final _AboutCta cta; + final int activeTeamIndex; + + const _AboutState({ + this.isLoading = true, + this.hero = const _AboutHero(), + this.stats = _defaultStats, + this.featuresBadge = 'Why Choose Us', + this.features = _defaultFeatures, + this.teamTitle = 'Meet the minds behind the platform', + this.teamSubtitle = + 'We are a team of passionate innovators, real estate experts, and designers working together to redefine how people connect with trusted agents.', + this.teamMembers = _defaultTeamMembers, + this.cta = const _AboutCta(), + this.activeTeamIndex = 0, + }); + + _AboutState copyWith({ + bool? isLoading, + _AboutHero? hero, + List<_AboutStatItem>? stats, + String? featuresBadge, + List<_AboutFeatureItem>? features, + String? teamTitle, + String? teamSubtitle, + List<_AboutTeamMember>? teamMembers, + _AboutCta? cta, + int? activeTeamIndex, + }) { + return _AboutState( + isLoading: isLoading ?? this.isLoading, + hero: hero ?? this.hero, + stats: stats ?? this.stats, + featuresBadge: featuresBadge ?? this.featuresBadge, + features: features ?? this.features, + teamTitle: teamTitle ?? this.teamTitle, + teamSubtitle: teamSubtitle ?? this.teamSubtitle, + teamMembers: teamMembers ?? this.teamMembers, + cta: cta ?? this.cta, + activeTeamIndex: activeTeamIndex ?? this.activeTeamIndex, + ); + } +} + +// ── Provider ── + +class _AboutNotifier extends StateNotifier<_AboutState> { + _AboutNotifier() : super(const _AboutState()) { + _load(); + } + + Future _load() async { + try { + final response = await ApiClient.instance.dio.get('/cms/page/about'); + final data = response.data['data'] as List?; + if (data != null) { + for (final record in data) { + final map = record as Map; + final sectionKey = map['sectionKey'] as String?; + final content = map['content'] as Map?; + if (content == null) continue; + + switch (sectionKey) { + case 'hero': + if (content['headline'] != null || content['description'] != null) { + state = state.copyWith(hero: _AboutHero.fromJson(content)); + } + case 'stats': + final statsList = content['stats'] as List?; + if (statsList != null && statsList.isNotEmpty) { + state = state.copyWith( + stats: statsList + .map((e) => _AboutStatItem.fromJson(e as Map)) + .toList(), + ); + } + case 'features': + final badge = content['badge'] as String?; + final featuresList = content['features'] as List?; + if (badge != null) { + state = state.copyWith(featuresBadge: badge); + } + if (featuresList != null && featuresList.isNotEmpty) { + state = state.copyWith( + features: featuresList + .map((e) => _AboutFeatureItem.fromJson(e as Map)) + .toList(), + ); + } + case 'team': + final title = content['title'] as String?; + final subtitle = content['subtitle'] as String?; + final membersList = content['members'] as List?; + if (title != null) state = state.copyWith(teamTitle: title); + if (subtitle != null) state = state.copyWith(teamSubtitle: subtitle); + if (membersList != null && membersList.isNotEmpty) { + state = state.copyWith( + teamMembers: membersList + .map((e) => _AboutTeamMember.fromJson(e as Map)) + .toList(), + ); + } + case 'cta': + if (content['title'] != null || content['description'] != null) { + state = state.copyWith(cta: _AboutCta.fromJson(content)); + } + } + } + } + } catch (_) { + // Fall through to defaults + } + + state = state.copyWith(isLoading: false); + } + + void setActiveTeamIndex(int index) { + state = state.copyWith(activeTeamIndex: index); + } +} + +final _aboutProvider = + StateNotifierProvider.autoDispose<_AboutNotifier, _AboutState>((ref) { + return _AboutNotifier(); +}); + +// ── Feature icon mapping (fallback when CMS has no iconPath) ── + +const _featureIconFallback = { + 'Verified Agents': Icons.verified, + 'Total Transparency': Icons.lock_outline, + 'Simple Journey': Icons.check_circle_outline, +}; + +// ── Screen ── + +class AboutScreen extends ConsumerWidget { + const AboutScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(_aboutProvider); + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + bottom: false, + child: Column( + children: [ + const HomeHeader(), + Expanded( + child: state.isLoading + ? const Center( + child: CircularProgressIndicator( + color: AppColors.accentOrange, + ), + ) + : SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + children: [ + const SizedBox(height: 18), + _buildHeroTitle(state.hero.headline), + const SizedBox(height: 16), + _buildHeroSubtitle(state.hero.description), + const SizedBox(height: 24), + _buildBannerImage(state.hero.bannerImageUrl), + const SizedBox(height: 10), + _buildBannerCaption(state.hero.bannerOverlayText), + const SizedBox(height: 16), + _buildStatsRow(state.stats), + const SizedBox(height: 32), + _buildSectionTitle(state.featuresBadge), + const SizedBox(height: 12), + _buildWhyChooseUsSubtitle(), + const SizedBox(height: 24), + ...state.features.map((f) => Padding( + padding: const EdgeInsets.only(bottom: 15), + child: _buildFeatureCard(f), + )), + const SizedBox(height: 25), + _buildTeamSection(context, ref, state), + const SizedBox(height: 40), + _buildCtaSection(context, state.cta), + const SizedBox(height: 30), + ], + ), + ), + ), + ], + ), + ), + bottomNavigationBar: _buildBottomNavBar(context, ref), + ); + } + + // -- Hero Title -- + Widget _buildHeroTitle(String text) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 25, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ); + } + + // -- Hero Subtitle -- + Widget _buildHeroSubtitle(String text) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + ); + } + + // -- Banner Image (from CMS or fallback to local asset) -- + Widget _buildBannerImage(String imageUrl) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 57), + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: SizedBox( + height: 295, + width: double.infinity, + child: imageUrl.isNotEmpty + ? S3Image( + imageUrl: imageUrl, + height: 295, + fit: BoxFit.cover, + errorWidget: (context) => Image.asset( + 'assets/images/about_cityscape.png', + height: 295, + width: double.infinity, + fit: BoxFit.cover, + ), + ) + : Image.asset( + 'assets/images/about_cityscape.png', + height: 295, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + ), + ); + } + + // -- Banner Caption -- + Widget _buildBannerCaption(String text) { + return Center( + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + ); + } + + // -- Stats Row (dynamic) -- + Widget _buildStatsRow(List<_AboutStatItem> stats) { + if (stats.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: stats.map((s) => _buildStatItem(s.value, s.label)).toList(), + ), + ); + } + + Widget _buildStatItem(String value, String label) { + return Column( + children: [ + Text( + value, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.accentOrange, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ], + ); + } + + // -- Section Title -- + Widget _buildSectionTitle(String text) { + return Center( + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 25, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ); + } + + // -- Why Choose Us Subtitle -- + Widget _buildWhyChooseUsSubtitle() { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 65), + child: Text( + 'Our core principles guide every interaction, ensuring you feel confident and supported at every step.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ); + } + + // -- Feature Card (dynamic with S3 icon support) -- + Widget _buildFeatureCard(_AboutFeatureItem feature) { + final fallbackIcon = _featureIconFallback[feature.title] ?? Icons.star_outline; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 57), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1), + width: 0.1, + ), + ), + child: Column( + children: [ + if (feature.iconPath.isNotEmpty) + SizedBox( + width: 36, + height: 36, + child: S3Image( + imageUrl: feature.iconPath, + width: 36, + height: 36, + fit: BoxFit.contain, + errorWidget: (_) => Icon( + fallbackIcon, + size: 36, + color: AppColors.accentOrange, + ), + ), + ) + else + Icon( + fallbackIcon, + size: 36, + color: AppColors.accentOrange, + ), + const SizedBox(height: 10), + Text( + feature.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + Text( + feature.description, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + height: 1.5, + ), + ), + ], + ), + ), + ); + } + + // -- Team Section (dynamic with carousel) -- + Widget _buildTeamSection( + BuildContext context, + WidgetRef ref, + _AboutState state, + ) { + final members = state.teamMembers; + if (members.isEmpty) return const SizedBox.shrink(); + + final activeIndex = state.activeTeamIndex.clamp(0, members.length - 1); + final screenWidth = MediaQuery.of(context).size.width; + final cardWidth = screenWidth - (69 * 2); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 57), + child: Text( + state.teamTitle, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 25, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 57), + child: Text( + state.teamSubtitle, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 40), + // Team carousel + SizedBox( + height: 330, + child: PageView.builder( + itemCount: members.length, + controller: PageController( + viewportFraction: cardWidth / screenWidth, + ), + onPageChanged: (index) { + ref.read(_aboutProvider.notifier).setActiveTeamIndex(index); + }, + itemBuilder: (context, index) { + final member = members[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1), + width: 0.1, + ), + ), + child: Column( + children: [ + const SizedBox(height: 19), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: SizedBox( + height: 211, + width: double.infinity, + child: _buildTeamMemberImage(member.imageUrl), + ), + ), + ), + const SizedBox(height: 16), + Text( + member.name, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + const SizedBox(height: 4), + Text( + member.role, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w300, + color: Colors.black, + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + // Page indicator + if (members.length > 1) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 58), + child: LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + final segmentWidth = totalWidth / members.length; + return Stack( + children: [ + Container( + height: 5, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1), + width: 0.1, + ), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + left: segmentWidth * activeIndex, + child: Container( + height: 5, + width: segmentWidth, + decoration: BoxDecoration( + color: AppColors.primaryDark, + borderRadius: BorderRadius.circular(15), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ); + } + + // -- Team member image (handles asset://, S3 keys, and URLs) -- + Widget _buildTeamMemberImage(String imageUrl) { + const placeholder = _TeamMemberPlaceholder(); + + if (imageUrl.isEmpty) return placeholder; + + // Local asset images (defaults) + if (imageUrl.startsWith('asset://')) { + final name = imageUrl.replaceFirst('asset://', ''); + return Image.asset( + 'assets/images/$name.jpg', + height: 211, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => placeholder, + ); + } + + // S3 key or URL from CMS + return S3Image( + imageUrl: imageUrl, + height: 211, + fit: BoxFit.cover, + placeholder: (_) => placeholder, + errorWidget: (_) => placeholder, + ); + } + + // -- CTA Section (dynamic) -- + Widget _buildCtaSection(BuildContext context, _AboutCta cta) { + return Column( + children: [ + Text( + cta.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 25, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 83), + child: Text( + cta.description, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 24), + // Illustration + Padding( + padding: const EdgeInsets.symmetric(horizontal: 34), + child: ClipRRect( + borderRadius: BorderRadius.circular(56), + child: Image.asset( + 'assets/images/about_find_agent.jpg', + height: 241, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 24), + // Find Agents Now button + GestureDetector( + onTap: () => context.push('/agents/search'), + child: Container( + height: 50, + width: 210, + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(25), + ), + alignment: Alignment.center, + child: Text( + cta.buttonText, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + ), + const SizedBox(height: 12), + // Get Help button + GestureDetector( + onTap: () => context.push('/contact'), + child: Container( + height: 50, + width: 210, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25), + border: Border.all( + color: AppColors.primaryDark, + width: 1, + ), + ), + alignment: Alignment.center, + child: const Text( + 'Get Help', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + ), + ], + ); + } + + // -- Bottom Navigation Bar -- + Widget _buildBottomNavBar(BuildContext context, WidgetRef ref) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: Color(0xFFE8E8E8), width: 1), + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavItem( + context: context, + ref: ref, + svgPath: 'assets/icons/nav_home_icon.svg', + fallbackIcon: Icons.home, + isSelected: false, + onTap: () => context.go('/home'), + ), + _buildNavItem( + context: context, + ref: ref, + svgPath: 'assets/icons/nav_list_icon.svg', + fallbackIcon: Icons.list, + isSelected: false, + onTap: () => context.push('/agents/search'), + ), + _buildNavItem( + context: context, + ref: ref, + svgPath: 'assets/icons/nav_messages_icon.svg', + fallbackIcon: Icons.chat_bubble, + isSelected: false, + onTap: () { + final authState = ref.read(authProvider); + if (authState.status != AuthStatus.authenticated) { + context.push('/login'); + return; + } + context.go('/messages'); + }, + ), + _buildNavItem( + context: context, + ref: ref, + svgPath: 'assets/icons/nav_profile_icon.svg', + fallbackIcon: Icons.person_outline, + isSelected: false, + onTap: () { + final authState = ref.read(authProvider); + if (authState.status != AuthStatus.authenticated) { + context.push('/login'); + return; + } + context.push('/profile'); + }, + ), + ], + ), + ), + ), + ); + } + + Widget _buildNavItem({ + required BuildContext context, + required WidgetRef ref, + required String svgPath, + required IconData fallbackIcon, + required bool isSelected, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SvgPicture.asset( + svgPath, + width: 24, + height: 24, + colorFilter: isSelected + ? const ColorFilter.mode(AppColors.primaryDark, BlendMode.srcIn) + : const ColorFilter.mode( + AppColors.accentOrange, BlendMode.srcIn), + placeholderBuilder: (_) => Icon( + fallbackIcon, + size: 24, + color: isSelected ? AppColors.primaryDark : AppColors.accentOrange, + ), + ), + ), + ); + } +} + +class _TeamMemberPlaceholder extends StatelessWidget { + const _TeamMemberPlaceholder(); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFFC4D9D4), + child: const Center( + child: Icon(Icons.person, size: 60, color: AppColors.primaryDark), + ), + ); + } +} diff --git a/lib/features/agents/presentation/screens/agent_home_screen.dart b/lib/features/agents/presentation/screens/agent_home_screen.dart new file mode 100644 index 0000000..3c77cf1 --- /dev/null +++ b/lib/features/agents/presentation/screens/agent_home_screen.dart @@ -0,0 +1,1236 @@ +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/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/agent_detail_provider.dart'; +import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart'; +import 'package:real_estate_mobile/features/profile/data/profile_repository.dart'; + +/// Provider that fetches the agent's own profile ID from /agents/profile/me +final _agentMyProfileProvider = + FutureProvider.autoDispose((ref) async { + final repo = ProfileRepository(); + try { + final data = await repo.getMyProfile('AGENT'); + return data['id'] as String?; + } catch (_) { + return null; + } +}); + +class AgentHomeScreen extends ConsumerWidget { + const AgentHomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final myProfileAsync = ref.watch(_agentMyProfileProvider); + + return myProfileAsync.when( + loading: () => const Scaffold( + backgroundColor: Colors.white, + body: Center( + child: CircularProgressIndicator( + color: AppColors.accentOrange, + strokeWidth: 2, + ), + ), + ), + error: (_, __) => Scaffold( + backgroundColor: Colors.white, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, + size: 48, color: AppColors.accentOrange), + const SizedBox(height: 16), + const Text('Failed to load profile', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark)), + const SizedBox(height: 12), + TextButton( + onPressed: () => ref.invalidate(_agentMyProfileProvider), + child: const Text('Retry', + style: TextStyle(color: AppColors.accentOrange)), + ), + ], + ), + ), + ), + data: (agentProfileId) { + if (agentProfileId == null) { + return const Scaffold( + backgroundColor: Colors.white, + body: Center( + child: Text('No agent profile found', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + color: AppColors.primaryDark)), + ), + ); + } + return _AgentHomeContent(agentProfileId: agentProfileId); + }, + ); + } +} + +class _AgentHomeContent extends ConsumerStatefulWidget { + final String agentProfileId; + + const _AgentHomeContent({required this.agentProfileId}); + + @override + ConsumerState<_AgentHomeContent> createState() => _AgentHomeContentState(); +} + +class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> { + @override + Widget build(BuildContext context) { + final state = ref.watch(agentDetailProvider(widget.agentProfileId)); + + return Scaffold( + backgroundColor: Colors.white, + body: state.isLoading + ? const Center( + child: CircularProgressIndicator(color: AppColors.accentOrange)) + : state.error != null + ? _buildError(state.error!) + : _buildContent(state), + bottomNavigationBar: _buildBottomNavBar(), + ); + } + + Widget _buildError(String error) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, + size: 48, color: AppColors.accentOrange), + const SizedBox(height: 16), + const Text('Unable to Load Profile', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + const SizedBox(height: 12), + TextButton( + onPressed: () => + ref.invalidate(agentDetailProvider(widget.agentProfileId)), + child: const Text('Retry', + style: TextStyle(color: AppColors.accentOrange)), + ), + ], + ), + ); + } + + Widget _buildContent(AgentDetailState state) { + final agent = state.agent!; + return SingleChildScrollView( + child: Column( + children: [ + SafeArea(bottom: false, child: const HomeHeader()), + const SizedBox(height: 12), + _buildCoverAndAvatar(agent), + const SizedBox(height: 16), + _buildStatusSection(agent), + const SizedBox(height: 12), + _buildContactInfo(state), + const SizedBox(height: 16), + _buildProfileInfo(agent), + const SizedBox(height: 24), + _buildExperienceSection(state), + const SizedBox(height: 24), + _buildInfoCards(state), + const SizedBox(height: 24), + _buildSpecializationSection(state), + const SizedBox(height: 24), + _buildTestimonialsSection(state), + const SizedBox(height: 24), + ], + ), + ); + } + + // ── Agent profile photo as cover image ── + Widget _buildCoverAndAvatar(AgentProfile agent) { + final screenWidth = MediaQuery.of(context).size.width; + final coverWidth = screenWidth * 0.877; // 377/430 from Figma + + return Center( + child: Stack( + children: [ + // Agent photo as the cover + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: SizedBox( + width: coverWidth, + height: 346, + child: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty + ? S3Image( + imageUrl: agent.avatarUrl!, + width: coverWidth, + height: 346, + fit: BoxFit.cover, + ) + : Container( + color: const Color(0xFFC4D9D4), + alignment: Alignment.center, + child: Icon(Icons.person, + size: 80, color: AppColors.primaryDark), + ), + ), + ), + // Orange edit icon top-right (tabler:edit style) + Positioned( + top: 12, + right: 10, + child: Container( + width: 28, + height: 26, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(7), + ), + alignment: Alignment.center, + child: SvgPicture.asset( + 'assets/icons/edit_icon.svg', + width: 20, + height: 20, + ), + ), + ), + ], + ), + ); + } + + // ── Available / Unavailable status ── + Widget _buildStatusSection(AgentProfile agent) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + _buildStatusRow( + isAvailable: true, + label: 'Available.', + buttonLabel: 'Connect', + buttonColor: AppColors.primaryDark, + buttonTextColor: Colors.white, + ), + const SizedBox(height: 12), + _buildStatusRow( + isAvailable: false, + label: 'Unavailable.', + buttonLabel: 'Connect', + buttonColor: AppColors.accentOrange, + buttonTextColor: AppColors.primaryDark, + ), + ], + ), + ); + } + + Widget _buildStatusRow({ + required bool isAvailable, + required String label, + required String buttonLabel, + required Color buttonColor, + required Color buttonTextColor, + }) { + return Container( + height: 55, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: const Color(0xFFE0E0E0), width: 1), + ), + child: Row( + children: [ + const SizedBox(width: 20), + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isAvailable + ? const Color(0xFF4CAF50) + : const Color(0xFFD0D0D0), + ), + ), + const SizedBox(width: 10), + Text( + label, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const Spacer(), + Container( + height: 55, + width: 164, + decoration: BoxDecoration( + color: buttonColor, + borderRadius: BorderRadius.circular(15), + ), + alignment: Alignment.center, + child: Text( + buttonLabel, + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w400, + color: buttonTextColor, + ), + ), + ), + ], + ), + ); + } + + // ── Contact Info ── + Widget _buildContactInfo(AgentDetailState state) { + final email = state.contactEmail ?? ''; + final phone = state.contactPhone ?? ''; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all(color: AppColors.primaryDark, width: 0.1), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text('Email:', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + const SizedBox(width: 16), + Expanded( + child: Text( + email.isNotEmpty ? state.maskEmail(email) : '-', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark), + ), + ), + const Icon(Icons.visibility_off_outlined, + size: 18, color: AppColors.primaryDark), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + const Text('Ph.No:', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + const SizedBox(width: 16), + Expanded( + child: Text( + phone.isNotEmpty ? state.maskPhone(phone) : '-', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark), + ), + ), + const Icon(Icons.visibility_off_outlined, + size: 18, color: AppColors.primaryDark), + ], + ), + ], + ), + ); + } + + // ── Name, verified badge, title, location, member since, bio ── + Widget _buildProfileInfo(AgentProfile agent) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 38), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Name + Verified badge + (Verified Expert) on same line + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + agent.firstName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(width: 4), + if (agent.isVerified) ...[ + const Padding( + padding: EdgeInsets.only(bottom: 1), + child: Icon(Icons.verified, + size: 16, color: Color(0xFF2196F3)), + ), + const SizedBox(width: 4), + ], + Text( + agent.lastName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + if (agent.isVerified) ...[ + const SizedBox(width: 6), + const Text( + '(Verified Expert)', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF638559), + ), + ), + ], + ], + ), + // Title + if (agent.agentType != null) ...[ + const SizedBox(height: 6), + Text( + agent.headline ?? agent.agentType!.name, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + color: AppColors.primaryDark, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 8), + // Location + Member Since + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (agent.location.isNotEmpty) ...[ + Icon(Icons.location_on, + size: 16, + color: + AppColors.primaryDark.withValues(alpha: 0.7)), + const SizedBox(width: 4), + Text( + agent.location, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(width: 20), + ], + Icon(Icons.calendar_today, + size: 14, + color: + AppColors.primaryDark.withValues(alpha: 0.7)), + const SizedBox(width: 4), + Text( + agent.memberSinceText, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + ], + ), + // Bio + if (agent.description.isNotEmpty) ...[ + const SizedBox(height: 16), + SizedBox( + width: 338, + child: Text( + agent.description, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + height: 1.43, + ), + textAlign: TextAlign.center, + ), + ), + ], + ], + ), + // Edit icon positioned at top-right + Positioned( + right: 0, + top: 0, + child: GestureDetector( + onTap: () {}, + child: SvgPicture.asset( + 'assets/icons/edit_icon.svg', + width: 22, + height: 22, + ), + ), + ), + ], + ), + ); + } + + // ── Experience Section ── + Widget _buildExperienceSection(AgentDetailState state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text('Experience', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + const SizedBox(height: 16), + _buildExperienceRow( + 'Years in Experience', state.yearsInExperience), + const Divider(height: 24, thickness: 0.2), + _buildExperienceRow( + 'Number of contract closed', state.contractsClosed), + const Divider(height: 24, thickness: 0.2), + if (state.licensingAreas.isNotEmpty) ...[ + const Align( + alignment: Alignment.centerLeft, + child: Text('Licensing & Areas', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + ), + const SizedBox(height: 8), + _buildTagsWrap(state.licensingAreas), + const SizedBox(height: 16), + ], + if (state.expertiseYears.isNotEmpty) ...[ + const Align( + alignment: Alignment.centerLeft, + child: Text('Areas in expertise & Years', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + ), + const SizedBox(height: 8), + _buildTagsWrap(state.expertiseYears + .map((e) => e['years']!.isNotEmpty + ? '${e['area']} – ${e['years']}' + : e['area']!) + .toList()), + ], + if (state.certifications.isNotEmpty) ...[ + const SizedBox(height: 16), + const Align( + alignment: Alignment.centerLeft, + child: Text('Certifications', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + ), + const SizedBox(height: 8), + ...state.certifications.map((cert) => Padding( + padding: const EdgeInsets.only(bottom: 12, left: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('\u2022 ', + style: TextStyle( + fontSize: 14, + color: AppColors.primaryDark)), + Expanded( + child: Text(cert['name']!, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark)), + ), + ], + ), + if (cert['org']!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 16), + child: Opacity( + opacity: 0.5, + child: Text(cert['org']!, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark)), + ), + ), + ], + ), + )), + ], + ], + ), + ); + } + + Widget _buildExperienceRow(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Row( + children: [ + const Text('\u2022 ', + style: TextStyle( + fontSize: 15, color: AppColors.primaryDark)), + Text(value, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark)), + ], + ), + ), + ], + ); + } + + Widget _buildTagsWrap(List tags) { + final displayTags = tags.take(6).toList(); + final remaining = tags.length - 6; + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...displayTags.map((tag) => Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: + Border.all(color: AppColors.primaryDark, width: 0.7), + ), + child: Text(tag, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark)), + )), + if (remaining > 0) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all(color: AppColors.primaryDark, width: 0.1), + ), + child: Text('+$remaining More', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 15, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark)), + ), + ], + ); + } + + // ── Info Cards (Availability, Work Environment, Experience) ── + Widget _buildInfoCards(AgentDetailState state) { + // Extract work environment and best experience from fieldValues + String workEnv = ''; + String bestExp = ''; + for (final fv in state.fieldValues) { + final slug = fv.fieldSlug.toLowerCase(); + if (slug.contains('work_environment') || + slug.contains('preferred_environment')) { + if (fv.textValue != null) workEnv = fv.textValue!; + if (fv.jsonValue is List) { + workEnv = + (fv.jsonValue as List).map((e) => e.toString()).join(', '); + } + } + if (slug.contains('best_experience') || + slug.contains('amazing_experience')) { + if (fv.textValue != null) bestExp = fv.textValue!; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + children: [ + // Availability card + if (state.availabilityType.isNotEmpty) + _buildInfoCard( + icon: Icons.access_time_filled, + title: 'Availability', + subtitle: state.availabilityType, + items: state.availabilitySchedule, + ), + if (workEnv.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildInfoCard( + icon: Icons.landscape_outlined, + description: workEnv, + ), + ], + if (bestExp.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildInfoCard( + icon: Icons.star_rounded, + description: bestExp, + ), + ], + ], + ), + ); + } + + Widget _buildInfoCard({ + required IconData icon, + String title = '', + String subtitle = '', + List? items, + String? description, + }) { + return Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 217), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD9D9D9).withValues(alpha: 0.5), + offset: const Offset(0, 10), + blurRadius: 20, + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(icon, size: 36, color: AppColors.accentOrange), + if (title.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + title, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ], + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ], + if (items != null && items.isNotEmpty) ...[ + const SizedBox(height: 8), + ...items.map((item) => Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + item, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + ), + )), + ], + if (description != null && description.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + description, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ); + } + + // ── Specialization Section (grey background) ── + Widget _buildSpecializationSection(AgentDetailState state) { + final cards = state.specializationCards; + if (cards.isEmpty) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + color: const Color(0xFFE6E6E6), + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + const Text('Specialization', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + const SizedBox(height: 4), + const Text('Area Of Expertise and Focus', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark)), + const SizedBox(height: 16), + ...cards.map((card) => _buildSpecCard(card)), + ], + ), + ); + } + + Widget _buildSpecCard(SpecializationCard card) { + final displayName = AgentDetailState.titleCase(card.name); + return Container( + margin: const EdgeInsets.symmetric(horizontal: 66, vertical: 8), + width: 298, + constraints: const BoxConstraints(minHeight: 236), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: AppColors.primaryDark, width: 0.7), + ), + child: Column( + children: [ + const Icon(Icons.category_outlined, + size: 28, color: AppColors.accentOrange), + const SizedBox(height: 10), + Text(displayName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + const SizedBox(height: 12), + ...card.values.take(5).map((val) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + AgentDetailState.titleCase(val), + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + height: 1.8), + ), + )), + if (card.values.length > 5) ...[ + const SizedBox(height: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: + Border.all(color: AppColors.accentOrange, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Show More', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.accentOrange)), + const SizedBox(width: 4), + Icon(Icons.keyboard_arrow_down, + size: 14, color: AppColors.accentOrange), + ], + ), + ), + ], + ], + ), + ); + } + + // ── Testimonials Section ── + Widget _buildTestimonialsSection(AgentDetailState state) { + if (state.testimonials.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const Text( + 'Testimonials', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + Divider( + color: AppColors.primaryDark.withValues(alpha: 0.15), + height: 1), + const SizedBox(height: 8), + const Text( + 'Clients rate our real estate services 4.9 out of 5 on average, based on recent client reviews.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + SizedBox( + height: 320, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.testimonials.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (_, i) => + _TestimonialCard(data: state.testimonials[i]), + ), + ), + ], + ), + ); + } + + // ── Bottom Navigation Bar ── + Widget _buildBottomNavBar() { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: Color(0xFFE8E8E8), width: 1), + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavItem( + svgPath: 'assets/icons/nav_home_icon.svg', + fallbackIcon: Icons.home, + isSelected: true, + onTap: () {}, + ), + _buildNavItem( + svgPath: 'assets/icons/nav_list_icon.svg', + fallbackIcon: Icons.list, + isSelected: false, + onTap: () => context.push('/agents/search'), + ), + _buildNavItem( + svgPath: 'assets/icons/nav_messages_icon.svg', + fallbackIcon: Icons.chat_bubble, + isSelected: false, + onTap: () { + final authState = ref.read(authProvider); + if (authState.status != AuthStatus.authenticated) { + context.push('/login'); + return; + } + context.push('/messages'); + }, + ), + _buildNavItem( + svgPath: 'assets/icons/nav_profile_icon.svg', + fallbackIcon: Icons.person_outline, + isSelected: false, + onTap: () { + final authState = ref.read(authProvider); + if (authState.status != AuthStatus.authenticated) { + context.push('/login'); + return; + } + context.push('/profile'); + }, + ), + ], + ), + ), + ), + ); + } + + Widget _buildNavItem({ + required String svgPath, + required IconData fallbackIcon, + required bool isSelected, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SvgPicture.asset( + svgPath, + width: 24, + height: 24, + colorFilter: isSelected + ? const ColorFilter.mode( + AppColors.primaryDark, BlendMode.srcIn) + : const ColorFilter.mode( + AppColors.accentOrange, BlendMode.srcIn), + placeholderBuilder: (_) => Icon( + fallbackIcon, + size: 24, + color: + isSelected ? AppColors.primaryDark : AppColors.accentOrange, + ), + ), + ), + ); + } +} + +// ── Testimonial Card (same as agent detail screen) ── +class _TestimonialCard extends StatelessWidget { + final Map data; + + const _TestimonialCard({required this.data}); + + @override + Widget build(BuildContext context) { + final text = data['text'] as String? ?? ''; + final authorName = data['authorName'] as String? ?? ''; + final authorRole = data['authorRole'] as String? ?? ''; + final rating = (data['rating'] as num?)?.toInt() ?? 5; + + return SizedBox( + width: 320, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Main card body with speech bubble shape + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD9D9D9).withValues(alpha: 0.5), + offset: const Offset(0, 10), + blurRadius: 20, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quote icon + const Text( + '\u201C', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 36, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + height: 0.8, + ), + ), + const SizedBox(height: 6), + // Title snippet + Text( + text.length > 35 + ? '${text.substring(0, 35)} ..' + : text, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 10), + // Full text + Expanded( + child: Text( + text, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + height: 1.5, + ), + overflow: TextOverflow.fade, + ), + ), + ], + ), + ), + ), + // Speech bubble triangle + Padding( + padding: const EdgeInsets.only(left: 30), + child: CustomPaint( + size: const Size(16, 10), + painter: _TrianglePainter(color: Colors.white), + ), + ), + const SizedBox(height: 6), + // Stars + Author info + Padding( + padding: const EdgeInsets.only(left: 4), + child: Row( + children: [ + // Stars + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + 5, + (i) => Icon( + i < rating ? Icons.star : Icons.star_border, + size: 16, + color: i < rating + ? AppColors.accentOrange + : AppColors.primaryDark.withValues(alpha: 0.3), + ), + ), + ), + const SizedBox(width: 10), + // Author + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + authorName, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + if (authorRole.isNotEmpty) + Text( + authorRole, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Triangle painter for speech bubble ── +class _TrianglePainter extends CustomPainter { + final Color color; + _TrianglePainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + final shadowPaint = Paint() + ..color = const Color(0xFFD9D9D9).withValues(alpha: 0.16) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); + + final path = Path() + ..moveTo(0, 0) + ..lineTo(size.width / 2, size.height) + ..lineTo(size.width, 0) + ..close(); + + canvas.drawPath(path, shadowPaint); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index 10d871f..b111e9e 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -148,7 +148,10 @@ class _MenuDrawer extends ConsumerWidget { context, icon: Icons.info_outline, label: 'About Us', - onTap: () => Navigator.pop(context), + onTap: () { + Navigator.pop(context); + context.push('/about'); + }, ), _buildMenuItem( context, diff --git a/lib/features/messaging/presentation/screens/chat_screen.dart b/lib/features/messaging/presentation/screens/chat_screen.dart index dc25a82..7181c91 100644 --- a/lib/features/messaging/presentation/screens/chat_screen.dart +++ b/lib/features/messaging/presentation/screens/chat_screen.dart @@ -4,9 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.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/utils/image_url_resolver.dart'; +import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart'; import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart'; +import 'package:url_launcher/url_launcher.dart'; class ChatScreen extends ConsumerStatefulWidget { final String conversationId; @@ -102,10 +105,12 @@ class _ChatScreenState extends ConsumerState { String _formatTime(String isoTimestamp) { try { final dt = DateTime.parse(isoTimestamp).toLocal(); - final hour = - dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour); + final hour = dt.hour > 12 + ? dt.hour - 12 + : (dt.hour == 0 ? 12 : dt.hour); final minute = dt.minute.toString().padLeft(2, '0'); - return '$hour.$minute'; + final amPm = dt.hour >= 12 ? 'PM' : 'AM'; + return '$hour:$minute $amPm'; } catch (_) { return ''; } @@ -156,6 +161,18 @@ class _ChatScreenState extends ConsumerState { return '$base/$avatarKey'; } + String _formatFileSize(int? bytes) { + if (bytes == null || bytes == 0) return ''; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + bool _isGiphyUrl(String text) { + final trimmed = text.trim(); + return trimmed.contains('giphy.com/') || trimmed.contains('giphy.gif'); + } + // ============================================================ // BUILD // ============================================================ @@ -219,7 +236,7 @@ class _ChatScreenState extends ConsumerState { } // ============================================================ - // HEADER - matches Figma: rounded border, back arrow, name, status, icons + // HEADER // ============================================================ Widget _buildHeader({ @@ -319,7 +336,7 @@ class _ChatScreenState extends ConsumerState { } // ============================================================ - // CHAT BODY (profile section + messages) + // CHAT BODY // ============================================================ Widget _buildChatBody({ @@ -372,7 +389,7 @@ class _ChatScreenState extends ConsumerState { } // ============================================================ - // PROFILE SECTION (large avatar, name, specialties) + // PROFILE SECTION // ============================================================ Widget _buildProfileSection({ @@ -399,7 +416,7 @@ class _ChatScreenState extends ConsumerState { width: 80, height: 80, fit: BoxFit.cover, - errorWidget: (_, __, ___) => + errorWidget: (_, e, s) => _AvatarFallback(letter: initials, size: 80), ) : _AvatarFallback(letter: initials, size: 80), @@ -472,7 +489,6 @@ class _ChatScreenState extends ConsumerState { } Widget _buildSpecialtiesRow(String headline) { - // Split by comma, pipe, or similar separators final specialties = headline.split(RegExp(r'[,|;]')) .map((s) => s.trim()) .where((s) => s.isNotEmpty) @@ -517,7 +533,6 @@ class _ChatScreenState extends ConsumerState { required bool isTyping, required bool isLoadingMore, }) { - // +1 for the profile section header at the end of reversed list final itemCount = messages.length + 1 + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0); @@ -533,7 +548,6 @@ class _ChatScreenState extends ConsumerState { final adjustedIndex = isTyping ? index - 1 : index; - // Profile section at the top (last index in reversed list) final profileIndex = messages.length + (isLoadingMore ? 1 : 0); if (adjustedIndex == profileIndex) { return _buildProfileSection( @@ -570,7 +584,6 @@ class _ChatScreenState extends ConsumerState { final showDateSeparator = _shouldShowDateSeparator(messages, adjustedIndex); - // Get sender info final senderName = isMine ? (message.sender.displayName.isNotEmpty ? message.sender.displayName @@ -634,9 +647,222 @@ class _ChatScreenState extends ConsumerState { ); } - // -- Sent Message (right-aligned, with avatar on right) -- + // ============================================================ + // MESSAGE CONTENT (handles text, image, gif, file) + // ============================================================ + + Widget _buildMessageContent(ChatMessage message, {required bool isMine}) { + switch (message.messageType) { + case MessageType.image: + return _buildImageContent(message, isMine: isMine); + case MessageType.file: + return _buildFileContent(message); + case MessageType.system: + return _buildSystemContent(message); + case MessageType.text: + // Detect Giphy URLs sent as text and render as GIF image + if (_isGiphyUrl(message.content)) { + return _buildImageContent(message, isMine: isMine); + } + return Text( + message.content, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 10, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + textAlign: isMine ? TextAlign.right : TextAlign.left, + ); + } + } + + // -- Image / GIF content -- + Widget _buildImageContent(ChatMessage message, {required bool isMine}) { + final imageUrl = message.fileUrl ?? message.content; + + // GIF from Giphy (direct URL) or S3 image + final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); + + return GestureDetector( + onTap: () => _showImageFullScreen(context, imageUrl), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 250, + maxHeight: 250, + ), + child: isDirectUrl + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => _buildImagePlaceholder(), + errorWidget: (context, url, error) => _buildImageError(), + ) + : S3Image( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (_) => _buildImagePlaceholder(), + errorWidget: (_) => _buildImageError(), + ), + ), + ), + ); + } + + Widget _buildImagePlaceholder() { + return Container( + width: 200, + height: 150, + color: const Color(0xFFF0F5FC), + child: const Center( + child: CircularProgressIndicator( + color: AppColors.accentOrange, + strokeWidth: 2, + ), + ), + ); + } + + Widget _buildImageError() { + return Container( + width: 200, + height: 150, + color: const Color(0xFFF0F5FC), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image_outlined, size: 40, color: AppColors.hintText), + SizedBox(height: 4), + Text( + 'Image not available', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 12, + color: AppColors.hintText, + ), + ), + ], + ), + ); + } + + // -- File content -- + Widget _buildFileContent(ChatMessage message) { + final fileName = message.fileName ?? 'File'; + final fileSize = _formatFileSize(message.fileSize); + + return GestureDetector( + onTap: () => _openOrDownloadFile(message), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF0F5FC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.accentOrange.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.attach_file, + size: 20, + color: AppColors.accentOrange, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (fileSize.isNotEmpty) + Text( + fileSize, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 10, + fontWeight: FontWeight.w400, + color: AppColors.hintText, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.download_rounded, + size: 20, + color: AppColors.accentOrange, + ), + ], + ), + ), + ); + } + + // -- System message -- + Widget _buildSystemContent(ChatMessage message) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF0F5FC), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + message.content, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 12, + fontWeight: FontWeight.w400, + color: AppColors.hintText, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ); + } + + // -- Message status indicator -- + Widget _buildStatusIcon(MessageStatus status) { + switch (status) { + case MessageStatus.read: + return const Icon(Icons.done_all, size: 14, color: Color(0xFF2196F3)); + case MessageStatus.delivered: + return Icon(Icons.done_all, size: 14, color: AppColors.hintText); + case MessageStatus.sent: + return Icon(Icons.done, size: 14, color: AppColors.hintText); + } + } + + // ============================================================ + // SENT MESSAGE (right-aligned) + // ============================================================ + Widget _buildSentMessage(ChatMessage message, String senderName) { final timestamp = _formatTime(message.createdAt); + final isMediaMessage = message.messageType == MessageType.image || + message.messageType == MessageType.file; return Row( mainAxisAlignment: MainAxisAlignment.end, @@ -648,10 +874,12 @@ class _ChatScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - // Name row with timestamp + // Name row with timestamp and status Row( mainAxisSize: MainAxisSize.min, children: [ + _buildStatusIcon(message.status), + const SizedBox(width: 4), Text( timestamp, style: const TextStyle( @@ -693,17 +921,23 @@ class _ChatScreenState extends ConsumerState { ], ), const SizedBox(height: 6), - // Message text - Text( - message.content, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 10, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, + // Message content + _buildMessageContent(message, isMine: true), + // Show text content below media if present + if (isMediaMessage && message.content.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + message.content, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 10, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + textAlign: TextAlign.right, + ), ), - textAlign: TextAlign.right, - ), ], ), ), @@ -714,10 +948,15 @@ class _ChatScreenState extends ConsumerState { ); } - // -- Received Message (left-aligned, with avatar on left) -- + // ============================================================ + // RECEIVED MESSAGE (left-aligned) + // ============================================================ + Widget _buildReceivedMessage( ChatMessage message, String senderName, String? avatar) { final timestamp = _formatTime(message.createdAt); + final isMediaMessage = message.messageType == MessageType.image || + message.messageType == MessageType.file; return Row( mainAxisAlignment: MainAxisAlignment.start, @@ -776,16 +1015,22 @@ class _ChatScreenState extends ConsumerState { ], ), const SizedBox(height: 6), - // Message text - Text( - message.content, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 10, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, + // Message content + _buildMessageContent(message, isMine: false), + // Show text content below media if present + if (isMediaMessage && message.content.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + message.content, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 10, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), ), - ), ], ), ), @@ -806,7 +1051,7 @@ class _ChatScreenState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorWidget: (_, __, ___) => + errorWidget: (_, e, s) => _AvatarFallback(letter: initials, size: size), ) : _AvatarFallback(letter: initials, size: size), @@ -854,7 +1099,83 @@ class _ChatScreenState extends ConsumerState { } // ============================================================ - // INPUT AREA - matches Figma: + circle, text input, send, mic circle + // IMAGE FULL SCREEN VIEWER + // ============================================================ + + void _showImageFullScreen(BuildContext context, String imageUrl) { + final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.white), + elevation: 0, + ), + body: Center( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 4.0, + child: isDirectUrl + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.contain, + placeholder: (context, url) => const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + errorWidget: (context, url, error) => const Icon( + Icons.broken_image, + size: 80, + color: Colors.white54, + ), + ) + : S3Image( + imageUrl: imageUrl, + fit: BoxFit.contain, + placeholder: (_) => const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + errorWidget: (_) => const Icon( + Icons.broken_image, + size: 80, + color: Colors.white54, + ), + ), + ), + ), + ), + ), + ); + } + + // ============================================================ + // FILE OPEN / DOWNLOAD + // ============================================================ + + Future _openOrDownloadFile(ChatMessage message) async { + final fileUrl = message.fileUrl; + if (fileUrl == null || fileUrl.isEmpty) return; + + String? url; + if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) { + url = fileUrl; + } else { + // Resolve S3 key to presigned download URL + url = await ImageUrlResolver.instance.resolve(fileUrl); + } + + if (url != null && url.isNotEmpty) { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + } + + // ============================================================ + // INPUT AREA // ============================================================ Widget _buildInputArea() { @@ -864,7 +1185,7 @@ class _ChatScreenState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Plus icon (plain, no border) + // Plus icon GestureDetector( onTap: _showAttachmentOptions, behavior: HitTestBehavior.opaque, @@ -881,15 +1202,17 @@ class _ChatScreenState extends ConsumerState { // Text input pill with send icon inside Expanded( child: Container( - height: 40, + height: 44, + margin: const EdgeInsets.only(left: 4), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(22), border: Border.all( color: AppColors.primaryDark, width: 1.5, ), ), + clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( @@ -913,8 +1236,8 @@ class _ChatScreenState extends ConsumerState { color: AppColors.primaryDark, ), contentPadding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + horizontal: 18, + vertical: 10, ), border: InputBorder.none, enabledBorder: InputBorder.none, @@ -927,7 +1250,7 @@ class _ChatScreenState extends ConsumerState { onTap: _sendMessage, behavior: HitTestBehavior.opaque, child: Padding( - padding: const EdgeInsets.only(right: 12), + padding: const EdgeInsets.only(right: 14), child: Icon( Icons.send, size: 20, diff --git a/lib/features/messaging/presentation/screens/conversations_screen.dart b/lib/features/messaging/presentation/screens/conversations_screen.dart index ac61335..a786e7b 100644 --- a/lib/features/messaging/presentation/screens/conversations_screen.dart +++ b/lib/features/messaging/presentation/screens/conversations_screen.dart @@ -63,15 +63,15 @@ class _ConversationsScreenState extends ConsumerState { // -- Header with back arrow, search, icons -- Widget _buildHeader() { return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: Container( - height: 51, + height: 48, decoration: BoxDecoration( border: Border.all( - color: AppColors.primaryDark, - width: 0.5, + color: AppColors.primaryDark.withValues(alpha: 0.2), + width: 1, ), - borderRadius: BorderRadius.circular(7), + borderRadius: BorderRadius.circular(15), ), child: Row( children: [ @@ -88,11 +88,11 @@ class _ConversationsScreenState extends ConsumerState { } }, behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 14), child: Icon( Icons.arrow_back_ios_new, - size: 17, + size: 16, color: AppColors.primaryDark, ), ), @@ -106,7 +106,7 @@ class _ConversationsScreenState extends ConsumerState { style: const TextStyle( fontFamily: 'Fractul', fontSize: 15, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: AppColors.primaryDark, ), decoration: const InputDecoration( @@ -114,7 +114,7 @@ class _ConversationsScreenState extends ConsumerState { hintStyle: TextStyle( fontFamily: 'Fractul', fontSize: 15, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: AppColors.hintText, ), border: InputBorder.none, @@ -129,7 +129,7 @@ class _ConversationsScreenState extends ConsumerState { style: TextStyle( fontFamily: 'Fractul', fontSize: 15, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, color: AppColors.primaryDark, ), ), @@ -139,11 +139,11 @@ class _ConversationsScreenState extends ConsumerState { GestureDetector( onTap: () => setState(() => _isSearchActive = true), behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 10), child: Icon( Icons.search, - size: 20, + size: 22, color: AppColors.primaryDark, ), ), @@ -152,11 +152,11 @@ class _ConversationsScreenState extends ConsumerState { GestureDetector( onTap: () {}, behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.only(right: 12, left: 4), + child: const Padding( + padding: EdgeInsets.only(right: 14, left: 2), child: Icon( Icons.more_horiz, - size: 20, + size: 22, color: AppColors.primaryDark, ), ), @@ -173,7 +173,7 @@ class _ConversationsScreenState extends ConsumerState { baseColor: const Color(0xFFE8E8E8), highlightColor: const Color(0xFFF5F5F5), child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 16), physics: const NeverScrollableScrollPhysics(), itemCount: 6, itemBuilder: (_, index) => Padding( @@ -181,14 +181,14 @@ class _ConversationsScreenState extends ConsumerState { child: Row( children: [ Container( - width: 52, - height: 52, + width: 56, + height: 56, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), ), - const SizedBox(width: 10), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -215,7 +215,7 @@ class _ConversationsScreenState extends ConsumerState { ), const SizedBox(width: 8), Container( - width: 45, + width: 55, height: 12, decoration: BoxDecoration( color: Colors.white, @@ -303,10 +303,13 @@ class _ConversationsScreenState extends ConsumerState { child: ListView.separated( padding: EdgeInsets.zero, itemCount: filtered.length, - separatorBuilder: (_, __) => Divider( - color: AppColors.primaryDark.withValues(alpha: 0.1), - height: 0.5, - thickness: 0.5, + separatorBuilder: (context, index) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + color: AppColors.primaryDark.withValues(alpha: 0.1), + height: 1, + thickness: 1, + ), ), itemBuilder: (context, index) { return _ConversationTile( @@ -318,7 +321,6 @@ class _ConversationsScreenState extends ConsumerState { ), ); } - } // -- Conversation Tile Widget -- @@ -350,6 +352,7 @@ class _ConversationTile extends StatelessWidget { final avatarUrl = _resolveAvatarUrl(otherParty.avatar); final lastMessage = conversation.lastMessageText ?? ''; final timestamp = _formatTimestamp(conversation.lastMessageAt); + final unread = conversation.unreadCount; final firstLetter = otherParty.name.isNotEmpty ? otherParty.name[0].toUpperCase() : '?'; @@ -357,32 +360,45 @@ class _ConversationTile extends StatelessWidget { return InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ // Avatar with online indicator Stack( children: [ - ClipOval( - child: avatarUrl.isNotEmpty - ? CachedNetworkImage( - imageUrl: avatarUrl, - width: 52, - height: 52, - fit: BoxFit.cover, - placeholder: (_, url) => _AvatarFallback( - letter: firstLetter, - ), - errorWidget: (_, url, err) => _AvatarFallback( - letter: firstLetter, - ), - ) - : _AvatarFallback(letter: firstLetter), + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.15), + width: 1.5, + ), + ), + child: ClipOval( + child: avatarUrl.isNotEmpty + ? CachedNetworkImage( + imageUrl: avatarUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + placeholder: (context, url) => _AvatarFallback( + letter: firstLetter, + ), + errorWidget: (context, url, err) => + _AvatarFallback( + letter: firstLetter, + ), + ) + : _AvatarFallback(letter: firstLetter), + ), ), if (otherParty.isOnline) Positioned( - right: 0, - bottom: 0, + right: 1, + bottom: 1, child: Container( width: 14, height: 14, @@ -398,31 +414,61 @@ class _ConversationTile extends StatelessWidget { ), ], ), - const SizedBox(width: 10), + const SizedBox(width: 14), // Name and last message Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - otherParty.name, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + // Name row with optional unread badge + Row( + children: [ + Expanded( + child: Text( + otherParty.name, + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: + unread > 0 ? FontWeight.w700 : FontWeight.w600, + color: AppColors.primaryDark, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (unread > 0) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$unread', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ], ), const SizedBox(height: 4), Text( lastMessage, - style: const TextStyle( + style: TextStyle( fontFamily: 'SourceSerif4', - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.w400, - color: AppColors.primaryDark, + color: unread > 0 + ? AppColors.primaryDark + : AppColors.hintText, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -430,15 +476,17 @@ class _ConversationTile extends StatelessWidget { ], ), ), - const SizedBox(width: 8), + const SizedBox(width: 10), // Timestamp Text( timestamp, - style: const TextStyle( + style: TextStyle( fontFamily: 'SourceSerif4', - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.w400, - color: AppColors.primaryDark, + color: unread > 0 + ? AppColors.primaryDark + : AppColors.hintText, ), ), ], @@ -458,8 +506,8 @@ class _AvatarFallback extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: 52, - height: 52, + width: 56, + height: 56, decoration: const BoxDecoration( color: Color(0xFFE8E8E8), shape: BoxShape.circle, @@ -469,7 +517,7 @@ class _AvatarFallback extends StatelessWidget { letter, style: const TextStyle( fontFamily: 'Fractul', - fontSize: 20, + fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.primaryDark, ), @@ -497,7 +545,7 @@ String _formatTimestamp(String? isoTimestamp) { return 'Now'; } - // Same day: show time like "2 hours Ago" + // Same day: show relative time if (messageDate == today) { if (diff.inHours >= 1) { return '${diff.inHours} hour${diff.inHours > 1 ? 's' : ''} Ago'; diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 1a287f4..bc48250 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -11,7 +11,9 @@ import 'package:real_estate_mobile/features/home/presentation/screens/home_scree import 'package:real_estate_mobile/features/messaging/presentation/screens/conversations_screen.dart'; import 'package:real_estate_mobile/features/messaging/presentation/screens/chat_screen.dart'; import 'package:real_estate_mobile/features/messaging/presentation/screens/messaging_shell.dart'; +import 'package:real_estate_mobile/features/agents/presentation/screens/agent_home_screen.dart'; import 'package:real_estate_mobile/features/contact/presentation/screens/contact_screen.dart'; +import 'package:real_estate_mobile/features/about/presentation/screens/about_screen.dart'; import 'package:real_estate_mobile/features/profile/presentation/screens/profile_settings_screen.dart'; final routerProvider = Provider((ref) { @@ -26,7 +28,7 @@ final routerProvider = Provider((ref) { }); // Public routes accessible without authentication (matching web middleware) - const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq', '/contact']; + const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq', '/contact', '/about']; const authRoutes = ['/login', '/signup']; return GoRouter( @@ -68,7 +70,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: '/home', - builder: (context, state) => const HomeScreen(), + builder: (context, state) => const _HomeRouteWrapper(), ), GoRoute( path: '/agents/search', @@ -108,6 +110,10 @@ final routerProvider = Provider((ref) { path: '/contact', builder: (context, state) => const ContactScreen(), ), + GoRoute( + path: '/about', + builder: (context, state) => const AboutScreen(), + ), GoRoute( path: '/profile', builder: (context, state) => const ProfileSettingsScreen(), @@ -116,6 +122,21 @@ final routerProvider = Provider((ref) { ); }); +/// Reactively switches between HomeScreen and AgentHomeScreen based on auth. +class _HomeRouteWrapper extends ConsumerWidget { + const _HomeRouteWrapper(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authProvider); + if (authState.status == AuthStatus.authenticated && + authState.user?.role == 'AGENT') { + return const AgentHomeScreen(); + } + return const HomeScreen(); + } +} + /// Notifier that triggers GoRouter refresh when auth state changes. class _AuthRefreshNotifier extends ChangeNotifier { void notify() { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index dd8425c..f4a99c5 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) emoji_picker_flutter_registrar = @@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a39777e..512e235 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST emoji_picker_flutter flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 84cdd3c..019226f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import flutter_secure_storage_macos import path_provider_foundation import shared_preferences_foundation import sqflite_darwin +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin")) @@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 70013ac..c06980e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1037,6 +1037,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: @@ -1151,4 +1215,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.1 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 158fefd..869b4b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,9 @@ dependencies: # Pin to avoid objective_c crash on iOS 26 simulator path_provider_foundation: 2.4.0 + # URL launching + url_launcher: ^6.3.1 + # Utils intl: ^0.20.2 logger: ^2.5.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a799fa3..03aa6eb 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { EmojiPickerFlutterPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("EmojiPickerFlutterPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 6595782..1edf968 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST emoji_picker_flutter flutter_secure_storage_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST