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