feat: Add professional ratings to cards, implement scroll-to-top on home tab re-tap, and improve agent type fetching in the hero section.
This commit is contained in:
@@ -4,6 +4,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
|
||||||
|
|
||||||
/// Determines which tab is active based on the current route.
|
/// Determines which tab is active based on the current route.
|
||||||
int _currentTabIndex(BuildContext context) {
|
int _currentTabIndex(BuildContext context) {
|
||||||
@@ -42,7 +43,24 @@ class AppBottomNavBar extends ConsumerWidget {
|
|||||||
svgPath: 'assets/icons/nav_home_icon.svg',
|
svgPath: 'assets/icons/nav_home_icon.svg',
|
||||||
fallbackIcon: Icons.home,
|
fallbackIcon: Icons.home,
|
||||||
isSelected: selectedIndex == 0,
|
isSelected: selectedIndex == 0,
|
||||||
onTap: () => context.go('/home'),
|
onTap: () {
|
||||||
|
if (selectedIndex == 0) {
|
||||||
|
// Already on home — scroll to top
|
||||||
|
try {
|
||||||
|
final controller =
|
||||||
|
ref.read(homeScrollControllerProvider);
|
||||||
|
if (controller.hasClients) {
|
||||||
|
controller.animateTo(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
} else {
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
svgPath: 'assets/icons/nav_list_icon.svg',
|
svgPath: 'assets/icons/nav_list_icon.svg',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:real_estate_mobile/core/widgets/s3_image.dart';
|
|||||||
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
|
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
|
||||||
import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.dart';
|
import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.dart';
|
||||||
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
|
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
|
||||||
|
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
/// Provider that fetches the agent's own profile ID from /agents/profile/me
|
/// Provider that fetches the agent's own profile ID from /agents/profile/me
|
||||||
@@ -219,7 +220,7 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
|||||||
if (state.error != null) {
|
if (state.error != null) {
|
||||||
return _buildError(state.error!);
|
return _buildError(state.error!);
|
||||||
}
|
}
|
||||||
return _buildContent(state);
|
return _buildContent(state, ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildError(String error) {
|
Widget _buildError(String error) {
|
||||||
@@ -248,9 +249,11 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(AgentDetailState state) {
|
Widget _buildContent(AgentDetailState state, WidgetRef ref) {
|
||||||
final agent = state.agent!;
|
final agent = state.agent!;
|
||||||
|
final scrollController = ref.watch(homeScrollControllerProvider);
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
controller: scrollController,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ProfessionalItem {
|
|||||||
final String experience;
|
final String experience;
|
||||||
final List<String> expertise;
|
final List<String> expertise;
|
||||||
final String imageUrl;
|
final String imageUrl;
|
||||||
|
final double rating;
|
||||||
|
|
||||||
const ProfessionalItem({
|
const ProfessionalItem({
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -77,6 +78,7 @@ class ProfessionalItem {
|
|||||||
this.experience = '',
|
this.experience = '',
|
||||||
this.expertise = const [],
|
this.expertise = const [],
|
||||||
this.imageUrl = '',
|
this.imageUrl = '',
|
||||||
|
this.rating = 5.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ProfessionalItem.fromJson(Map<String, dynamic> json) {
|
factory ProfessionalItem.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -90,6 +92,9 @@ class ProfessionalItem {
|
|||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
imageUrl: json['imageUrl'] as String? ?? '',
|
imageUrl: json['imageUrl'] as String? ?? '',
|
||||||
|
rating: (json['rating'] as num?)?.toDouble() ??
|
||||||
|
(json['averageRating'] as num?)?.toDouble() ??
|
||||||
|
5.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ import 'package:real_estate_mobile/features/home/presentation/widgets/testimonia
|
|||||||
import 'package:real_estate_mobile/features/home/presentation/widgets/top_professionals_section.dart';
|
import 'package:real_estate_mobile/features/home/presentation/widgets/top_professionals_section.dart';
|
||||||
import 'package:real_estate_mobile/features/home/presentation/widgets/trust_stats_section.dart';
|
import 'package:real_estate_mobile/features/home/presentation/widgets/trust_stats_section.dart';
|
||||||
|
|
||||||
|
/// Global scroll controller for the home screen so the bottom nav can scroll to top.
|
||||||
|
final homeScrollControllerProvider = Provider<ScrollController>((ref) {
|
||||||
|
final controller = ScrollController();
|
||||||
|
ref.onDispose(() => controller.dispose());
|
||||||
|
return controller;
|
||||||
|
});
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final scrollController = ref.watch(homeScrollControllerProvider);
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
controller: scrollController,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Hero Section with Search
|
// Hero Section with Search
|
||||||
|
|||||||
@@ -55,15 +55,19 @@ class _HeroSectionState extends ConsumerState<HeroSection> {
|
|||||||
context.push('/agents/search${queryString.isNotEmpty ? '?$queryString' : ''}');
|
context.push('/agents/search${queryString.isNotEmpty ? '?$queryString' : ''}');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showTypePicker() {
|
Future<void> _showTypePicker() async {
|
||||||
final topProState = ref.read(topProfessionalsProvider);
|
var agentTypes = ref.read(topProfessionalsProvider).agentTypes;
|
||||||
final agentTypes = topProState.agentTypes;
|
|
||||||
|
|
||||||
|
// If types haven't loaded yet, fetch them directly
|
||||||
if (agentTypes.isEmpty) {
|
if (agentTypes.isEmpty) {
|
||||||
// If types not loaded yet, just navigate to search
|
try {
|
||||||
context.push('/agents/search');
|
final repo = ref.read(agentsRepositoryProvider);
|
||||||
|
agentTypes = await repo.getAgentTypes();
|
||||||
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!mounted || agentTypes.isEmpty) return;
|
||||||
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class _TopProfessionalsSectionState
|
|||||||
else ...[
|
else ...[
|
||||||
// Professional Cards - horizontal scroll
|
// Professional Cards - horizontal scroll
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 480,
|
height: 540,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
@@ -293,8 +293,10 @@ class _TopProfessionalsSectionState
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Image
|
// Image (226px per Figma)
|
||||||
Expanded(
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 226,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius:
|
borderRadius:
|
||||||
const BorderRadius.vertical(top: Radius.circular(15)),
|
const BorderRadius.vertical(top: Radius.circular(15)),
|
||||||
@@ -302,7 +304,7 @@ class _TopProfessionalsSectionState
|
|||||||
? S3Image(
|
? S3Image(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: 226,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
placeholder: (_) => _buildImagePlaceholder(index),
|
placeholder: (_) => _buildImagePlaceholder(index),
|
||||||
@@ -313,7 +315,8 @@ class _TopProfessionalsSectionState
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
Padding(
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -468,9 +471,26 @@ class _TopProfessionalsSectionState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Star Rating
|
||||||
|
Row(
|
||||||
|
children: List.generate(5, (i) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: Icon(
|
||||||
|
i < item.rating.round()
|
||||||
|
? Icons.star_rounded
|
||||||
|
: Icons.star_outline_rounded,
|
||||||
|
color: const Color(0xFFE5A625),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -478,7 +498,7 @@ class _TopProfessionalsSectionState
|
|||||||
|
|
||||||
Widget _buildShimmerCards() {
|
Widget _buildShimmerCards() {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 480,
|
height: 540,
|
||||||
child: Shimmer.fromColors(
|
child: Shimmer.fromColors(
|
||||||
baseColor: const Color(0xFFE0E0E0),
|
baseColor: const Color(0xFFE0E0E0),
|
||||||
highlightColor: const Color(0xFFF5F5F5),
|
highlightColor: const Color(0xFFF5F5F5),
|
||||||
@@ -492,7 +512,7 @@ class _TopProfessionalsSectionState
|
|||||||
children: [
|
children: [
|
||||||
// Image placeholder
|
// Image placeholder
|
||||||
Container(
|
Container(
|
||||||
height: 192,
|
height: 226,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(15)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(15)),
|
||||||
@@ -558,7 +578,7 @@ class _TopProfessionalsSectionState
|
|||||||
Widget _buildAvatarPlaceholder() {
|
Widget _buildAvatarPlaceholder() {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 192,
|
height: 226,
|
||||||
color: AppColors.gradientStart,
|
color: AppColors.gradientStart,
|
||||||
child: const Icon(Icons.person, size: 60, color: AppColors.primaryDark),
|
child: const Icon(Icons.person, size: 60, color: AppColors.primaryDark),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user