From 5be4f667180ea080d81e6c377ea37698bdd8ac62 Mon Sep 17 00:00:00 2001 From: aamir-csol Date: Sun, 5 Apr 2026 15:00:16 +0300 Subject: [PATCH] doctor ratings. --- assets/langs/ar-SA.json | 2 + assets/langs/en-US.json | 2 + lib/generated/locale_keys.g.dart | 2 + .../book_appointment/doctor_profile_page.dart | 71 +-- .../widgets/doctor_rating_details.dart | 436 +++++++++++------- lib/theme/colors.dart | 11 + 6 files changed, 320 insertions(+), 204 deletions(-) diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index bf1aedd0..6a0b0ed2 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -3,6 +3,8 @@ "arabic": "العربية", "login": "تسجيل الدخول", "noDataAvailable": "لا توجد بيانات متاحة", + "noRatingAvailable": "لا يوجد تقييم متاح", + "doctorDoesNotHaveRating": "لم يحصل هذا الطبيب على أي تقييمات بعد.", "confirm": "تأكيد", "loadingText": "جاري التحميل، يرجى الانتظار...", "kilometerUnit": "كم", diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 61245515..1a7f7ff9 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -3,6 +3,8 @@ "arabic": "Arabic", "login": "Login", "noDataAvailable": "No Data Available", + "noRatingAvailable": "No Rating Available", + "doctorDoesNotHaveRating": "This doctor does not have any ratings yet.", "confirm": "Confirm", "loadingText": "Loading, please wait...", "kilometerUnit": "KM", diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 285567e1..aadd037a 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -7,6 +7,8 @@ abstract class LocaleKeys { static const arabic = 'arabic'; static const login = 'login'; static const noDataAvailable = 'noDataAvailable'; + static const noRatingAvailable = 'noRatingAvailable'; + static const doctorDoesNotHaveRating = 'doctorDoesNotHaveRating'; static const confirm = 'confirm'; static const loadingText = 'loadingText'; static const kilometerUnit = 'kilometerUnit'; diff --git a/lib/presentation/book_appointment/doctor_profile_page.dart b/lib/presentation/book_appointment/doctor_profile_page.dart index f9761c3a..968a4805 100644 --- a/lib/presentation/book_appointment/doctor_profile_page.dart +++ b/lib/presentation/book_appointment/doctor_profile_page.dart @@ -34,40 +34,42 @@ class DoctorProfilePage extends StatelessWidget { Expanded( child: CollapsingListView( title: LocaleKeys.profileOnlyText.tr(), - trailing: appState.isAuthenticated ? Consumer( - builder: (context, viewModel, child) { - return SizedBox( - width: 24.h, - height: 24.h, - child: Icon( - viewModel.isFavouriteDoctor ? Icons.bookmark : Icons.bookmark_border_outlined, - size: 36.h, - color: viewModel.isFavouriteDoctor ? AppColors.primaryRedColor : AppColors.textColor, - ).onPress(() async { - viewModel.toggleFavouriteDoctor(); - await viewModel.insertFavouriteDoctor( - patientID: appState.getAuthenticatedUser()!.patientId!, - projectID: viewModel.doctorsProfileResponseModel.projectID ?? 0, - clinicID: viewModel.doctorsProfileResponseModel.clinicID ?? 0, - doctorID: viewModel.doctorsProfileResponseModel.doctorID ?? 0, - isActive: viewModel.isFavouriteDoctor, - onSuccess: (response) { - // Successfully added/removed favorite - refresh the favorites list - getIt.get().refreshFavouriteDoctors(); - print( - viewModel.isFavouriteDoctor ? "Doctor added to favorites" : "Doctor removed from favorites", - ); - }, - onError: (error) { - // Revert the state on error - viewModel.toggleFavouriteDoctor(); - Utils.showToast(error); - }, - ); - }), - ); - }, - ) : SizedBox.shrink(), + trailing: appState.isAuthenticated + ? Consumer( + builder: (context, viewModel, child) { + return SizedBox( + width: 24.h, + height: 24.h, + child: Icon( + viewModel.isFavouriteDoctor ? Icons.bookmark : Icons.bookmark_border_outlined, + size: 36.h, + color: viewModel.isFavouriteDoctor ? AppColors.primaryRedColor : AppColors.textColor, + ).onPress(() async { + viewModel.toggleFavouriteDoctor(); + await viewModel.insertFavouriteDoctor( + patientID: appState.getAuthenticatedUser()!.patientId!, + projectID: viewModel.doctorsProfileResponseModel.projectID ?? 0, + clinicID: viewModel.doctorsProfileResponseModel.clinicID ?? 0, + doctorID: viewModel.doctorsProfileResponseModel.doctorID ?? 0, + isActive: viewModel.isFavouriteDoctor, + onSuccess: (response) { + // Successfully added/removed favorite - refresh the favorites list + getIt.get().refreshFavouriteDoctors(); + print( + viewModel.isFavouriteDoctor ? "Doctor added to favorites" : "Doctor removed from favorites", + ); + }, + onError: (error) { + // Revert the state on error + viewModel.toggleFavouriteDoctor(); + Utils.showToast(error); + }, + ); + }), + ); + }, + ) + : SizedBox.shrink(), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -204,7 +206,6 @@ class DoctorProfilePage extends StatelessWidget { bookAppointmentsViewModel.selectedDoctor.clinicName = bookAppointmentsViewModel.doctorsProfileResponseModel.clinicDescription; bookAppointmentsViewModel.selectedDoctor.projectName = bookAppointmentsViewModel.doctorsProfileResponseModel.projectName; - LoaderBottomSheet.showLoader(); bookAppointmentsViewModel.isLiveCareSchedule ? await bookAppointmentsViewModel.getLiveCareDoctorFreeSlots( diff --git a/lib/presentation/book_appointment/widgets/doctor_rating_details.dart b/lib/presentation/book_appointment/widgets/doctor_rating_details.dart index c866b8a8..a19fa2eb 100644 --- a/lib/presentation/book_appointment/widgets/doctor_rating_details.dart +++ b/lib/presentation/book_appointment/widgets/doctor_rating_details.dart @@ -1,9 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_rating_bar/flutter_rating_bar.dart'; -import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; -import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; @@ -15,188 +12,289 @@ class DoctorRatingDetails extends StatelessWidget { @override Widget build(BuildContext context) { return Consumer(builder: (context, bookAppointmentsVM, child) { - return bookAppointmentsVM.isDoctorRatingDetailsLoading - ? Utils.getLoadingWidget() - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - bookAppointmentsVM.doctorsProfileResponseModel.decimalDoctorRate!.toString().toText44(isBold: true, isEnglishOnly: true), - SizedBox(height: 4.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - "${bookAppointmentsVM.doctorsProfileResponseModel.noOfPatientsRate} " - .toText16(weight: FontWeight.w500, color: AppColors.greyInfoTextColor, isEnglishOnly: true), - LocaleKeys.reviews.tr(context: context) - .toText16(weight: FontWeight.w500, color: AppColors.greyInfoTextColor,), - ], - ), + if (bookAppointmentsVM.isDoctorRatingDetailsLoading) { + return Utils.getLoadingWidget(); + } - RatingBar( - initialRating: bookAppointmentsVM.doctorsProfileResponseModel.actualDoctorRate!.toDouble(), - direction: Axis.horizontal, - allowHalfRating: true, - itemCount: 5, - itemSize: 20.h, - ignoreGestures: true, - ratingWidget: RatingWidget( - full: Icon( - Icons.star, - color: AppColors.ratingColorYellow, - size: 24.h, - ), - half: Icon( - Icons.star_half, - color: AppColors.ratingColorYellow, - ), - empty: Icon( - Icons.star, - color: AppColors.ratingColorYellow, - ), - ), - tapOnlyMode: true, - unratedColor: Colors.grey[500], - itemPadding: EdgeInsets.symmetric(horizontal: 4.0), - onRatingUpdate: (rating) { - print(rating); - }, - ), - ], - ), - SizedBox(height: 8.h), - Container( - margin: EdgeInsets.only(top: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 100.0, - margin: EdgeInsets.only(top: 10.0, left: 15.0, right: 15.0), - child: Text(LocaleKeys.excellent.tr(context: context), style: TextStyle(fontSize: 13.0, color: AppColors.textColor, fontWeight: FontWeight.w600))), - getRatingLine(bookAppointmentsVM.doctorDetailsList[0].ratio, Colors.green[700]!), - ], - ), - Container( - margin: EdgeInsets.only(top: 10.0, left: 10.0, right: 10.0), - child: Text("${getRatingWidth(bookAppointmentsVM.doctorDetailsList[0].ratio).round()}%", style: TextStyle(fontSize: 14.0, color: AppColors.textColor, fontWeight: FontWeight.w600, fontFamily: "Poppins")), - ), - ], - ), + final rating = bookAppointmentsVM.doctorsProfileResponseModel.decimalDoctorRate?.toDouble() ?? 0.0; + if (rating == 0 || rating == 0.0) { + return NoRatingDialog(); + } + + return DoctorRatingDialog( + averageRating: rating, + totalReviews: (bookAppointmentsVM.doctorsProfileResponseModel.noOfPatientsRate ?? 0).toInt(), + fiveStarPercent: bookAppointmentsVM.doctorDetailsList.isNotEmpty ? bookAppointmentsVM.doctorDetailsList[0].ratio.round() : 0, + fourStarPercent: bookAppointmentsVM.doctorDetailsList.length > 1 ? bookAppointmentsVM.doctorDetailsList[1].ratio.round() : 0, + threeStarPercent: bookAppointmentsVM.doctorDetailsList.length > 2 ? bookAppointmentsVM.doctorDetailsList[2].ratio.round() : 0, + twoStarPercent: bookAppointmentsVM.doctorDetailsList.length > 3 ? bookAppointmentsVM.doctorDetailsList[3].ratio.round() : 0, + oneStarPercent: bookAppointmentsVM.doctorDetailsList.length > 4 ? bookAppointmentsVM.doctorDetailsList[4].ratio.round() : 0, + ); + }); + } +} + +class DoctorRatingDialog extends StatelessWidget { + final double averageRating; + final int totalReviews; + final int fiveStarPercent; + final int fourStarPercent; + final int threeStarPercent; + final int twoStarPercent; + final int oneStarPercent; + + const DoctorRatingDialog({ + super.key, + required this.averageRating, + required this.totalReviews, + required this.fiveStarPercent, + required this.fourStarPercent, + required this.threeStarPercent, + required this.twoStarPercent, + required this.oneStarPercent, + }); + + /// Returns the color for the rating badge and progress bar based on star level. + /// 5 stars -> bright green + /// 4 stars -> slightly darker green + /// 3 stars -> dark yellow / olive + /// 2 stars -> dark orange / brown-red + /// 1 star -> red + Color _getBarColor(int stars) { + switch (stars) { + case 5: + return AppColors.ratingFiveStarColor; + case 4: + return AppColors.ratingFourStarColor; + case 3: + return AppColors.ratingThreeStarColor; + case 2: + return AppColors.ratingTwoStarColor; + case 1: + return AppColors.ratingOneStarColor; + default: + return Colors.grey; + } + } + + /// Returns the background color for the large rating badge based on the + /// overall average rating. + Color _getBadgeColor(double rating) { + if (rating == 0 || rating == 0.0) { + return AppColors.greyLightColor; // Grey color for no rating + } else if (rating >= 4.5) { + return AppColors.ratingFiveStarColor; // Bright green + } else if (rating >= 4.0) { + return AppColors.ratingBadgeFiveStarColor; // Green + } else if (rating >= 3.0) { + return AppColors.ratingThreeStarColor; + } else if (rating >= 2.0) { + return AppColors.ratingTwoStarColor; + } else { + return AppColors.ratingOneStarColor; + } + } + + @override + Widget build(BuildContext context) { + final ratingRows = [ + _RatingRowData(stars: 5, percent: fiveStarPercent), + _RatingRowData(stars: 4, percent: fourStarPercent), + _RatingRowData(stars: 3, percent: threeStarPercent), + _RatingRowData(stars: 2, percent: twoStarPercent), + _RatingRowData(stars: 1, percent: oneStarPercent), + ]; + + return Container( + width: 420, + padding: const EdgeInsets.all(0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 130, + height: 160, + decoration: BoxDecoration( + color: _getBadgeColor(averageRating), + borderRadius: BorderRadius.circular(18), ), - Container( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 100.0, - margin: EdgeInsets.only(top: 10.0, left: 15.0, right: 15.0), - child: Text(LocaleKeys.vGood.tr(context: context), style: TextStyle(fontSize: 13.0, color: AppColors.textColor, fontWeight: FontWeight.w600))), - getRatingLine(bookAppointmentsVM.doctorDetailsList[1].ratio, Color(0xffB7B723)), - ], + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + averageRating.toStringAsFixed(1), + style: TextStyle( + fontSize: 60, + fontWeight: FontWeight.w700, + color: averageRating == 0 || averageRating == 0.0 ? AppColors.textColor : AppColors.whiteColor, + height: 1.1, ), - Container( - margin: EdgeInsets.only(top: 10.0, left: 10.0, right: 10.0), - child: Text("${bookAppointmentsVM.doctorDetailsList[1].ratio.round()}%", style: TextStyle(fontSize: 14.0, color: AppColors.textColor, fontWeight: FontWeight.w600, fontFamily: "Poppins")), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, ), - ], - ), - ), - Container( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 100.0, - margin: EdgeInsets.only(top: 10.0, left: 15.0, right: 15.0), - child: Text(LocaleKeys.good.tr(context: context), style: TextStyle(fontSize: 13.0, color: AppColors.textColor, fontWeight: FontWeight.w600))), - getRatingLine(bookAppointmentsVM.doctorDetailsList[2].ratio, Color(0xffEBA727)), - ], + decoration: BoxDecoration( + color: averageRating == 0 || averageRating == 0.0 ? AppColors.greyF7Color : const Color.fromRGBO(0, 0, 0, 0.15), + borderRadius: BorderRadius.circular(8), ), - Container( - margin: EdgeInsets.only(top: 10.0, left: 10.0, right: 10.0), - child: Text("${bookAppointmentsVM.doctorDetailsList[2].ratio.round()}%", style: TextStyle(fontSize: 14.0, color: AppColors.textColor, fontWeight: FontWeight.w600, fontFamily: "Poppins")), + child: Text( + '$totalReviews Review', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: averageRating == 0 || averageRating == 0.0 ? AppColors.textColor : AppColors.whiteColor, + ), ), - ], - ), + ), + ], ), - Container( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 100.0, - margin: EdgeInsets.only(top: 10.0, left: 15.0, right: 15.0), - child: Text(LocaleKeys.average.tr(context: context), style: TextStyle(fontSize: 13.0, color: AppColors.textColor, fontWeight: FontWeight.w600))), - getRatingLine(bookAppointmentsVM.doctorDetailsList[3].ratio, Color(0xffEB7227)), - ], - ), - Container( - margin: EdgeInsets.only(top: 10.0, left: 10.0, right: 10.0), - child: Text("${bookAppointmentsVM.doctorDetailsList[3].ratio.round()}%", style: TextStyle(fontSize: 14.0, color: AppColors.textColor, fontWeight: FontWeight.w600, fontFamily: "Poppins")), - ), - ], - ), + ), + + const SizedBox(width: 16), + + // ── Right: Star Breakdown ── + Expanded( + child: Column( + children: ratingRows + .map( + (row) => _buildRatingRow(row.stars, row.percent), + ) + .toList(), ), - Container( - margin: EdgeInsets.only(bottom: 30.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 100.0, - margin: EdgeInsets.only(top: 10.0, left: 15.0, right: 15.0), - child: Text(LocaleKeys.average.tr(context: context), style: TextStyle(fontSize: 13.0, color: AppColors.textColor, fontWeight: FontWeight.w600))), - getRatingLine(bookAppointmentsVM.doctorDetailsList[4].ratio, Color(0xffE20C0C)), - ], - ), - Container( - margin: EdgeInsets.only(top: 10.0, left: 10.0, right: 10.0), - child: Text("${bookAppointmentsVM.doctorDetailsList[4].ratio.round()}%", style: TextStyle(fontSize: 14.0, color: AppColors.textColor, fontWeight: FontWeight.w600, fontFamily: "Poppins")), - ), - ], + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ); + } + + Widget _buildRatingRow(int stars, int percent) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + // Star icons + SizedBox( + width: 70, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: List.generate( + stars, + (_) => Icon(Icons.star, size: 14, color: AppColors.ratingStarIconColor), + ), + ), + ), + + const SizedBox(width: 8), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + height: 10, + child: LinearProgressIndicator( + value: percent / 100, + backgroundColor: AppColors.shimmerBaseColor, + valueColor: AlwaysStoppedAnimation( + _getBarColor(stars), ), ), - ], - ); - }); - } + ), + ), + ), - double getRatingWidth(num patientNumber) { - var width = patientNumber; - return width.roundToDouble(); + const SizedBox(width: 8), + + // Percentage text + SizedBox( + width: 36, + child: Text( + '$percent%', + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.ratingPercentageTextColor, + ), + ), + ), + ], + ), + ); } +} + +class _RatingRowData { + final int stars; + final int percent; + + _RatingRowData({required this.stars, required this.percent}); +} + +class NoRatingDialog extends StatelessWidget { + const NoRatingDialog({super.key}); - Widget getRatingLine(double patientNumber, Color color) { + @override + Widget build(BuildContext context) { return Container( - margin: EdgeInsets.only(top: 10.0), - child: Stack(children: [ - SizedBox( - width: 150.0, - height: 7.h, - child: Container( - color: Colors.grey[300], + width: 420, + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.lightRedButtonColor, + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.star_border_rounded, + size: 48, + color: AppColors.primaryRedColor, + ), + ), + + const SizedBox(height: 20), + + // Title + Text( + LocaleKeys.noRatingAvailable.tr(context: context), + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textColor, + ), + textAlign: TextAlign.center, ), - ), - SizedBox( - width: patientNumber * 1.55, - height: 7.h, - child: Container( - color: color, + + const SizedBox(height: 12), + + // Description + Text( + LocaleKeys.doctorDoesNotHaveRating.tr(context: context), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + color: AppColors.textColorLight, + ), + textAlign: TextAlign.center, ), - ), - ]), + + const SizedBox(height: 8), + ], + ), ); } } diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index c8cbf308..240f35e8 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -41,6 +41,17 @@ class AppColors { static Color get ratingColorYellow => isDarkMode ? dark.ratingColorYellow : const Color(0xFFFFAF15); static Color get spacerLineColor => isDarkMode ? dark.spacerLineColor : const Color(0x2E30391A); + // ── Doctor Rating Colors ────────────────────────────────────────────────── + static const Color ratingStarIconColor = Color(0xFFFFA726); // Orange-amber for star icons + static const Color ratingFiveStarColor = Color(0xFF4CAF50); // Green for 5 stars + static const Color ratingFourStarColor = Color(0xFF8BC34A); // Light green for 4 stars + static const Color ratingThreeStarColor = Color(0xFFC0A000); // Dark yellow/olive for 3 stars + static const Color ratingTwoStarColor = Color(0xFFD84315); // Dark orange-red for 2 stars + static const Color ratingOneStarColor = Color(0xFFD32F2F); // Red for 1 star + static const Color ratingBadgeFiveStarColor = Color(0xFF5BBD8E); // Green badge for ≥4.0 + static const Color ratingPercentageTextColor = Color(0xFF4A4A4A); // Dark grey for percentage text + + // ── Semantic / Status Colors ────────────────────────────────────────────── static Color get errorColor => isDarkMode ? dark.errorColor : const Color(0xFFED1C2B); static Color get alertColor => isDarkMode ? dark.alertColor : const Color(0xFFD48D05);