diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index c9c4aaff..453f142b 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -4,7 +4,7 @@ import 'package:hmg_patient_app_new/core/enums.dart'; class ApiConsts { static const maxSmallScreen = 660; - static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.prod; + static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.uat; // static String baseUrl = 'https://uat.hmgwebservices.com/'; // HIS API URL UAT diff --git a/lib/features/authentication/authentication_view_model.dart b/lib/features/authentication/authentication_view_model.dart index ef48f388..066464b9 100644 --- a/lib/features/authentication/authentication_view_model.dart +++ b/lib/features/authentication/authentication_view_model.dart @@ -95,6 +95,79 @@ class AuthenticationViewModel extends ChangeNotifier { final ValueNotifier otpScreenNotifier = ValueNotifier(false); int patientShareRequestID = 0; + //================== Validation Error States ================== + + // Login screen errors + String? _nationalIdError; + String? get nationalIdError => _nationalIdError; + + // Phone number errors (used in multiple screens) + String? _phoneNumberError; + String? get phoneNumberError => _phoneNumberError; + + // Registration screen errors + String? _nameError; + String? get nameError => _nameError; + + String? _emailError; + String? get emailError => _emailError; + + String? _dobError; + String? get dobError => _dobError; + + // Check if registration form has any errors (for container border) + bool get hasRegistrationFormError => + _nationalIdError != null || _dobError != null; + + // Check if ID and phone have errors (for family file container) + bool get hasIdAndPhoneError => + _nationalIdError != null || _phoneNumberError != null; + + // Additional field errors for UAE registration step 2 + String? _genderError; + String? _maritalStatusError; + String? _countryError; + + // Getters for step 2 field errors (nameError and emailError already exist above) + String? get genderError => _genderError; + String? get maritalStatusError => _maritalStatusError; + String? get countryError => _countryError; + + // Check if registration step 2 form has any errors (for container border) + bool get hasRegistrationStep2FormError => + _nameError != null || + _genderError != null || + _maritalStatusError != null || + _countryError != null; + + // Clear all step 2 field errors + void clearAllStep2FieldErrors() { + _nameError = null; + _genderError = null; + _maritalStatusError = null; + _countryError = null; + _emailError = null; + notifyListeners(); + } + + // Clear gender error + void clearGenderError() { + _genderError = null; + notifyListeners(); + } + + // Clear marital status error + void clearMaritalStatusError() { + _maritalStatusError = null; + notifyListeners(); + } + + // Clear country error + void clearCountryError() { + _countryError = null; + notifyListeners(); + } + //================== String errorMsg = ''; @@ -138,8 +211,342 @@ class AuthenticationViewModel extends ChangeNotifier { _appState.setNHICUserData = CheckUserStatusResponseNHIC(); getIt.get().setSelectedHeight(0); getIt.get().setSelectedWeight(0); + + // Clear all validation errors + clearAllErrors(); + } + + //==================== Validation Methods ==================== + + /// Clear all validation errors + void clearAllErrors() { + _nationalIdError = null; + _phoneNumberError = null; + _nameError = null; + _emailError = null; + _dobError = null; + notifyListeners(); + } + + /// Clear national ID error + void clearNationalIdError() { + _nationalIdError = null; + notifyListeners(); + } + + /// Clear phone number error + void clearPhoneNumberError() { + _phoneNumberError = null; + notifyListeners(); + } + + /// Clear name error + void clearNameError() { + _nameError = null; + notifyListeners(); + } + + /// Clear email error + void clearEmailError() { + _emailError = null; + notifyListeners(); + } + + /// Clear DOB error + void clearDobError() { + _dobError = null; + notifyListeners(); + } + + /// Validate National ID for login/registration + /// Returns true if valid, false otherwise + /// Only shows red border error, no dialog popups + bool validateNationalId() { + // Clear previous error + _nationalIdError = null; + + // Check if field is empty + if (nationalIdController.text.isEmpty) { + _nationalIdError = LocaleKeys.pleaseEnterAnationalID.tr(); + notifyListeners(); + return false; + } + + // Check if it's 10 digits and valid Saudi ID + String cleanedId = nationalIdController.text.replaceAll(RegExp(r'[^0-9]'), ''); + if (cleanedId.length == 10) { + if (!Utils.isSAUDIIDValid(cleanedId)) { + _nationalIdError = LocaleKeys.enterValidNationalId.tr(); + notifyListeners(); + return false; + } + } + + // Validate based on selected country + if (selectedCountrySignup == CountryEnum.saudiArabia) { + if (!ValidationUtils.validateIqama(nationalIdController.text)) { + _nationalIdError = LocaleKeys.pleaseEnterAValidIqamaID.tr(); + notifyListeners(); + return false; + } + } else if (selectedCountrySignup == CountryEnum.unitedArabEmirates) { + if (!ValidationUtils.validateUaeNationalId(nationalIdController.text)) { + _nationalIdError = LocaleKeys.pleaseEnterAValidNationalID.tr(); + notifyListeners(); + return false; + } + } + + // Clear error on success + _nationalIdError = null; + notifyListeners(); + return true; + } + + /// Validate phone number + /// Returns true if valid, false otherwise + /// Only shows red border error, no dialog popups + bool validatePhoneNumber() { + // Clear previous error + _phoneNumberError = null; + + // Check if phone is empty + if (phoneNumberController.text.isEmpty) { + _phoneNumberError = LocaleKeys.enterValidPhoneNumber.tr(); + notifyListeners(); + return false; + } + + // Check if "Others" country is selected and phone number starts with restricted codes + if (selectedCountrySignup == CountryEnum.others) { + if (phoneNumberController.text.startsWith('00966') || phoneNumberController.text.startsWith('00971')) { + _phoneNumberError = LocaleKeys.cannotEnterSaudiOrUAENumber.tr(); + notifyListeners(); + return false; + } + } + + // Clear error on success + _phoneNumberError = null; + notifyListeners(); + return true; } + /// Validate National ID and Phone Number together + /// Returns true if both are valid, false otherwise + /// Only shows red border errors, no dialog popups + /// Uses PROGRESSIVE VALIDATION - shows errors step by step + bool validateIdAndPhone() { + // Clear all previous errors first + _nationalIdError = null; + _phoneNumberError = null; + + // Step 1: Validate National ID empty first (highest priority) + if (nationalIdController.text.isEmpty) { + _nationalIdError = LocaleKeys.pleaseEnterAnationalID.tr(); + notifyListeners(); + return false; // Stop here, don't check phone yet + } + + // Step 2: Validate National ID format + String cleanedId = nationalIdController.text.replaceAll(RegExp(r'[^0-9]'), ''); + + if (cleanedId.length == 10) { + if (!Utils.isSAUDIIDValid(cleanedId)) { + _nationalIdError = LocaleKeys.enterValidNationalId.tr(); + notifyListeners(); + return false; // Stop here + } + } + + // Step 3: Validate National ID based on country + if (selectedCountrySignup == CountryEnum.saudiArabia) { + if (!ValidationUtils.validateIqama(nationalIdController.text)) { + _nationalIdError = LocaleKeys.pleaseEnterAValidIqamaID.tr(); + notifyListeners(); + return false; // Stop here + } + } else if (selectedCountrySignup == CountryEnum.unitedArabEmirates) { + if (!ValidationUtils.validateUaeNationalId(nationalIdController.text)) { + _nationalIdError = LocaleKeys.pleaseEnterAValidNationalID.tr(); + notifyListeners(); + return false; // Stop here + } + } + + // Step 4: ONLY validate phone if National ID is fully valid + if (phoneNumberController.text.isEmpty) { + _phoneNumberError = LocaleKeys.enterValidPhoneNumber.tr(); + notifyListeners(); + return false; // Stop here + } + + // Step 5: Validate phone number format based on country + if (selectedCountrySignup == CountryEnum.others) { + if (phoneNumberController.text.startsWith('00966') || phoneNumberController.text.startsWith('00971')) { + _phoneNumberError = LocaleKeys.cannotEnterSaudiOrUAENumber.tr(); + notifyListeners(); + return false; + } + } + + // All validations passed + notifyListeners(); + return true; + } + + /// Validate registration form (National ID, DOB, Terms) + /// Returns true if all valid, false otherwise + /// Only shows red border errors, no dialog popups + /// Uses PROGRESSIVE VALIDATION - shows errors step by step + bool validateRegistrationForm() { + // Clear all previous errors first + _nationalIdError = null; + _dobError = null; + + // Step 1: Validate National ID first (highest priority) + if (nationalIdController.text.isEmpty) { + _nationalIdError = LocaleKeys.pleaseEnterAnationalID.tr(); + notifyListeners(); + return false; // Stop here, don't check other fields + } + + // Step 2: Validate National ID format + String cleanedId = nationalIdController.text.replaceAll(RegExp(r'[^0-9]'), ''); + + if (cleanedId.length == 10) { + if (!Utils.isSAUDIIDValid(cleanedId)) { + _nationalIdError = LocaleKeys.enterValidNationalId.tr(); + notifyListeners(); + return false; // Stop here + } + } + + // Step 3: Validate based on selected country + if (selectedCountrySignup == CountryEnum.saudiArabia) { + if (!ValidationUtils.validateIqama(nationalIdController.text)) { + _nationalIdError = LocaleKeys.pleaseEnterAValidIqamaID.tr(); + notifyListeners(); + return false; // Stop here + } + } else if (selectedCountrySignup == CountryEnum.unitedArabEmirates) { + if (!ValidationUtils.validateUaeNationalId(nationalIdController.text)) { + _nationalIdError = LocaleKeys.pleaseEnterAValidNationalID.tr(); + notifyListeners(); + return false; // Stop here + } + } + + // Step 4: Only validate DOB if National ID is valid + if (dobController.text.isEmpty || dob == null || dob!.isEmpty) { + _dobError = LocaleKeys.pleaseEnterAValidDateOfBirth.tr(); + notifyListeners(); + return false; // Stop here + } + + // Step 5: Only validate Terms if both National ID and DOB are valid + // Show dialog popup for Terms (old behavior) + if (!isTermsAccepted) { + _dialogService.showExceptionBottomSheet( + message: LocaleKeys.pleaseAcceptTermsConditions.tr(), + onOkPressed: () { + _navigationService.pop(); + }, + ); + notifyListeners(); + return false; // Stop here + } + + // All validations passed + notifyListeners(); + return true; + } + + //==================== End Validation Methods ==================== + + /// Validate registration step 2 form (UAE users: name, gender, marital status, country) + /// Returns true if all valid, false otherwise + /// Shows only the FIRST error encountered - progressive validation + bool validateRegistrationStep2Form() { + final isArabic = _appState.isArabic(); + + // Clear all previous errors first + _nameError = null; + _genderError = null; + _maritalStatusError = null; + _countryError = null; + + // Step 1: Validate name (only for UAE users) + if (isUserFromUAE()) { + if (nameController.text.trim().isEmpty) { + _nameError = isArabic ? "الرجاء إدخال الاسم الكامل" : "Please enter full name"; + notifyListeners(); + return false; // Stop here + } + } + + // Step 2: Validate gender (only for UAE users) + if (isUserFromUAE()) { + if (genderType == null) { + _genderError = isArabic ? "الرجاء اختيار الجنس" : "Please select gender"; + notifyListeners(); + return false; // Stop here + } + } + + // Step 3: Validate marital status (only for UAE users) + if (isUserFromUAE()) { + if (maritalStatus == null) { + _maritalStatusError = isArabic ? "الرجاء اختيار الحالة الاجتماعية" : "Please select marital status"; + notifyListeners(); + return false; // Stop here + } + } + + // Step 4: Validate country (only for UAE users) + if (isUserFromUAE()) { + if (pickedCountryByUAEUser == null) { + _countryError = isArabic ? "الرجاء اختيار البلد" : "Please select country"; + notifyListeners(); + return false; // Stop here + } + } + + // All validations passed + notifyListeners(); + return true; + } + + /// Validate email in the bottom sheet + /// Returns true if valid, false otherwise + bool validateEmail() { + final isArabic = _appState.isArabic(); + + // Clear previous error + _emailError = null; + + // Step 1: Check if email is empty + if (emailController.text.trim().isEmpty) { + _emailError = isArabic ? "الرجاء إدخال البريد الإلكتروني" : "Please enter email"; + notifyListeners(); + return false; + } + + // Step 2: Validate email format + final bool emailIsValid = RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}$").hasMatch(emailController.text.trim()); + if (!emailIsValid) { + _emailError = isArabic ? "الرجاء إدخال تنسيق بريد إلكتروني صالح" : "Please enter a valid email format"; + notifyListeners(); + return false; + } + + // Email is valid + notifyListeners(); + return true; + } + + //==================== End Validation Methods ==================== + void onCountryChange(CountryEnum country) { selectedCountrySignup = country; notifyListeners(); @@ -173,11 +580,13 @@ class AuthenticationViewModel extends ChangeNotifier { void onMaritalStatusChange(String? status) { maritalStatus = MaritalStatusTypeExtension.fromType(status)!; + clearMaritalStatusError(); notifyListeners(); } void onGenderChange(String? status) { genderType = GenderTypeExtension.fromType(status)!; + clearGenderError(); notifyListeners(); } @@ -186,7 +595,8 @@ class AuthenticationViewModel extends ChangeNotifier { } void onUAEUserCountrySelection(String? value) { - pickedCountryByUAEUser = countriesList!.firstWhere((element) => element.name == value); + pickedCountryByUAEUser = countriesList!.firstWhere((element) => element.name == value || element.nameN == value); + clearCountryError(); notifyListeners(); } diff --git a/lib/features/contact_us/contact_us_view_model.dart b/lib/features/contact_us/contact_us_view_model.dart index 34657a85..b9fe55f0 100644 --- a/lib/features/contact_us/contact_us_view_model.dart +++ b/lib/features/contact_us/contact_us_view_model.dart @@ -52,6 +52,62 @@ class ContactUsViewModel extends ChangeNotifier { FeedbackType selectedFeedbackType = FeedbackType(id: 5, nameEN: "Not classified", nameAR: 'غير محدد'); + // ==================== FEEDBACK ERROR TRACKING ==================== + // Individual field errors + String? _subjectError; + String? _messageError; + + // Getters for field errors + String? get subjectError => _subjectError; + + String? get messageError => _messageError; + + // Clear subject error + void clearSubjectError() { + _subjectError = null; + notifyListeners(); + } + + // Clear message error + void clearMessageError() { + _messageError = null; + notifyListeners(); + } + + // Clear all feedback errors + void clearAllFeedbackErrors() { + _subjectError = null; + _messageError = null; + notifyListeners(); + } + + // Validate feedback form - shows only first error + bool validateFeedbackForm(String subject, String message) { + final isArabic = appState.isArabic(); + + // Clear previous errors + _subjectError = null; + _messageError = null; + + // Check if subject is empty + if (subject.trim().isEmpty) { + _subjectError = isArabic ? "الرجاء إدخال الموضوع" : "Please enter subject"; + notifyListeners(); + return false; + } + + // Check if message is empty + if (message.trim().isEmpty) { + _messageError = isArabic ? "الرجاء إدخال الرسالة" : "Please enter message"; + notifyListeners(); + return false; + } + + // All validations passed + notifyListeners(); + return true; + } + ContactUsViewModel({required this.contactUsRepo, required this.errorHandlerService, required this.appState}); initContactUsViewModel() async { @@ -95,7 +151,12 @@ class ContactUsViewModel extends ChangeNotifier { setIsSendFeedbackTabSelected(bool isSelected) { isSendFeedbackTabSelected = isSelected; - if (!isSelected) { + if (isSelected) { + // Clear errors when switching to send tab + clearAllFeedbackErrors(); + } else { + // Clear errors when switching away from send tab + clearAllFeedbackErrors(); getStatusForCOC(identificationNo: appState.getAuthenticatedUser()!.patientIdentificationNo!, mobileNo: "966${Utils.getPhoneNumberWithoutZero(appState.getAuthenticatedUser()!.mobileNumber!)}"); } notifyListeners(); diff --git a/lib/features/doctor_filter/doctor_filter_view_model.dart b/lib/features/doctor_filter/doctor_filter_view_model.dart index 889ab5a2..5bdd21b1 100644 --- a/lib/features/doctor_filter/doctor_filter_view_model.dart +++ b/lib/features/doctor_filter/doctor_filter_view_model.dart @@ -22,6 +22,61 @@ class DoctorFilterViewModel extends ChangeNotifier{ String? selectedClinicForFilters; bool applyFilters = false; + // ==================== FILTER ERROR TRACKING ==================== + // Individual field errors + String? _hospitalError; + String? _clinicError; + + // Getters for field errors + String? get hospitalError => _hospitalError; + String? get clinicError => _clinicError; + + // Clear hospital error + void clearHospitalError() { + _hospitalError = null; + notifyListeners(); + } + + // Clear clinic error + void clearClinicError() { + _clinicError = null; + notifyListeners(); + } + + // Clear all filter errors + void clearAllFilterErrors() { + _hospitalError = null; + _clinicError = null; + notifyListeners(); + } + + // Validate filters - shows only first error + bool validateFilters() { + final isArabic = appState.isArabic(); + + // Clear previous errors + _hospitalError = null; + _clinicError = null; + + // Check if at least one hospital is selected + if (selectedHospitalForFilters == null) { + _hospitalError = isArabic ? "الرجاء اختيار المستشفى" : "Please select a hospital"; + notifyListeners(); + return false; + } + + // Check if at least one clinic is selected + if (selectedClinicForFilters == null || selectedClinicForFilters!.isEmpty) { + _clinicError = isArabic ? "الرجاء اختيار العيادة" : "Please select a clinic"; + notifyListeners(); + return false; + } + + // All validations passed + notifyListeners(); + return true; + } + void clearSearchFilters() { searchedRegionList.clear(); searchedHospitalList.clear(); @@ -36,6 +91,7 @@ class DoctorFilterViewModel extends ChangeNotifier{ selectedHospitalForFilters = null; selectedRegionForFilters = []; applyFilters = false; + clearAllFilterErrors(); notifyListeners(); } @@ -76,6 +132,7 @@ class DoctorFilterViewModel extends ChangeNotifier{ void setSelectedHospital(PatientDoctorAppointmentList? hospital) { selectedHospitalForFilters = hospital; + clearHospitalError(); notifyListeners(); } @@ -91,6 +148,7 @@ class DoctorFilterViewModel extends ChangeNotifier{ void setSelectedClinicForFilter(String? clinic) { selectedClinicForFilters = clinic; + clearClinicError(); notifyListeners(); } diff --git a/lib/features/habib_wallet/habib_wallet_view_model.dart b/lib/features/habib_wallet/habib_wallet_view_model.dart index 3d72f651..44fd640a 100644 --- a/lib/features/habib_wallet/habib_wallet_view_model.dart +++ b/lib/features/habib_wallet/habib_wallet_view_model.dart @@ -33,6 +33,69 @@ class HabibWalletViewModel extends ChangeNotifier { List habibWalletBalanceList = []; + // ==================== RECHARGE VALIDATION ERROR TRACKING ==================== + // Individual field errors + String? _amountError; + String? _hospitalError; + + // Getters for field errors + String? get amountError => _amountError; + String? get hospitalError => _hospitalError; + + // Clear amount error + void clearAmountError() { + _amountError = null; + notifyListeners(); + } + + // Clear hospital error + void clearHospitalError() { + _hospitalError = null; + notifyListeners(); + } + + // Clear all recharge errors + void clearAllRechargeErrors() { + _amountError = null; + _hospitalError = null; + notifyListeners(); + } + + // Validate recharge form - shows only first error + bool validateRechargeForm(String amount) { + // Clear previous errors + _amountError = null; + _hospitalError = null; + + // Check if amount is empty + if (amount.trim().isEmpty) { + _amountError = "Please enter amount"; + notifyListeners(); + return false; + } + + // Remove commas and validate amount + String cleanAmount = amount.replaceAll(',', ''); + final parsedAmount = num.tryParse(cleanAmount); + + if (parsedAmount == null || parsedAmount <= 0) { + _amountError = "Please enter a valid amount"; + notifyListeners(); + return false; + } + + // Check if hospital is selected + if (selectedHospital == null) { + _hospitalError = "Please select a hospital"; + notifyListeners(); + return false; + } + + // All validations passed + notifyListeners(); + return true; + } + HabibWalletViewModel({required this.habibWalletRepo, required this.errorHandlerService}); initHabibWalletProvider() { @@ -57,6 +120,7 @@ class HabibWalletViewModel extends ChangeNotifier { setSelectedHospital(HospitalsModel hospital) { selectedHospital = hospital; + clearHospitalError(); notifyListeners(); } diff --git a/lib/features/health_trackers/health_trackers_view_model.dart b/lib/features/health_trackers/health_trackers_view_model.dart index 6b16fdc3..52ae3ad6 100644 --- a/lib/features/health_trackers/health_trackers_view_model.dart +++ b/lib/features/health_trackers/health_trackers_view_model.dart @@ -34,6 +34,102 @@ class HealthTrackersViewModel extends ChangeNotifier { String? get errorMessage => _errorMessage; + // ==================== FORM ERROR TRACKING ==================== + // Individual field errors + String? _bloodSugarError; + String? _bloodSugarMeasureTimeError; + String? _dateError; + String? _timeError; + String? _weightError; + String? _systolicError; + String? _diastolicError; + String? _measuredArmError; + + // Getters for field errors + String? get bloodSugarError => _bloodSugarError; + String? get bloodSugarMeasureTimeError => _bloodSugarMeasureTimeError; + String? get dateError => _dateError; + String? get timeError => _timeError; + String? get weightError => _weightError; + String? get systolicError => _systolicError; + String? get diastolicError => _diastolicError; + String? get measuredArmError => _measuredArmError; + + // Check if blood sugar form has any errors (for container border) + bool get hasBloodSugarFormError => + _bloodSugarError != null || + _bloodSugarMeasureTimeError != null || + _dateError != null || + _timeError != null; + + // Check if weight form has any errors (for container border) + bool get hasWeightFormError => + _weightError != null || + _dateError != null || + _timeError != null; + + // Check if blood pressure form has any errors (for container border) + bool get hasBloodPressureFormError => + _systolicError != null || + _diastolicError != null || + _measuredArmError != null || + _dateError != null || + _timeError != null; + + // Clear all field errors + void clearAllFieldErrors() { + _bloodSugarError = null; + _bloodSugarMeasureTimeError = null; + _dateError = null; + _timeError = null; + _weightError = null; + _systolicError = null; + _diastolicError = null; + _measuredArmError = null; + notifyListeners(); + } + + // Clear individual field errors + void clearBloodSugarError() { + _bloodSugarError = null; + notifyListeners(); + } + + void clearBloodSugarMeasureTimeError() { + _bloodSugarMeasureTimeError = null; + notifyListeners(); + } + + void clearDateError() { + _dateError = null; + notifyListeners(); + } + + void clearTimeError() { + _timeError = null; + notifyListeners(); + } + + void clearWeightError() { + _weightError = null; + notifyListeners(); + } + + void clearSystolicError() { + _systolicError = null; + notifyListeners(); + } + + void clearDiastolicError() { + _diastolicError = null; + notifyListeners(); + } + + void clearMeasuredArmError() { + _measuredArmError = null; + notifyListeners(); + } + final List durationFiltersEn = ["Week", "Month", "Year"]; final List durationFiltersAr = ["أسبوع", "شهر", "سنة"]; @@ -1017,7 +1113,62 @@ class HealthTrackersViewModel extends ChangeNotifier { } } - // Validation method + // Validation method - returns true if valid, false if errors exist + // Shows only the first error encountered + bool validateBloodSugarEntry(String dateTime) { + final isArabic = getIt.get().isArabic(); + + // Clear all previous errors + _bloodSugarError = null; + _bloodSugarMeasureTimeError = null; + _dateError = null; + _timeError = null; + + // Validate blood sugar value - stop at first error + if (bloodSugarController.text.trim().isEmpty) { + _bloodSugarError = isArabic ? "الرجاء إدخال مستوى سكر الدم" : "Please enter blood sugar value"; + notifyListeners(); + return false; + } + + final bloodSugarValue = double.tryParse(bloodSugarController.text.trim()); + if (bloodSugarValue == null) { + _bloodSugarError = isArabic ? "الرجاء إدخال رقم صحيح" : "Please enter a valid number"; + notifyListeners(); + return false; + } + + if (bloodSugarValue <= 0) { + _bloodSugarError = isArabic ? "يجب أن يكون مستوى سكر الدم أكبر من 0" : "Blood sugar value must be greater than 0"; + notifyListeners(); + return false; + } + + if (bloodSugarValue > 1000) { + _bloodSugarError = isArabic ? "مستوى سكر الدم يبدو مرتفع جداً. يرجى التحقق وإدخالها مرة أخرى" : "Blood sugar value seems too high. Please check and enter again"; + notifyListeners(); + return false; + } + + // Validate date time + if (dateTime.trim().isEmpty) { + _dateError = isArabic ? "الرجاء اختيار التاريخ والوقت" : "Please select date and time"; + notifyListeners(); + return false; + } + + // Validate measure time + if (_selectedBloodSugarMeasureTime.isEmpty) { + _bloodSugarMeasureTimeError = isArabic ? "الرجاء اختيار وقت القياس" : "Please select when the measurement was taken"; + notifyListeners(); + return false; + } + + notifyListeners(); + return true; + } + + // Old validation method kept for reference String? _validateBloodSugarEntry(String dateTime) { final isArabic = getIt.get().isArabic(); @@ -1111,7 +1262,44 @@ class HealthTrackersViewModel extends ChangeNotifier { // ==================== WEIGHT ENTRY METHODS ==================== - // Validate weight entry before saving + // Validate weight entry before saving - returns true if valid, false if errors exist + // Shows only the first error encountered + bool validateWeightEntry(String dateTime) { + final isArabic = getIt.get().isArabic(); + + // Clear all previous errors + _weightError = null; + _dateError = null; + _timeError = null; + + // Validate weight value - stop at first error + final weightValue = weightController.text.trim(); + if (weightValue.isEmpty) { + _weightError = isArabic ? "الرجاء إدخال قيمة الوزن" : "Please enter weight value"; + notifyListeners(); + return false; + } + + // Check if it's a valid number + final parsedValue = double.tryParse(weightValue); + if (parsedValue == null || parsedValue <= 0) { + _weightError = isArabic ? "الرجاء إدخال قيمة وزن صحيحة" : "Please enter a valid weight value"; + notifyListeners(); + return false; + } + + // Validate date time + if (dateTime.trim().isEmpty) { + _dateError = isArabic ? "الرجاء تحديد التاريخ والوقت" : "Please select date and time"; + notifyListeners(); + return false; + } + + notifyListeners(); + return true; + } + + // Old validation method kept for reference String? _validateWeightEntry(String dateTime) { final isArabic = getIt.get().isArabic(); @@ -1190,7 +1378,67 @@ class HealthTrackersViewModel extends ChangeNotifier { // ==================== BLOOD PRESSURE ENTRY METHODS ==================== - // Validate blood pressure entry before saving + // Validate blood pressure entry before saving - returns true if valid, false if errors exist + // Shows only the first error encountered + bool validateBloodPressureEntry(String dateTime) { + final isArabic = getIt.get().isArabic(); + + // Clear all previous errors + _systolicError = null; + _diastolicError = null; + _measuredArmError = null; + _dateError = null; + _timeError = null; + + // Validate systolic value - stop at first error + final systolicValue = systolicController.text.trim(); + if (systolicValue.isEmpty) { + _systolicError = isArabic ? "الرجاء إدخال القيمة الانقباضية" : "Please enter systolic value"; + notifyListeners(); + return false; + } + + final parsedSystolic = int.tryParse(systolicValue); + if (parsedSystolic == null || parsedSystolic <= 0) { + _systolicError = isArabic ? "الرجاء إدخال قيمة صحيحة للضغط الانقباضي" : "Please enter a valid systolic value"; + notifyListeners(); + return false; + } + + // Validate diastolic value + final diastolicValue = diastolicController.text.trim(); + if (diastolicValue.isEmpty) { + _diastolicError = isArabic ? "الرجاء إدخال قيمة الضغط الانبساطي" : "Please enter diastolic value"; + notifyListeners(); + return false; + } + + final parsedDiastolic = int.tryParse(diastolicValue); + if (parsedDiastolic == null || parsedDiastolic <= 0) { + _diastolicError = isArabic ? "الرجاء إدخال قيمة صحيحة للضغط الانبساطي" : "Please enter a valid diastolic value"; + notifyListeners(); + return false; + } + + // Validate arm selection + if (_selectedMeasuredArm.isEmpty) { + _measuredArmError = isArabic ? "الرجاء اختيار الذراع المقاسة" : "Please select measured arm"; + notifyListeners(); + return false; + } + + // Validate date time + if (dateTime.trim().isEmpty) { + _dateError = isArabic ? "الرجاء تحديد التاريخ والوقت" : "Please select date and time"; + notifyListeners(); + return false; + } + + notifyListeners(); + return true; + } + + // Old validation method kept for reference String? _validateBloodPressureEntry(String dateTime) { final isArabic = getIt.get().isArabic(); diff --git a/lib/features/profile_settings/profile_settings_view_model.dart b/lib/features/profile_settings/profile_settings_view_model.dart index 5d2f58d4..7891357e 100644 --- a/lib/features/profile_settings/profile_settings_view_model.dart +++ b/lib/features/profile_settings/profile_settings_view_model.dart @@ -49,6 +49,96 @@ class ProfileSettingsViewModel extends ChangeNotifier { String? profileImageData; String? profileImageError; + // ==================== EMAIL UPDATE ERROR TRACKING ==================== + // Individual field error for email + String? _emailError; + + // Getter for email error + String? get emailError => _emailError; + + // Clear email error + void clearEmailError() { + _emailError = null; + notifyListeners(); + } + + // Validate email before updating + bool validateEmail(String email) { + final appState = GetIt.instance.get(); + final isArabic = appState.isArabic(); + + // Clear previous error + _emailError = null; + + // Check if email is empty + if (email.trim().isEmpty) { + _emailError = isArabic ? "الرجاء إدخال البريد الإلكتروني" : "Please enter email address"; + notifyListeners(); + return false; + } + + // Validate email format + final bool emailIsValid = RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9\-]+\.[a-zA-Z]{2,}$").hasMatch(email.trim()); + if (!emailIsValid) { + _emailError = isArabic ? "الرجاء إدخال تنسيق بريد إلكتروني صالح" : "Please enter a valid email format"; + notifyListeners(); + return false; + } + + // Email is valid + notifyListeners(); + return true; + } + + // ==================== EMERGENCY CONTACT ERROR TRACKING ==================== + // Individual field error for emergency contact + String? _emergencyContactError; + + // Getter for emergency contact error + String? get emergencyContactError => _emergencyContactError; + + // Clear emergency contact error + void clearEmergencyContactError() { + _emergencyContactError = null; + notifyListeners(); + } + + // Validate emergency contact number before updating + bool validateEmergencyContact(String contactNumber) { + final appState = GetIt.instance.get(); + final isArabic = appState.isArabic(); + + // Clear previous error + _emergencyContactError = null; + + // Check if contact number is empty + if (contactNumber.trim().isEmpty) { + _emergencyContactError = isArabic ? "الرجاء إدخال رقم الاتصال في حالات الطوارئ" : "Please enter emergency contact number"; + notifyListeners(); + return false; + } + + // Remove any non-digit characters for validation + String cleanedNumber = contactNumber.replaceAll(RegExp(r'[^0-9]'), ''); + + // Check if it's a valid Saudi phone number (starts with 05 and has 10 digits) + if (cleanedNumber.length != 10) { + _emergencyContactError = isArabic ? "يجب أن يكون رقم الهاتف 10 أرقام" : "Phone number must be 10 digits"; + notifyListeners(); + return false; + } + + if (!cleanedNumber.startsWith('05')) { + _emergencyContactError = isArabic ? "يجب أن يبدأ رقم الهاتف بـ 05" : "Phone number must start with 05"; + notifyListeners(); + return false; + } + + // Contact number is valid + notifyListeners(); + return true; + } + ProfileSettingsViewModel({ required CacheService cacheService, required this.profileSettingsRepo, diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 5ef765b6..f5b7f8c6 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -1834,6 +1834,7 @@ abstract class LocaleKeys { static const liveCareNotificationPermissionsMessage = 'liveCareNotificationPermissionsMessage'; static const weatherIndicators = 'weatherIndicators'; static const submitRating = 'submitRating'; - static const completedPrescriptionOrder = 'completedPrescriptionOrder'; + static const maxOneFileAllowed = 'maxOneFileAllowed'; + static const fileSizeExceedsLimit = 'fileSizeExceedsLimit'; } diff --git a/lib/presentation/authentication/login.dart b/lib/presentation/authentication/login.dart index 05164dec..ee240fd3 100644 --- a/lib/presentation/authentication/login.dart +++ b/lib/presentation/authentication/login.dart @@ -7,7 +7,6 @@ import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/enums.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/core/utils/validation_utils.dart'; import 'package:hmg_patient_app_new/extensions/context_extensions.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; @@ -35,17 +34,33 @@ class LoginScreenState extends State { void initState() { super.initState(); _nationalIdFocusNode = FocusNode(); + + // Clear errors when entering login screen + WidgetsBinding.instance.addPostFrameCallback((_) { + final authVm = context.read(); + authVm.clearNationalIdError(); + authVm.clearPhoneNumberError(); + }); } @override void dispose() { _nationalIdFocusNode.dispose(); + + // Clear errors when leaving login screen + // Use post frame callback to avoid calling notifyListeners during dispose + WidgetsBinding.instance.addPostFrameCallback((_) { + final authVm = context.read(); + authVm.clearNationalIdError(); + authVm.clearPhoneNumberError(); + }); + super.dispose(); } + @override Widget build(BuildContext context) { - AuthenticationViewModel authVm = context.read(); return Scaffold( backgroundColor: AppColors.bgScaffoldColor, appBar: CustomAppBar( @@ -56,94 +71,103 @@ class LoginScreenState extends State { context.setLocale(value == 'en' ? Locale('en', 'US') : Locale('ar', 'SA')); }, ), - body: GestureDetector( - onTap: () { - // Dismiss the keyboard and unfocus any focused widget when tapping outside - _nationalIdFocusNode.unfocus(); - FocusScope.of(context).unfocus(); - }, - child: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 24.h), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Utils.showLottie(context: context, assetPath: AppAnimations.login, width: 200.h, height: 200.h, repeat: true, fit: BoxFit.cover), - // SizedBox(height: 130.h), - SizedBox(height: MediaQuery.of(context).size.height * 0.12), // based on the screen height Adjusted to sizer unit - LocaleKeys.welcomeToDrSulaiman.tr(context: context).toText32(isBold: true, color: AppColors.textColor ), - SizedBox(height: 32.h), - Localizations.override(context: context, locale: Locale('en', 'US'), child: Container()), // Force English locale for this widget - TextInputWidget( - labelText: "${LocaleKeys.nationalIdFileNumber.tr(context: context)}", - hintText: "xxxxxxxxx", - controller: authVm.nationalIdController, - focusNode: _nationalIdFocusNode, - keyboardType: TextInputType.text, - isEnable: true, - prefix: null, - autoFocus: true, - isAllowRadius: true, - isBorderAllowed: false, - isAllowLeadingIcon: true, - padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 10.h), - leadingIcon: AppAssets.student_card, - errorMessage: LocaleKeys.enterValidIDorIqama.tr(context: context), - hasError: false, - fontFamily: "Poppins", - ), - SizedBox(height: 16.h), - CustomButton( - height: 50.h, - text: LocaleKeys.login.tr(context: context), - icon: AppAssets.login1, - iconColor: Colors.white, - onPressed: () { - _nationalIdFocusNode.unfocus(); - FocusScope.of(context).unfocus(); - - if (ValidationUtils.isValidatedId( - nationalId: authVm.nationalIdController.text, - onOkPress: () { - Navigator.of(context).pop(); - })) { - showLoginModelSheet(context: context, phoneNumberController: authVm.phoneNumberController, authViewModel: authVm); - } - }, - ), - SizedBox(height: 10.h), - Center( - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: context.dynamicTextStyle(color: AppColors.textColor, fontSize: 14.f, height: 26 / 16, fontWeight: FontWeight.w600,), - children: [ - TextSpan(text: LocaleKeys.dontHaveAccount.tr(context: context), style: context.dynamicTextStyle()), - TextSpan(text: " "), - TextSpan( - text: LocaleKeys.registernow.tr(context: context), - style: context.dynamicTextStyle( - color: AppColors.primaryRedColor, - fontSize: 14.f, // Adjusted to sizer unit - height: 26 / 16, // Ratio - fontWeight: FontWeight.w600,), - recognizer: TapGestureRecognizer() - ..onTap = () { - Navigator.of(context).push( - MaterialPageRoute(builder: (BuildContext context) => RegisterNew()), - ); - }, + body: Consumer( + builder: (context, authVm, child) { + return GestureDetector( + onTap: () { + // Dismiss the keyboard and unfocus any focused widget when tapping outside + _nationalIdFocusNode.unfocus(); + FocusScope.of(context).unfocus(); + }, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Utils.showLottie(context: context, assetPath: AppAnimations.login, width: 200.h, height: 200.h, repeat: true, fit: BoxFit.cover), + // SizedBox(height: 130.h), + SizedBox(height: MediaQuery.of(context).size.height * 0.12), // based on the screen height Adjusted to sizer unit + LocaleKeys.welcomeToDrSulaiman.tr(context: context).toText32(isBold: true, color: AppColors.textColor ), + SizedBox(height: 32.h), + Localizations.override(context: context, locale: Locale('en', 'US'), child: Container()), // Force English locale for this widget + TextInputWidget( + labelText: LocaleKeys.nationalIdFileNumber.tr(context: context), + hintText: "xxxxxxxxx", + controller: authVm.nationalIdController, + focusNode: _nationalIdFocusNode, + keyboardType: TextInputType.text, + isEnable: true, + prefix: null, + autoFocus: true, + isAllowRadius: true, + isBorderAllowed: false, + isAllowLeadingIcon: true, + padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 10.h), + leadingIcon: AppAssets.student_card, + errorMessage: authVm.nationalIdError, + hasError: authVm.nationalIdError != null, + fontFamily: "Poppins", + onChange: (value) { + // Clear error when user starts typing + authVm.clearNationalIdError(); + }, + ), + SizedBox(height: 16.h), + CustomButton( + height: 50.h, + text: LocaleKeys.login.tr(context: context), + icon: AppAssets.login1, + iconColor: Colors.white, + onPressed: () { + _nationalIdFocusNode.unfocus(); + FocusScope.of(context).unfocus(); + + // Use ViewModel validation method + if (authVm.validateNationalId()) { + showLoginModelSheet( + context: context, + phoneNumberController: authVm.phoneNumberController, + authViewModel: authVm, + ); + } + }, + ), + SizedBox(height: 10.h), + Center( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: context.dynamicTextStyle(color: AppColors.textColor, fontSize: 14.f, height: 26 / 16, fontWeight: FontWeight.w600,), + children: [ + TextSpan(text: LocaleKeys.dontHaveAccount.tr(context: context), style: context.dynamicTextStyle()), + TextSpan(text: " "), + TextSpan( + text: LocaleKeys.registernow.tr(context: context), + style: context.dynamicTextStyle( + color: AppColors.primaryRedColor, + fontSize: 14.f, // Adjusted to sizer unit + height: 26 / 16, // Ratio + fontWeight: FontWeight.w600,), + recognizer: TapGestureRecognizer() + ..onTap = () { + Navigator.of(context).push( + MaterialPageRoute(builder: (BuildContext context) => RegisterNew()), + ); + }, + ), + ], ), - ], + ).withVerticalPadding(2.h), ), - ).withVerticalPadding(2.h), + SizedBox(height: 20.h), + ], ), - SizedBox(height: 20.h), - ], + ), ), - ), - ), + ); + }, ), ); } @@ -154,6 +178,7 @@ class LoginScreenState extends State { required AuthenticationViewModel authViewModel, }) async { AppState appState = getIt(); + context.showBottomSheet( isScrollControlled: true, isDismissible: false, @@ -161,80 +186,85 @@ class LoginScreenState extends State { constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width), backgroundColor: AppColors.transparent, child: StatefulBuilder(builder: (BuildContext context, StateSetter setModalState) { + + // Helper function to validate phone number with error handling + void validatePhoneAndProceed(OTPTypeEnum otpType) { + // Use ViewModel validation method + bool isValid = authViewModel.validatePhoneNumber(); + + if (isValid) { + Navigator.of(context).pop(); + appState.setSelectDeviceByImeiRespModelElement(null); + authViewModel.checkUserAuthentication(otpTypeEnum: otpType); + } + } + return Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: SingleChildScrollView( - child: GenericBottomSheet( - countryCode: authViewModel.selectedCountrySignup.countryCode, - initialPhoneNumber: "", - textController: phoneNumberController, - isEnableCountryDropdown: true, - onCountryChange: (country) { - authViewModel.onCountryChange(country); - setModalState(() {}); - }, - onChange: authViewModel.onPhoneNumberChange, - buttons: [ - if (authViewModel.selectedCountrySignup != CountryEnum.others) - Padding( - padding: EdgeInsets.only(bottom: 10.h), - child: CustomButton( - text: LocaleKeys.sendOTPSMS.tr(context: context), - onPressed: () async { - if (ValidationUtils.isValidatePhone( - phoneNumber: phoneNumberController!.text, - selectedCountry: authViewModel.selectedCountrySignup, - onOkPress: () { - Navigator.of(context).pop(); - })) { - Navigator.of(context).pop(); - appState.setSelectDeviceByImeiRespModelElement(null); - await authViewModel.checkUserAuthentication(otpTypeEnum: OTPTypeEnum.sms); - } - }, - backgroundColor: AppColors.primaryRedColor, - borderColor: AppColors.primaryRedBorderColor, - textColor: Colors.white, - iconColor: Colors.white, - icon: AppAssets.message, - ), - ), - if (authViewModel.selectedCountrySignup != CountryEnum.others) - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ + child: Consumer( + builder: (context, authVm, child) { + return GenericBottomSheet( + countryCode: authVm.selectedCountrySignup.countryCode, + initialPhoneNumber: "", + textController: phoneNumberController, + isEnableCountryDropdown: true, + onCountryChange: (country) { + authVm.onCountryChange(country); + // Clear error when country changes + authVm.clearPhoneNumberError(); + }, + onChange: (value) { + authVm.onPhoneNumberChange(value); + // Clear error when user starts typing + authVm.clearPhoneNumberError(); + }, + phoneNumberError: authVm.phoneNumberError, + buttons: [ + if (authVm.selectedCountrySignup != CountryEnum.others) Padding( - padding: EdgeInsets.symmetric(horizontal: 8.h), - child: LocaleKeys.oR.tr(context: context).toText16(color: AppColors.textColor), + padding: EdgeInsets.only(bottom: 10.h), + child: CustomButton( + text: LocaleKeys.sendOTPSMS.tr(context: context), + onPressed: () async { + validatePhoneAndProceed(OTPTypeEnum.sms); + }, + backgroundColor: AppColors.primaryRedColor, + borderColor: AppColors.primaryRedBorderColor, + textColor: Colors.white, + iconColor: Colors.white, + icon: AppAssets.message, + ), ), - ], - ), - Padding( - padding: EdgeInsets.only(bottom: 10.h, top: 10.h), - child: CustomButton( - text: LocaleKeys.sendOTPWHATSAPP.tr(context: context), - onPressed: () async { - if (ValidationUtils.isValidatePhone( - phoneNumber: phoneNumberController!.text, - selectedCountry: authViewModel.selectedCountrySignup, - onOkPress: () { - Navigator.of(context).pop(); - })) { - Navigator.of(context).pop(); - appState.setSelectDeviceByImeiRespModelElement(null); - await authViewModel.checkUserAuthentication(otpTypeEnum: OTPTypeEnum.whatsapp); - } - }, - backgroundColor: AppColors.whiteColor, - borderColor: AppColors.textColor, - textColor: AppColors.textColor, - icon: AppAssets.whatsapp, - iconColor: null, - applyThemeColor: false, - ), - ), - ], + if (authVm.selectedCountrySignup != CountryEnum.others) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.h), + child: LocaleKeys.oR.tr(context: context).toText16(color: AppColors.textColor), + ), + ], + ), + Padding( + padding: EdgeInsets.only(bottom: 10.h, top: 10.h), + child: CustomButton( + text: LocaleKeys.sendOTPWHATSAPP.tr(context: context), + onPressed: () async { + validatePhoneAndProceed(OTPTypeEnum.whatsapp); + }, + backgroundColor: AppColors.whiteColor, + borderColor: AppColors.textColor, + textColor: AppColors.textColor, + icon: AppAssets.whatsapp, + iconColor: null, + applyThemeColor: false, + ), + ), + ], + ); + }, ), ), ); diff --git a/lib/presentation/authentication/register.dart b/lib/presentation/authentication/register.dart index 2f181e16..61b5924e 100644 --- a/lib/presentation/authentication/register.dart +++ b/lib/presentation/authentication/register.dart @@ -7,7 +7,6 @@ import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/enums.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/core/utils/validation_utils.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; import 'package:hmg_patient_app_new/features/authentication/authentication_view_model.dart'; @@ -34,19 +33,35 @@ class _RegisterNew extends State { super.initState(); _nationalIdFocusNode = FocusNode(); _dobFocusNode = FocusNode(); + + // Clear errors when entering register screen + WidgetsBinding.instance.addPostFrameCallback((_) { + final authVm = context.read(); + authVm.clearNationalIdError(); + authVm.clearDobError(); + authVm.clearPhoneNumberError(); + }); } @override void dispose() { _nationalIdFocusNode.dispose(); _dobFocusNode.dispose(); + + // Clear errors when leaving register screen + // Use post frame callback to avoid calling notifyListeners during dispose + WidgetsBinding.instance.addPostFrameCallback((_) { + final authVm = context.read(); + authVm.clearNationalIdError(); + authVm.clearDobError(); + authVm.clearPhoneNumberError(); + }); + super.dispose(); } @override Widget build(BuildContext context) { - AuthenticationViewModel authVm = context.read(); - return Scaffold( backgroundColor: AppColors.bgScaffoldColor, appBar: CustomAppBar( @@ -57,199 +72,216 @@ class _RegisterNew extends State { context.setLocale(value == 'en' ? Locale('en', 'US') : Locale('ar', 'SA')); }, ), - body: GestureDetector( - onTap: () { - // Dismiss keyboard and unfocus all input fields - _nationalIdFocusNode.unfocus(); - _dobFocusNode.unfocus(); - FocusScope.of(context).unfocus(); - }, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(overscroll: false, physics: const ClampingScrollPhysics()), - child: NotificationListener( - onNotification: (notification) { - notification.disallowIndicator(); - return true; + body: Consumer( + builder: (context, authVm, child) { + return GestureDetector( + onTap: () { + // Dismiss keyboard and unfocus all input fields + _nationalIdFocusNode.unfocus(); + _dobFocusNode.unfocus(); + FocusScope.of(context).unfocus(); }, - child: SingleChildScrollView( - physics: ClampingScrollPhysics(), - padding: EdgeInsets.symmetric(horizontal: 24.h), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Utils.showLottie(context: context, assetPath: 'assets/animations/lottie/register.json', width: 200.w, height: 200.h, fit: BoxFit.cover, repeat: true), - SizedBox(height: 16.h), - LocaleKeys.prepareToElevate.tr(context: context).toText32(isBold: true), - SizedBox(height: 24.h), - Directionality( - textDirection: Directionality.of(context), - child: Container( - decoration: BoxDecoration(color: AppColors.whiteColor, borderRadius: BorderRadius.circular(24)), - padding: EdgeInsets.symmetric(horizontal: 16.h), - child: Column( - children: [ - CustomCountryDropdown( - countryList: CountryEnum.values.where((c) => c != CountryEnum.others).toList(), - onCountryChange: authVm.onCountryChange, - // isRtl: Directionality.of(context) == TextDirection.LTR, - ).withVerticalPadding(8.h), - Divider(height: 1.h), - TextInputWidget( - labelText: LocaleKeys.nationalIdNumber.tr(context: context), - hintText: "xxxxxxxxx", - controller: authVm.nationalIdController, - focusNode: _nationalIdFocusNode, - keyboardType: TextInputType.number, - isEnable: true, - prefix: null, - isAllowRadius: true, - isBorderAllowed: false, - isAllowLeadingIcon: true, - autoFocus: true, - padding: EdgeInsets.symmetric(vertical: 8.h), - leadingIcon: AppAssets.student_card, - fontFamily: "Poppins", - ).withVerticalPadding(8), - Divider(height: 1), - TextInputWidget( - labelText: LocaleKeys.dob.tr(context: context), - hintText: "11 July, 1994", - controller: authVm.dobController, - focusNode: _dobFocusNode, - isEnable: true, - prefix: null, - isAllowRadius: true, - isBorderAllowed: false, - isAllowLeadingIcon: true, - padding: EdgeInsets.symmetric(vertical: 8.h), - leadingIcon: AppAssets.birthday_cake, - selectionType: SelectionTypeEnum.calendar, - onCalendarTypeChanged: authVm.onCalenderTypeChange, - onChange: authVm.onDobChange, - fontFamily: "Poppins", - ).withVerticalPadding(8), - ], - ), - ), - ), - SizedBox(height: 25.h), - GestureDetector( - onTap: authVm.onTermAccepted, - child: Row( - children: [ - Selector( - selector: (_, viewModel) => viewModel.isTermsAccepted, - shouldRebuild: (previous, next) => previous != next, - builder: (context, isTermsAccepted, child) { - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - height: 24.h, - width: 24.h, - decoration: BoxDecoration( - color: isTermsAccepted ? AppColors.primaryRedColor : Colors.transparent, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: isTermsAccepted ? AppColors.primaryRedBorderColor : AppColors.greyColor, width: 2.h), - ), - child: isTermsAccepted ? Icon(Icons.check, size: 16.f, color: Colors.white) : null, - ); - }, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(overscroll: false, physics: const ClampingScrollPhysics()), + child: NotificationListener( + onNotification: (notification) { + notification.disallowIndicator(); + return true; + }, + child: SingleChildScrollView( + physics: ClampingScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 24.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Utils.showLottie(context: context, assetPath: 'assets/animations/lottie/register.json', width: 200.w, height: 200.h, fit: BoxFit.cover, repeat: true), + SizedBox(height: 16.h), + LocaleKeys.prepareToElevate.tr(context: context).toText32(isBold: true), + SizedBox(height: 24.h), + Directionality( + textDirection: Directionality.of(context), + child: Container( + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: authVm.hasRegistrationFormError ? AppColors.primaryRedBorderColor : Colors.transparent, + width: 1, + ), + ), + padding: EdgeInsets.symmetric(horizontal: 16.h), + child: Column( + children: [ + CustomCountryDropdown( + countryList: CountryEnum.values.where((c) => c != CountryEnum.others).toList(), + onCountryChange: authVm.onCountryChange, + // isRtl: Directionality.of(context) == TextDirection.LTR, + ).withVerticalPadding(8.h), + Divider(height: 1.h), + TextInputWidget( + labelText: LocaleKeys.nationalIdNumber.tr(context: context), + hintText: "xxxxxxxxx", + controller: authVm.nationalIdController, + focusNode: _nationalIdFocusNode, + keyboardType: TextInputType.number, + isEnable: true, + prefix: null, + isAllowRadius: true, + isBorderAllowed: false, + isAllowLeadingIcon: true, + autoFocus: true, + padding: EdgeInsets.symmetric(vertical: 8.h), + leadingIcon: AppAssets.student_card, + fontFamily: "Poppins", + hasError: false, // Don't show individual field border + errorMessage: authVm.nationalIdError, // Show error message if exists + onChange: (value) { + // Clear error when user starts typing + authVm.clearNationalIdError(); + }, + ).withVerticalPadding(8), + Divider(height: 1), + TextInputWidget( + labelText: LocaleKeys.dob.tr(context: context), + hintText: "11 July, 1994", + controller: authVm.dobController, + focusNode: _dobFocusNode, + isEnable: true, + prefix: null, + isAllowRadius: true, + isBorderAllowed: false, + isAllowLeadingIcon: true, + padding: EdgeInsets.symmetric(vertical: 8.h), + leadingIcon: AppAssets.birthday_cake, + selectionType: SelectionTypeEnum.calendar, + onCalendarTypeChanged: authVm.onCalenderTypeChange, + onChange: (value) { + authVm.onDobChange(value); + // Clear error when user selects a date + authVm.clearDobError(); + }, + fontFamily: "Poppins", + hasError: false, // Don't show individual field border + errorMessage: authVm.dobError, // Show error message if exists + ).withVerticalPadding(8), + ], + ), ), - SizedBox(width: 12.h), - Row( + ), + SizedBox(height: 25.h), + GestureDetector( + onTap: authVm.onTermAccepted, + child: Row( children: [ - Text( - LocaleKeys.iAcceptThe.tr(context: context), - style: context.dynamicTextStyle(fontSize: 14.f, fontWeight: FontWeight.w600, color: Color(0xFF2E3039)), - ), - GestureDetector( - onTap: () { - // Navigate to terms and conditions page - Utils.openWebView( - url: 'https://hmg.com/en/Pages/Terms.aspx', + Selector( + selector: (_, viewModel) => viewModel.isTermsAccepted, + shouldRebuild: (previous, next) => previous != next, + builder: (context, isTermsAccepted, child) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 24.h, + width: 24.h, + decoration: BoxDecoration( + color: isTermsAccepted ? AppColors.primaryRedColor : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: isTermsAccepted ? AppColors.primaryRedBorderColor : AppColors.greyColor, width: 2.h), + ), + child: isTermsAccepted ? Icon(Icons.check, size: 16.f, color: Colors.white) : null, ); }, - child: Text( - " ${LocaleKeys.termsConditoins.tr(context: context)}", - style: context.dynamicTextStyle( - fontSize: 14.f, - fontWeight: FontWeight.w600, - color: AppColors.primaryRedColor, - decoration: TextDecoration.underline, - decorationColor: AppColors.primaryRedBorderColor, + ), + SizedBox(width: 12.h), + Row( + children: [ + Text( + LocaleKeys.iAcceptThe.tr(context: context), + style: context.dynamicTextStyle(fontSize: 14.f, fontWeight: FontWeight.w600, color: Color(0xFF2E3039)), ), - ), + GestureDetector( + onTap: () { + // Navigate to terms and conditions page + Utils.openWebView( + url: 'https://hmg.com/en/Pages/Terms.aspx', + ); + }, + child: Text( + " ${LocaleKeys.termsConditoins.tr(context: context)}", + style: context.dynamicTextStyle( + fontSize: 14.f, + fontWeight: FontWeight.w600, + color: AppColors.primaryRedColor, + decoration: TextDecoration.underline, + decorationColor: AppColors.primaryRedBorderColor, + ), + ), + ), + ], ), + // Expanded( + // child: Text( + // LocaleKeys.iAcceptTermsConditions.tr(context: context).split("the").first, + // style: context.dynamicTextStyle(fontSize: 14.fSize, isBold: true, color: Color(0xFF2E3039)), + // ), + // ), ], ), - // Expanded( - // child: Text( - // LocaleKeys.iAcceptTermsConditions.tr(context: context).split("the").first, - // style: context.dynamicTextStyle(fontSize: 14.fSize, isBold: true, color: Color(0xFF2E3039)), - // ), - // ), - ], - ), - ), - SizedBox(height: 25.h), - CustomButton( - text: LocaleKeys.registernow.tr(context: context), - icon: AppAssets.note_edit, - onPressed: () { - // Dismiss keyboard before proceeding - _nationalIdFocusNode.unfocus(); - _dobFocusNode.unfocus(); - FocusScope.of(context).unfocus(); + ), + SizedBox(height: 25.h), + CustomButton( + text: LocaleKeys.registernow.tr(context: context), + icon: AppAssets.note_edit, + onPressed: () { + // Dismiss keyboard before proceeding + _nationalIdFocusNode.unfocus(); + _dobFocusNode.unfocus(); + FocusScope.of(context).unfocus(); - if (ValidationUtils.isValidatedId( - nationalId: authVm.nationalIdController.text, - selectedCountry: authVm.selectedCountrySignup, - isTermsAccepted: authVm.isTermsAccepted, - dob: authVm.dobController.text, - onOkPress: () { - Navigator.of(context).pop(); - })) { - showRegisterModel(context: context, authVM: authVm); - } - }, - ), - SizedBox(height: 14), - Center( - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: context.dynamicTextStyle( - color: Colors.black, - fontSize: 16.f, - height: 26 / 16, - fontWeight: FontWeight.w600, - ), - children: [ - TextSpan(text: LocaleKeys.alreadyHaveAccount.tr(context: context), style: context.dynamicTextStyle()), - TextSpan(text: " "), - TextSpan( - text: LocaleKeys.loginNow.tr(context: context), + // Use ViewModel validation method + if (authVm.validateRegistrationForm()) { + showRegisterModel(context: context, authVM: authVm); + } + }, + ), + SizedBox(height: 14), + Center( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( style: context.dynamicTextStyle( - color: AppColors.primaryRedColor, + color: Colors.black, fontSize: 16.f, height: 26 / 16, fontWeight: FontWeight.w600, ), - recognizer: TapGestureRecognizer() - ..onTap = () { - Navigator.of(context).pop(); - }, + children: [ + TextSpan(text: LocaleKeys.alreadyHaveAccount.tr(context: context), style: context.dynamicTextStyle()), + TextSpan(text: " "), + TextSpan( + text: LocaleKeys.loginNow.tr(context: context), + style: context.dynamicTextStyle( + color: AppColors.primaryRedColor, + fontSize: 16.f, + height: 26 / 16, + fontWeight: FontWeight.w600, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Navigator.of(context).pop(); + }, + ), + ], ), - ], + ), ), - ), + SizedBox(height: 30.h), + ], ), - SizedBox(height: 30.h), - ], + ), ), ), - ), - ), + ); + }, )); } @@ -264,73 +296,75 @@ class _RegisterNew extends State { builder: (bottomSheetContext) => Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(bottomSheetContext).viewInsets.bottom), child: SingleChildScrollView( - child: GenericBottomSheet( - countryCode: authVM.selectedCountrySignup.countryCode, - initialPhoneNumber: authVM.phoneNumberController.text, - textController: authVM.phoneNumberController, - isEnableCountryDropdown: false, - onCountryChange: authVM.onCountryChange, - onChange: authVM.onPhoneNumberChange, - autoFocus: true, - buttons: [ - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: CustomButton( - text: LocaleKeys.sendOTPSMS.tr(context: context), - onPressed: () async { - // Dismiss keyboard before validation - FocusScope.of(context).unfocus(); + child: Consumer( + builder: (context, authVm, child) { + return GenericBottomSheet( + countryCode: authVm.selectedCountrySignup.countryCode, + initialPhoneNumber: authVm.phoneNumberController.text, + textController: authVm.phoneNumberController, + isEnableCountryDropdown: false, + onCountryChange: authVm.onCountryChange, + onChange: (value) { + authVm.onPhoneNumberChange(value); + // Clear error when user starts typing + authVm.clearPhoneNumberError(); + }, + autoFocus: true, + phoneNumberError: authVm.phoneNumberError, + buttons: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: CustomButton( + text: LocaleKeys.sendOTPSMS.tr(context: context), + onPressed: () async { + // Dismiss keyboard before validation + FocusScope.of(context).unfocus(); - if (ValidationUtils.isValidatePhone( - phoneNumber: authVM.phoneNumberController.text, - onOkPress: () { - Navigator.of(context).pop(); + // Use ViewModel validation method + if (authVm.validatePhoneNumber()) { + appState.setSelectDeviceByImeiRespModelElement(null); + await authVm.onRegistrationStart(otpTypeEnum: OTPTypeEnum.sms); + } }, - )) { - appState.setSelectDeviceByImeiRespModelElement(null); - await authVM.onRegistrationStart(otpTypeEnum: OTPTypeEnum.sms); - } - }, - backgroundColor: AppColors.primaryRedColor, - borderColor: AppColors.primaryRedBorderColor, - textColor: AppColors.whiteColor, - icon: AppAssets.message, - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ + backgroundColor: AppColors.primaryRedColor, + borderColor: AppColors.primaryRedBorderColor, + textColor: AppColors.whiteColor, + icon: AppAssets.message, + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.h), + child: LocaleKeys.oR.tr(context: context).toText16(color: AppColors.textColor), + ), + ], + ), Padding( - padding: EdgeInsets.symmetric(horizontal: 8.h), - child: LocaleKeys.oR.tr(context: context).toText16(color: AppColors.textColor), + padding: EdgeInsets.only(bottom: 10.h, top: 10.h), + child: CustomButton( + text: LocaleKeys.sendOTPWHATSAPP.tr(context: context), + onPressed: () async { + FocusScope.of(context).unfocus(); + + // Use ViewModel validation method + if (authVm.validatePhoneNumber()) { + appState.setSelectDeviceByImeiRespModelElement(null); + await authVm.onRegistrationStart(otpTypeEnum: OTPTypeEnum.whatsapp); + } + }, + backgroundColor: AppColors.whiteColor, + borderColor: AppColors.borderOnlyColor, + textColor: AppColors.textColor, + icon: AppAssets.whatsapp, + iconColor: null, + ), ), ], - ), - Padding( - padding: EdgeInsets.only(bottom: 10.h, top: 10.h), - child: CustomButton( - text: LocaleKeys.sendOTPWHATSAPP.tr(context: context), - onPressed: () async { - FocusScope.of(context).unfocus(); - if (ValidationUtils.isValidatePhone( - phoneNumber: authVM.phoneNumberController.text, - onOkPress: () { - Navigator.of(context).pop(); - }, - )) { - appState.setSelectDeviceByImeiRespModelElement(null); - await authVM.onRegistrationStart(otpTypeEnum: OTPTypeEnum.whatsapp); - } - }, - backgroundColor: AppColors.whiteColor, - borderColor: AppColors.borderOnlyColor, - textColor: AppColors.textColor, - icon: AppAssets.whatsapp, - iconColor: null, - ), - ), - ], + ); + }, ), ), ), diff --git a/lib/presentation/authentication/register_step2.dart b/lib/presentation/authentication/register_step2.dart index ffde3227..c17d9ba7 100644 --- a/lib/presentation/authentication/register_step2.dart +++ b/lib/presentation/authentication/register_step2.dart @@ -7,7 +7,6 @@ import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/enums.dart'; import 'package:hmg_patient_app_new/core/utils/date_util.dart'; import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; -import 'package:hmg_patient_app_new/core/utils/validation_utils.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; import 'package:hmg_patient_app_new/features/authentication/authentication_view_model.dart'; @@ -39,6 +38,11 @@ class _RegisterNew extends State { authVM = context.read(); insuranceVM = context.read(); + // Clear errors when entering the page + WidgetsBinding.instance.addPostFrameCallback((_) { + authVM?.clearAllStep2FieldErrors(); + }); + // Call insurance API to fetch data WidgetsBinding.instance.addPostFrameCallback((_) { debugPrint("Registration Step 2: Calling insurance API"); @@ -51,6 +55,10 @@ class _RegisterNew extends State { @override void dispose() { + // Clear errors when leaving the page + WidgetsBinding.instance.addPostFrameCallback((_) { + authVM?.clearAllStep2FieldErrors(); + }); super.dispose(); } @@ -107,18 +115,17 @@ class _RegisterNew extends State { icon: AppAssets.confirm, iconColor: AppColors.whiteColor, onPressed: () { + // Unfocus keyboard + FocusScope.of(context).unfocus(); + + // For UAE users, validate the form first if (appState.getUserRegistrationPayload.zipCode != CountryEnum.saudiArabia.countryCode) { - if (ValidationUtils.validateUaeRegistration( - name: authVM!.nameController.text, - gender: authVM!.genderType, - country: authVM!.pickedCountryByUAEUser, - maritalStatus: authVM!.maritalStatus, - onOkPress: () { - Navigator.of(context).pop(); - })) { + // Use ViewModel validation method + if (authVM!.validateRegistrationStep2Form()) { showModel(context: context); } } else { + // For Saudi users, no validation needed, show email modal directly showModel(context: context); } }, @@ -166,10 +173,21 @@ class _RegisterNew extends State { }, ), - Container( - decoration: BoxDecoration(color: AppColors.whiteColor, borderRadius: BorderRadius.circular(24)), - padding: EdgeInsets.only(left: 16.h, right: 16.h), - child: Column( + // Form Container with Error Border + Selector( + selector: (_, model) => model.hasRegistrationStep2FormError, + builder: (context, hasError, child) { + return Container( + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: hasError ? AppColors.primaryRedBorderColor : Colors.transparent, + width: 1, + ), + ), + padding: EdgeInsets.only(left: 16.h, right: 16.h), + child: Column( children: [ TextInputWidget( labelText: authVM!.isUserFromUAE() ? LocaleKeys.fullName.tr(context: context) : LocaleKeys.name.tr(context: context), @@ -184,11 +202,27 @@ class _RegisterNew extends State { onSubmitted: (value) { FocusScope.of(context).unfocus(); }, + onChange: (value) { + // Clear error when user starts typing + authVM!.clearNameError(); + }, isAllowLeadingIcon: true, isReadOnly: authVM!.isUserFromUAE() ? false : true, leadingIcon: AppAssets.user_circle, labelColor: AppColors.textColor, ).paddingSymmetrical(0.h, 8.h), + // Show error message if exists + if (authVM!.isUserFromUAE() && authVM!.nameError != null) + Padding( + padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h, right: 16.w), + child: Text( + authVM!.nameError!, + style: TextStyle( + color: AppColors.primaryRedColor, + fontSize: 12.f, + ), + ), + ), Divider(height: 1.h, color: AppColors.greyColor), TextInputWidget( labelText: LocaleKeys.nationalIdNumber.tr(context: context), @@ -240,6 +274,18 @@ class _RegisterNew extends State { labelColor: AppColors.textColor, onChange: (value) {}) .paddingSymmetrical(0.h, 8.h), + // Show gender error message if exists (for UAE users) + if (authVM!.isUserFromUAE() && authVM!.genderError != null) + Padding( + padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h, right: 16.w), + child: Text( + authVM!.genderError!, + style: TextStyle( + color: AppColors.primaryRedColor, + fontSize: 12.f, + ), + ), + ), Divider(height: 1, color: AppColors.greyColor), authVM!.isUserFromUAE() ? Selector( @@ -279,6 +325,18 @@ class _RegisterNew extends State { leadingIcon: AppAssets.smart_phone, onChange: (value) {}) .paddingSymmetrical(0.h, 8.h), + // Show marital status error message if exists (for UAE users) + if (authVM!.isUserFromUAE() && authVM!.maritalStatusError != null) + Padding( + padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h, right: 16.w), + child: Text( + authVM!.maritalStatusError!, + style: TextStyle( + color: AppColors.primaryRedColor, + fontSize: 12.f, + ), + ), + ), Divider(height: 1.h, color: AppColors.greyColor), authVM!.isUserFromUAE() ? Selector? countriesList, NationalityCountries? selectedCountry, bool isArabic})>( @@ -329,6 +387,18 @@ class _RegisterNew extends State { leadingIcon: AppAssets.globe, onChange: (value) {}) .paddingSymmetrical(0.h, 8.h), + // Show country error message if exists (for UAE users) + if (authVM!.isUserFromUAE() && authVM!.countryError != null) + Padding( + padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h, right: 16.w), + child: Text( + authVM!.countryError!, + style: TextStyle( + color: AppColors.primaryRedColor, + fontSize: 12.f, + ), + ), + ), Divider( height: 1, color: AppColors.greyColor, @@ -365,6 +435,8 @@ class _RegisterNew extends State { ).paddingSymmetrical(0.h, 8.h), ], ), + ); + }, ), SizedBox(height: 50.h), // Row( @@ -438,12 +510,17 @@ class _RegisterNew extends State { child: CustomButton( text: LocaleKeys.submit.tr(context: context), onPressed: () { - if (ValidationUtils.isValidateEmail( - email: authVM!.emailController.text, - onOkPress: () { - Navigator.of(context).pop(); - })) { + // Use ViewModel validation method + if (authVM!.validateEmail()) { authVM!.onRegistrationComplete(); + } else { + // Show error in a dialog + if (authVM!.emailError != null) { + // dialogService can be used here if needed + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(authVM!.emailError!)), + ); + } } }, backgroundColor: AppColors.bgGreenColor, diff --git a/lib/presentation/book_appointment/doctor_filter/doctors_filter.dart b/lib/presentation/book_appointment/doctor_filter/doctors_filter.dart index c09fbfef..b6f8e02f 100644 --- a/lib/presentation/book_appointment/doctor_filter/doctors_filter.dart +++ b/lib/presentation/book_appointment/doctor_filter/doctors_filter.dart @@ -98,62 +98,90 @@ class DoctorsFilters extends StatelessWidget{ height: 42.h, child: FacilityChip()), titleWidget(LocaleKeys.hospital.tr()), - TextInputWidget( - controller: TextEditingController()..text =context.watch().selectedHospitalForFilters?.filterName??'', - labelText: LocaleKeys.hospital.tr(context: context), - hintText: LocaleKeys.searchHospital.tr(context: context), - isEnable: false, - prefix: null, - autoFocus: false, - isBorderAllowed: false, - keyboardType: TextInputType.text, - suffix:context.watch().selectedHospitalForFilters != null - ? GestureDetector( - onTap: () { - context.read().setSelectedHospital(null); - }, - child: Utils.buildSvgWithAssets(icon: AppAssets.ic_cross_circle, width: 24.h, height: 24.h, fit: BoxFit.scaleDown), - ) - : null, - onChange: (value) { - // DoctorFilterViewModel.filterClinics(value!); + Consumer( + builder: (context, viewModel, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextInputWidget( + controller: TextEditingController()..text = viewModel.selectedHospitalForFilters?.filterName ?? '', + labelText: LocaleKeys.hospital.tr(context: context), + hintText: LocaleKeys.searchHospital.tr(context: context), + isEnable: false, + prefix: null, + autoFocus: false, + isBorderAllowed: true, + keyboardType: TextInputType.text, + hasError: viewModel.hospitalError != null, + errorMessage: viewModel.hospitalError, + suffix: viewModel.selectedHospitalForFilters != null + ? GestureDetector( + onTap: () { + viewModel.setSelectedHospital(null); + }, + child: Utils.buildSvgWithAssets(icon: AppAssets.ic_cross_circle, width: 24.h, height: 24.h, fit: BoxFit.scaleDown), + ) + : null, + onChange: (value) { + // Clear error when field changes + viewModel.clearHospitalError(); + }, + padding: EdgeInsets.symmetric( + vertical: ResponsiveExtension(8).h, + horizontal: ResponsiveExtension(10).h, + ), + ).onPress(() { + // Clear error when opening bottom sheet + context.read().clearHospitalError(); + openRegionListBottomSheet(context, RegionBottomSheetType.FOR_REGION); + }), + ], + ); }, - padding: EdgeInsets.symmetric( - vertical: ResponsiveExtension(8).h, - horizontal: ResponsiveExtension(10).h, - ), - ).onPress((){ - openRegionListBottomSheet(context, RegionBottomSheetType.FOR_REGION); - }), + ), titleWidget(LocaleKeys.clinic.tr()), - TextInputWidget( - controller: TextEditingController()..text =context.watch().selectedClinicForFilters ??'', - labelText: LocaleKeys.clinicName.tr(context: context), - hintText: LocaleKeys.searchClinic.tr(), - isEnable: false, - prefix: null, - autoFocus: false, - isBorderAllowed: false, - keyboardType: TextInputType.text, - suffix:context.read().selectedClinicForFilters?.isNotEmpty == true - ? GestureDetector( - onTap: () { - context.read().setSelectedClinicForFilter(null); - }, - child: Utils.buildSvgWithAssets(icon: AppAssets.ic_cross_circle, width: 20.h, height: 20.h, fit: BoxFit.scaleDown), - ) - : null, - onChange: (value) { - // DoctorFilterViewModel.filterClinics(value!); + Consumer( + builder: (context, viewModel, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextInputWidget( + controller: TextEditingController()..text = viewModel.selectedClinicForFilters ?? '', + labelText: LocaleKeys.clinicName.tr(context: context), + hintText: LocaleKeys.searchClinic.tr(), + isEnable: false, + prefix: null, + autoFocus: false, + isBorderAllowed: true, + keyboardType: TextInputType.text, + hasError: viewModel.clinicError != null, + errorMessage: viewModel.clinicError, + suffix: viewModel.selectedClinicForFilters?.isNotEmpty == true + ? GestureDetector( + onTap: () { + viewModel.setSelectedClinicForFilter(null); + }, + child: Utils.buildSvgWithAssets(icon: AppAssets.ic_cross_circle, width: 20.h, height: 20.h, fit: BoxFit.scaleDown), + ) + : null, + onChange: (value) { + // Clear error when field changes + viewModel.clearClinicError(); + }, + padding: EdgeInsets.symmetric( + vertical: 8.h, + horizontal: 10.h, + ), + ).onPress(() { + // Clear error when opening bottom sheet + context.read().clearClinicError(); + openClinicListBottomSheet(context); + }), + ], + ); }, - padding: EdgeInsets.symmetric( - vertical: 8.h, - horizontal: 10.h, - ), - ).onPress((){ - openClinicListBottomSheet(context,); - }), + ), ], diff --git a/lib/presentation/contact_us/feedback_page.dart b/lib/presentation/contact_us/feedback_page.dart index c49b3679..730fcddf 100644 --- a/lib/presentation/contact_us/feedback_page.dart +++ b/lib/presentation/contact_us/feedback_page.dart @@ -86,20 +86,12 @@ class FeedbackPage extends StatelessWidget { child: CustomButton( text: LocaleKeys.submit.tr(context: context), onPressed: () async { - if (subjectTextController.text.isEmpty) { - showCommonBottomSheetWithoutHeight( - context, - child: Utils.getErrorWidget(loadingText: LocaleKeys.emptySubject.tr(context: context)), - ); - return; - } - if (messageTextController.text.isEmpty) { - showCommonBottomSheetWithoutHeight( - context, - child: Utils.getErrorWidget(loadingText: LocaleKeys.emptyMessage.tr(context: context)), - ); + // Use ViewModel validation method + if (!contactUsViewModel.validateFeedbackForm(subjectTextController.text, messageTextController.text)) { + // Validation failed, errors are already set in viewModel and displayed below fields return; } + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.sendingFeedback.tr(context: context)); contactUsViewModel.insertCOCItem( subject: subjectTextController.text, @@ -109,6 +101,7 @@ class FeedbackPage extends StatelessWidget { subjectTextController.clear(); messageTextController.clear(); contactUsViewModel.setPatientFeedbackSelectedAppointment(null); + contactUsViewModel.clearAllFeedbackErrors(); showCommonBottomSheetWithoutHeight(context, child: Utils.getSuccessWidget(loadingText: LocaleKeys.success.tr(context: context)), callBackFunc: () { Navigator.pop(context); }); @@ -117,7 +110,7 @@ class FeedbackPage extends StatelessWidget { LoaderBottomSheet.hideLoader(); showCommonBottomSheetWithoutHeight( context, - child: Utils.getSuccessWidget(loadingText: err), + child: Utils.getErrorWidget(loadingText: err), ); }); }, @@ -303,35 +296,55 @@ class FeedbackPage extends StatelessWidget { ), ], SizedBox(height: 16.h), - TextInputWidget( - labelText: LocaleKeys.subject.tr(context: context), - hintText: LocaleKeys.enterSubjectHere.tr(context: context), - controller: subjectTextController, - isEnable: true, - prefix: null, - autoFocus: false, - isBorderAllowed: false, - keyboardType: TextInputType.text, - padding: EdgeInsets.symmetric( - vertical: ResponsiveExtension(10).h, - horizontal: ResponsiveExtension(15).h, - ), + Consumer( + builder: (context, viewModel, child) { + return TextInputWidget( + labelText: LocaleKeys.subject.tr(context: context), + hintText: LocaleKeys.enterSubjectHere.tr(context: context), + controller: subjectTextController, + isEnable: true, + prefix: null, + autoFocus: false, + isBorderAllowed: true, + keyboardType: TextInputType.text, + hasError: viewModel.subjectError != null, + errorMessage: viewModel.subjectError, + onChange: (value) { + // Clear error when user starts typing + viewModel.clearSubjectError(); + }, + padding: EdgeInsets.symmetric( + vertical: ResponsiveExtension(10).h, + horizontal: ResponsiveExtension(15).h, + ), + ); + }, ), SizedBox(height: 16.h), - TextInputWidget( - labelText: LocaleKeys.message.tr(context: context), - hintText: LocaleKeys.enterMessageHere.tr(context: context), - controller: messageTextController, - isEnable: true, - prefix: null, - autoFocus: false, - isBorderAllowed: false, - isMultiline: true, - keyboardType: TextInputType.text, - padding: EdgeInsets.symmetric( - vertical: ResponsiveExtension(10).h, - horizontal: ResponsiveExtension(15).h, - ), + Consumer( + builder: (context, viewModel, child) { + return TextInputWidget( + labelText: LocaleKeys.message.tr(context: context), + hintText: LocaleKeys.enterMessageHere.tr(context: context), + controller: messageTextController, + isEnable: true, + prefix: null, + autoFocus: false, + isBorderAllowed: true, + isMultiline: true, + keyboardType: TextInputType.text, + hasError: viewModel.messageError != null, + errorMessage: viewModel.messageError, + onChange: (value) { + // Clear error when user starts typing + viewModel.clearMessageError(); + }, + padding: EdgeInsets.symmetric( + vertical: ResponsiveExtension(10).h, + horizontal: ResponsiveExtension(15).h, + ), + ); + }, ), SizedBox(height: 16.h), CustomButton( diff --git a/lib/presentation/habib_wallet/recharge_wallet_page.dart b/lib/presentation/habib_wallet/recharge_wallet_page.dart index 538317bd..b7feaadc 100644 --- a/lib/presentation/habib_wallet/recharge_wallet_page.dart +++ b/lib/presentation/habib_wallet/recharge_wallet_page.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; @@ -23,8 +21,6 @@ import 'package:provider/provider.dart'; import 'widgets/select-medical_file.dart'; -import 'dart:ui' as ui; - class RechargeWalletPage extends StatefulWidget { const RechargeWalletPage({super.key}); @@ -33,7 +29,7 @@ class RechargeWalletPage extends StatefulWidget { } class _RechargeWalletPageState extends State { - FocusNode textFocusNode = FocusNode(); + late FocusNode _amountFocusNode; late HabibWalletViewModel habibWalletVM; late AppState appState; @@ -42,88 +38,152 @@ class _RechargeWalletPageState extends State { @override void initState() { - scheduleMicrotask(() { + super.initState(); + _amountFocusNode = FocusNode(); + + // Clear errors when entering recharge wallet page + WidgetsBinding.instance.addPostFrameCallback((_) { + habibWalletVM = context.read(); habibWalletVM.setDepositorDetails(appState.getAuthenticatedUser()!.patientId.toString(), "${appState.getAuthenticatedUser()!.firstName} ${appState.getAuthenticatedUser()!.lastName}", appState.getAuthenticatedUser()!.mobileNumber!); habibWalletVM.setSelectedRechargeType(0); habibWalletVM.getProjectsList(); + + // Clear any previous recharge errors + habibWalletVM.clearAllRechargeErrors(); }); - super.initState(); + } + + @override + void dispose() { + // Clear errors before disposing + habibWalletVM.clearAllRechargeErrors(); + + _amountFocusNode.dispose(); + amountTextController.dispose(); + notesTextController.dispose(); + + super.dispose(); } @override Widget build(BuildContext context) { habibWalletVM = Provider.of(context, listen: false); appState = getIt.get(); - return Scaffold( - backgroundColor: AppColors.bgScaffoldColor, - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: CollapsingListView( - title: LocaleKeys.rechargePageKey.tr(context: context), - child: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.all(24.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 135.h, - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( - color: AppColors.whiteColor, - borderRadius: 24.h, - hasShadow: false, - side: BorderSide(color: AppColors.textColor, width: 2.h), - ), - child: Padding( - padding: EdgeInsets.all(16.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - //TODO: Check with hussain to show AED or SAR - LocaleKeys.amount.tr(context: context).toText14(color: AppColors.greyTextColor, isBold: true), - Spacer(), - Row( - crossAxisAlignment: CrossAxisAlignment.end, + return PopScope( + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (didPop) { + // Clear errors when user navigates back + habibWalletVM.clearAllRechargeErrors(); + } + }, + child: Scaffold( + backgroundColor: AppColors.bgScaffoldColor, + body: GestureDetector( + onTap: () { + // Dismiss the keyboard and unfocus any focused widget when tapping outside + _amountFocusNode.unfocus(); + FocusScope.of(context).unfocus(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: CollapsingListView( + title: LocaleKeys.rechargePageKey.tr(context: context), + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Consumer( + builder: (context, viewModel, child) { + return Container( + height: 135.h, + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.h, + hasShadow: false, + side: BorderSide( + color: viewModel.amountError != null ? AppColors.primaryRedBorderColor : AppColors.textColor, + width: 2.h, + ), + ), + child: Padding( + padding: EdgeInsets.all(16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 150.h, - child: TextInputWidget( - controller: amountTextController, - labelText: "", - hintText: "", - isEnable: true, - prefix: null, - isAllowRadius: true, - isBorderAllowed: false, - isAllowLeadingIcon: true, - autoFocus: true, - fontSize: 25.f, - padding: EdgeInsets.symmetric(horizontal: 8.h, vertical: 0.h), - focusNode: textFocusNode, - isWalletAmountInput: true, - keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true), - fontFamily: "Poppins", - // leadingIcon: AppAssets.student_card, - ), + //TODO: Check with hussain to show AED or SAR + LocaleKeys.amount.tr(context: context).toText14(color: AppColors.greyTextColor, isBold: true), + Spacer(), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox( + width: 150.h, + child: TextInputWidget( + controller: amountTextController, + labelText: "", + hintText: "", + isEnable: true, + prefix: null, + isAllowRadius: true, + isBorderAllowed: false, + isAllowLeadingIcon: true, + autoFocus: true, + fontSize: 25.f, + padding: EdgeInsets.symmetric(horizontal: 8.h, vertical: 0.h), + focusNode: _amountFocusNode, + isWalletAmountInput: true, + keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true), + fontFamily: "Poppins", + onChange: (value) { + // Clear error when user starts typing + viewModel.clearAmountError(); + }, + // leadingIcon: AppAssets.student_card, + ), + ), + const Spacer(), + LocaleKeys.sar.tr(context: context).toText20(color: AppColors.greyTextColor, isBold: true), + ], ), - const Spacer(), - LocaleKeys.sar.tr(context: context).toText20(color: AppColors.greyTextColor, isBold: true), ], ), - ], - ), - ), + ), + ); + }, + ), + // Show error message if exists + Consumer( + builder: (context, viewModel, child) { + if (viewModel.amountError != null) { + return Padding( + padding: EdgeInsets.only(left: 16.w, top: 8.h), + child: Text( + viewModel.amountError!, + style: TextStyle( + color: AppColors.primaryRedColor, + fontSize: 12.f, + ), + ), + ); + } + return SizedBox.shrink(); + }, ), SizedBox(height: 24.h), Consumer(builder: (context, habibWalletVM, child) { return Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + decoration: BoxDecoration( color: AppColors.whiteColor, - borderRadius: 24.h, - hasShadow: false, + borderRadius: BorderRadius.circular(24.h), + border: Border.all( + color: habibWalletVM.hospitalError != null ? AppColors.primaryRedBorderColor : Colors.transparent, + width: 2.h, + ), ), child: Padding( padding: EdgeInsets.all(16.h), @@ -194,6 +254,18 @@ class _RechargeWalletPageState extends State { showCommonBottomSheetWithoutHeight(context, title: LocaleKeys.selectHospital.tr(context: context), isDismissible: false, child: SelectHospitalBottomSheet(), callBackFunc: () {}); }), + // Show hospital error message if exists + if (habibWalletVM.hospitalError != null) + Padding( + padding: EdgeInsets.only(top: 8.h), + child: Text( + habibWalletVM.hospitalError!, + style: TextStyle( + color: AppColors.primaryRedColor, + fontSize: 12.f, + ), + ), + ), SizedBox(height: 16.h), Divider(color: AppColors.borderOnlyColor.withValues(alpha: 0.1), height: 1.h), SizedBox(height: 16.h), @@ -249,27 +321,11 @@ class _RechargeWalletPageState extends State { child: CustomButton( text: LocaleKeys.next.tr(context: context), onPressed: () { - if (amountTextController.text.isEmpty) { - showCommonBottomSheetWithoutHeight( - context, - child: Utils.getErrorWidget(loadingText: LocaleKeys.enterAmount.tr(context: context)), - callBackFunc: () { - textFocusNode.requestFocus(); - }, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } else if (habibWalletVM.selectedHospital == null) { - showCommonBottomSheetWithoutHeight( - context, - child: Utils.getErrorWidget(loadingText: LocaleKeys.selectHospitalForAdvancePayment.tr(context: context)), - callBackFunc: () { - textFocusNode.requestFocus(); - }, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } else { + _amountFocusNode.unfocus(); + FocusScope.of(context).unfocus(); + + // Use ViewModel validation method + if (habibWalletVM.validateRechargeForm(amountTextController.text)) { habibWalletVM.setWalletRechargeAmount(num.parse(amountTextController.text.replaceAll(',', ''))); habibWalletVM.setNotesText(notesTextController.text); // habibWalletVM.setDepositorDetails(appState.getAuthenticatedUser()!.patientId.toString(), "${appState.getAuthenticatedUser()!.firstName} ${appState.getAuthenticatedUser()!.lastName}", @@ -296,6 +352,8 @@ class _RechargeWalletPageState extends State { ), ], ), + ), + ), ); } } diff --git a/lib/presentation/health_trackers/add_health_tracker_entry_page.dart b/lib/presentation/health_trackers/add_health_tracker_entry_page.dart index 9f7f01cf..c448cc97 100644 --- a/lib/presentation/health_trackers/add_health_tracker_entry_page.dart +++ b/lib/presentation/health_trackers/add_health_tracker_entry_page.dart @@ -44,12 +44,27 @@ class _AddHealthTrackerEntryPageState extends State { void initState() { super.initState(); dialogService = getIt.get(); + + // Clear errors when entering the page + WidgetsBinding.instance.addPostFrameCallback((_) { + final viewModel = context.read(); + viewModel.clearAllFieldErrors(); + }); } @override void dispose() { dateController.dispose(); timeController.dispose(); + + // Clear errors when leaving the page + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final viewModel = context.read(); + viewModel.clearAllFieldErrors(); + } + }); + super.dispose(); } @@ -94,10 +109,17 @@ class _AddHealthTrackerEntryPageState extends State { // Save Blood Sugar entry Future _saveBloodSugarEntry(HealthTrackersViewModel viewModel) async { - LoaderBottomSheet.showLoader(loadingText: LocaleKeys.pleaseWait.tr(context: context)); // Combine date and time final dateTime = "${dateController.text} ${timeController.text}"; + // Validate using ViewModel method that sets field errors + if (!viewModel.validateBloodSugarEntry(dateTime)) { + // Validation failed, errors are already set in viewModel + return; + } + + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.pleaseWait.tr(context: context)); + // Call ViewModel method with callbacks await viewModel.saveBloodSugarEntry( dateTime: dateTime, @@ -115,10 +137,17 @@ class _AddHealthTrackerEntryPageState extends State { // Save Weight entry Future _saveWeightEntry(HealthTrackersViewModel viewModel) async { - LoaderBottomSheet.showLoader(loadingText: LocaleKeys.pleaseWait.tr(context: context)); // Combine date and time final dateTime = "${dateController.text} ${timeController.text}"; + // Validate using ViewModel method that sets field errors + if (!viewModel.validateWeightEntry(dateTime)) { + // Validation failed, errors are already set in viewModel + return; + } + + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.pleaseWait.tr(context: context)); + // Call ViewModel method with callbacks await viewModel.saveWeightEntry( dateTime: dateTime, @@ -135,10 +164,17 @@ class _AddHealthTrackerEntryPageState extends State { // Save Blood Pressure entry Future _saveBloodPressureEntry(HealthTrackersViewModel viewModel) async { - LoaderBottomSheet.showLoader(loadingText: LocaleKeys.pleaseWait.tr(context: context)); // Combine date and time final dateTime = "${dateController.text} ${timeController.text}"; + // Validate using ViewModel method that sets field errors + if (!viewModel.validateBloodPressureEntry(dateTime)) { + // Validation failed, errors are already set in viewModel + return; + } + + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.pleaseWait.tr(context: context)); + // Call ViewModel method with callbacks await viewModel.saveBloodPressureEntry( dateTime: dateTime, @@ -286,13 +322,19 @@ class _AddHealthTrackerEntryPageState extends State { } // Reusable method to build text field - Widget _buildTextField(TextEditingController controller, String hintText, {TextInputType keyboardType = TextInputType.name}) { + Widget _buildTextField( + TextEditingController controller, + String hintText, { + TextInputType keyboardType = TextInputType.name, + Function(String)? onChanged, + }) { return TextField( controller: controller, keyboardType: keyboardType, maxLines: 1, cursorHeight: 14.h, textAlignVertical: TextAlignVertical.center, + onChanged: onChanged, decoration: InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.zero, @@ -308,6 +350,23 @@ class _AddHealthTrackerEntryPageState extends State { ); } + // Build error text widget + Widget _buildErrorText(String errorMessage) { + return Padding( + padding: EdgeInsets.only(left: 52.w, top: 4.h, bottom: 4.h), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + errorMessage, + style: TextStyle( + color: AppColors.primaryRedColor, + fontSize: 12.f, + ), + ), + ), + ); + } + // Reusable method to build settings row Widget _buildSettingsRow({ required String icon, @@ -401,133 +460,230 @@ class _AddHealthTrackerEntryPageState extends State { /// Blood Sugar form fields Widget _buildBloodSugarForm(HealthTrackersViewModel viewModel) { - return Column( - children: [ - _buildSettingsRow( - icon: AppAssets.heightIcon, - label: LocaleKeys.enterBloodSugar.tr(context: context), - inputField: _buildTextField(viewModel.bloodSugarController, '', keyboardType: TextInputType.number), - unit: viewModel.selectedBloodSugarUnit, - onUnitTap: () => _showBloodSugarUnitSelectionBottomSheet(context, viewModel), - ), - _buildDateTimeFields(), - Divider(height: 1, color: AppColors.dividerColor), - _buildSettingsRow( - icon: AppAssets.weight_tracker_icon, - label: LocaleKeys.selectMeasureTime.tr(context: context), - value: viewModel.selectedBloodSugarMeasureTime, - onRowTap: () => _showBloodSugarEntryTimeBottomSheet(context, viewModel), - ), - ], + return Selector( + selector: (_, model) => model.hasBloodSugarFormError, + builder: (context, hasError, child) { + return Container( + margin: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h), + padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 4.h, bottom: 4.h), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.r, + hasShadow: true, + side: hasError + ? BorderSide(color: AppColors.primaryRedBorderColor, width: 1) + : null, + ), + child: Column( + children: [ + _buildSettingsRow( + icon: AppAssets.heightIcon, + label: LocaleKeys.enterBloodSugar.tr(context: context), + inputField: _buildTextField( + viewModel.bloodSugarController, + '', + keyboardType: TextInputType.number, + onChanged: (value) => viewModel.clearBloodSugarError(), + ), + unit: viewModel.selectedBloodSugarUnit, + onUnitTap: () => _showBloodSugarUnitSelectionBottomSheet(context, viewModel), + ), + if (viewModel.bloodSugarError != null) + _buildErrorText(viewModel.bloodSugarError!), + _buildDateTimeFields(), + Divider(height: 1, color: AppColors.dividerColor), + _buildSettingsRow( + icon: AppAssets.weight_tracker_icon, + label: LocaleKeys.selectMeasureTime.tr(context: context), + value: viewModel.selectedBloodSugarMeasureTime, + onRowTap: () => _showBloodSugarEntryTimeBottomSheet(context, viewModel), + ), + if (viewModel.bloodSugarMeasureTimeError != null) + _buildErrorText(viewModel.bloodSugarMeasureTimeError!), + ], + ), + ); + }, ); } /// Blood Pressure form fields Widget _buildBloodPressureForm(HealthTrackersViewModel viewModel) { - return Column( - children: [ - _buildSettingsRow( - icon: AppAssets.bloodPressureIcon, - iconColor: AppColors.greyTextColor, - label: LocaleKeys.enterSystolicValue.tr(context: context), - inputField: _buildTextField(viewModel.systolicController, '', keyboardType: TextInputType.number), - ), - _buildSettingsRow( - icon: AppAssets.bloodPressureIcon, - iconColor: AppColors.greyTextColor, - label: LocaleKeys.enterDiastolicValue.tr(context: context), - inputField: _buildTextField(viewModel.diastolicController, '', keyboardType: TextInputType.number), - ), - _buildSettingsRow( - icon: AppAssets.bodyIcon, - iconColor: AppColors.greyTextColor, - label: LocaleKeys.selectArm.tr(context: context), - value: viewModel.selectedMeasuredArmDisplay, - onRowTap: () => _showMeasuredArmSelectionBottomSheet(context, viewModel), - ), - _buildDateTimeFields(), - ], + return Selector( + selector: (_, model) => model.hasBloodPressureFormError, + builder: (context, hasError, child) { + return Container( + margin: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h), + padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 4.h, bottom: 4.h), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.r, + hasShadow: true, + side: hasError + ? BorderSide(color: AppColors.primaryRedBorderColor, width: 1) + : null, + ), + child: Column( + children: [ + _buildSettingsRow( + icon: AppAssets.bloodPressureIcon, + iconColor: AppColors.greyTextColor, + label: LocaleKeys.enterSystolicValue.tr(context: context), + inputField: _buildTextField( + viewModel.systolicController, + '', + keyboardType: TextInputType.number, + onChanged: (value) => viewModel.clearSystolicError(), + ), + ), + if (viewModel.systolicError != null) + _buildErrorText(viewModel.systolicError!), + _buildSettingsRow( + icon: AppAssets.bloodPressureIcon, + iconColor: AppColors.greyTextColor, + label: LocaleKeys.enterDiastolicValue.tr(context: context), + inputField: _buildTextField( + viewModel.diastolicController, + '', + keyboardType: TextInputType.number, + onChanged: (value) => viewModel.clearDiastolicError(), + ), + ), + if (viewModel.diastolicError != null) + _buildErrorText(viewModel.diastolicError!), + _buildSettingsRow( + icon: AppAssets.bodyIcon, + iconColor: AppColors.greyTextColor, + label: LocaleKeys.selectArm.tr(context: context), + value: viewModel.selectedMeasuredArmDisplay, + onRowTap: () { + _showMeasuredArmSelectionBottomSheet(context, viewModel); + viewModel.clearMeasuredArmError(); + }, + ), + if (viewModel.measuredArmError != null) + _buildErrorText(viewModel.measuredArmError!), + _buildDateTimeFields(), + ], + ), + ); + }, ); } /// Weight form fields Widget _buildWeightForm(HealthTrackersViewModel viewModel) { - return Column( - children: [ - _buildSettingsRow( - icon: AppAssets.weightScale, - label: LocaleKeys.enterWeight.tr(context: context), - inputField: _buildTextField(viewModel.weightController, '', keyboardType: TextInputType.number), - unit: viewModel.selectedWeightUnitDisplay, - onUnitTap: () => _showWeightUnitSelectionBottomSheet(context, viewModel), - ), - _buildDateTimeFields(), - ], + return Selector( + selector: (_, model) => model.hasWeightFormError, + builder: (context, hasError, child) { + return Container( + margin: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h), + padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 4.h, bottom: 4.h), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration( + color: AppColors.whiteColor, + borderRadius: 24.r, + hasShadow: true, + side: hasError + ? BorderSide(color: AppColors.primaryRedBorderColor, width: 1) + : null, + ), + child: Column( + children: [ + _buildSettingsRow( + icon: AppAssets.weightScale, + label: LocaleKeys.enterWeight.tr(context: context), + inputField: _buildTextField( + viewModel.weightController, + '', + keyboardType: TextInputType.number, + onChanged: (value) => viewModel.clearWeightError(), + ), + unit: viewModel.selectedWeightUnitDisplay, + onUnitTap: () => _showWeightUnitSelectionBottomSheet(context, viewModel), + ), + if (viewModel.weightError != null) + _buildErrorText(viewModel.weightError!), + _buildDateTimeFields(), + ], + ), + ); + }, ); } /// Common date and time fields Widget _buildDateTimeFields() { - return Column( - children: [ - SizedBox(width: 8.w), - TextInputWidget( - controller: dateController, - isReadOnly: true, - isArrowTrailing: true, - labelText: LocaleKeys.date.tr(context: context), - hintText: LocaleKeys.pickADate.tr(context: context), - focusNode: FocusNode(), - isEnable: true, - prefix: null, - isAllowRadius: true, - isBorderAllowed: false, - isAllowLeadingIcon: true, - padding: EdgeInsets.symmetric(vertical: 8.h), - leadingIcon: AppAssets.calendarGrey, - selectionType: SelectionTypeEnum.calendar, - isHideSwitcher: true, - btnTitle: LocaleKeys.add.tr(context: context), - onCalendarTypeChanged: (val) {}, - onChange: (val) { - if (val == null) return; - try { - final parsedDate = DateTime.parse(val); - final formattedDate = DateFormat('dd MMM yyyy').format(parsedDate); - dateController.text = formattedDate; - log("date: $formattedDate"); - } catch (e) { - dateController.text = val; - log("date: $val"); - } - }, - ), - Divider(height: 1, color: AppColors.dividerColor), - SizedBox(width: 8.w), - TextInputWidget( - controller: timeController, - isReadOnly: true, - isArrowTrailing: true, - labelText: LocaleKeys.time.tr(context: context), - hintText: LocaleKeys.selectMeasureTime.tr(context: context), - focusNode: FocusNode(), - isEnable: true, - prefix: null, - isAllowRadius: true, - isBorderAllowed: false, - isAllowLeadingIcon: true, - padding: EdgeInsets.symmetric(vertical: 8.h), - leadingIcon: AppAssets.calendarGrey, - selectionType: SelectionTypeEnum.time, - isHideSwitcher: true, - onCalendarTypeChanged: (val) {}, - onChange: (val) { - if (val == null) return; - timeController.text = val; - log("time: $val"); - }, - ), - ], + return Consumer( + builder: (context, viewModel, child) { + return Column( + children: [ + SizedBox(width: 8.w), + TextInputWidget( + controller: dateController, + isReadOnly: true, + isArrowTrailing: true, + labelText: LocaleKeys.date.tr(context: context), + hintText: LocaleKeys.pickADate.tr(context: context), + focusNode: FocusNode(), + isEnable: true, + prefix: null, + isAllowRadius: true, + isBorderAllowed: false, + isAllowLeadingIcon: true, + padding: EdgeInsets.symmetric(vertical: 8.h), + leadingIcon: AppAssets.calendarGrey, + selectionType: SelectionTypeEnum.calendar, + isHideSwitcher: true, + btnTitle: LocaleKeys.add.tr(context: context), + onCalendarTypeChanged: (val) {}, + onChange: (val) { + if (val == null) return; + try { + final parsedDate = DateTime.parse(val); + final formattedDate = DateFormat('dd MMM yyyy').format(parsedDate); + dateController.text = formattedDate; + viewModel.clearDateError(); + log("date: $formattedDate"); + } catch (e) { + dateController.text = val; + viewModel.clearDateError(); + log("date: $val"); + } + }, + ), + if (viewModel.dateError != null) + _buildErrorText(viewModel.dateError!), + Divider(height: 1, color: AppColors.dividerColor), + SizedBox(width: 8.w), + TextInputWidget( + controller: timeController, + isReadOnly: true, + isArrowTrailing: true, + labelText: LocaleKeys.time.tr(context: context), + hintText: LocaleKeys.selectMeasureTime.tr(context: context), + focusNode: FocusNode(), + isEnable: true, + prefix: null, + isAllowRadius: true, + isBorderAllowed: false, + isAllowLeadingIcon: true, + padding: EdgeInsets.symmetric(vertical: 8.h), + leadingIcon: AppAssets.calendarGrey, + selectionType: SelectionTypeEnum.time, + isHideSwitcher: true, + onCalendarTypeChanged: (val) {}, + onChange: (val) { + if (val == null) return; + timeController.text = val; + viewModel.clearTimeError(); + log("time: $val"); + }, + ), + if (viewModel.timeError != null) + _buildErrorText(viewModel.timeError!), + ], + ); + }, ); } @@ -555,12 +711,7 @@ class _AddHealthTrackerEntryPageState extends State { ), ), ), - child: Container( - margin: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h), - padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 4.h, bottom: 4.h), - decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 24.r, hasShadow: true), - child: _buildFormFields(viewModel), - ), + child: _buildFormFields(viewModel), ), ), ); diff --git a/lib/presentation/my_family/widget/family_cards.dart b/lib/presentation/my_family/widget/family_cards.dart index b648a23a..f69430f2 100644 --- a/lib/presentation/my_family/widget/family_cards.dart +++ b/lib/presentation/my_family/widget/family_cards.dart @@ -26,6 +26,7 @@ import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; import 'package:hmg_patient_app_new/widgets/chip/app_custom_chip_widget.dart'; import 'package:hmg_patient_app_new/widgets/chip/custom_chip_widget.dart'; import 'package:hmg_patient_app_new/widgets/common_bottom_sheet.dart'; +import 'package:hmg_patient_app_new/widgets/expandable_list_widget.dart'; import 'package:hmg_patient_app_new/widgets/user_avatar_widget.dart'; import 'package:hmg_patient_app_new/widgets/image_picker.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -390,7 +391,6 @@ class _FamilyCardsState extends State { } } - double _calculateAspectRatio(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; final itemWidth = (screenWidth - 32.w - 10.w) / 2; @@ -424,35 +424,49 @@ class _FamilyCardsState extends State { if (widget.isRequestDesign) { return Column( children: [ - Row( - children: [ - Utils.buildSvgWithAssets(icon: AppAssets.alertSquare), - SizedBox(width: 8.h), - LocaleKeys.whoCanViewMyMedicalFile.tr(context: context).toText14(color: AppColors.textColor, isUnderLine: true, isBold: true).onPress(() { - dialogService.showFamilyBottomSheetWithoutHWithChild( - label: LocaleKeys.manageFiles.tr(context: context), - message: "", - child: manageFamily(), - onOkPressed: () {}, - ); - }), - SizedBox(width: 4.h), - Transform.flip( - flipX: getIt.get().isArabic(), - child: Utils.buildSvgWithAssets( - icon: AppAssets.arrowRight, - iconColor: AppColors.blackColor, - width: 22.w, - height: 22.h, - fit: BoxFit.contain, - ) + // Row( + // children: [ + // Utils.buildSvgWithAssets(icon: AppAssets.alertSquare), + // SizedBox(width: 8.h), + // LocaleKeys.whoCanViewMyMedicalFile.tr(context: context).toText14(color: AppColors.textColor, isUnderLine: true, isBold: true).onPress(() { + // dialogService.showFamilyBottomSheetWithoutHWithChild( + // label: LocaleKeys.manageFiles.tr(context: context), + // message: "", + // child: manageFamily(), + // onOkPressed: () {}, + // ); + // }), + // SizedBox(width: 4.h), + // Transform.flip( + // flipX: getIt.get().isArabic(), + // child: Utils.buildSvgWithAssets( + // icon: AppAssets.arrowRight, + // iconColor: AppColors.blackColor, + // width: 22.w, + // height: 22.h, + // fit: BoxFit.contain, + // )), + // ], + // ), + // SizedBox(height: 24.h), + CustomExpandableList( + expansionMode: ExpansionMode.exactlyOne, + dividerColor: AppColors.dividerColor, + itemPadding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 14.h), + items: [ + ExpandableListItem( + title: LocaleKeys.whoCanViewMyMedicalFile.tr(context: context).toText18(isBold: true), + expandedBackgroundColor: Colors.transparent, + children: [ + SizedBox(height: 10.h), + manageFamily() + ], ), - ], - ), - SizedBox(height: 24.h), - widget.profileViewList!.where((profile) => profile.isRequestFromMySide ?? false).isEmpty - ? Utils.getNoDataWidget(context) - : ListView.builder( + ExpandableListItem(title: LocaleKeys.notifications.tr(context: context).toText18(isBold: true), expandedBackgroundColor: Colors.transparent, initiallyExpanded: true, children: [ + SizedBox(height: 10.h), + widget.profileViewList!.where((profile) => profile.isRequestFromMySide ?? false).isEmpty + ? Utils.getNoDataWidget(context) + : ListView.builder( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, @@ -477,10 +491,10 @@ class _FamilyCardsState extends State { backgroundColor: profile.status == FamilyFileEnum.pending.toInt ? AppColors.alertLightColor.withValues(alpha: 0.20) : profile.status == FamilyFileEnum.rejected.toInt - ? AppColors.primaryRedColor.withValues(alpha: 0.20) - : profile.status == FamilyFileEnum.active.toInt - ? AppColors.lightGreenColor - : AppColors.lightGrayBGColor, + ? AppColors.primaryRedColor.withValues(alpha: 0.20) + : profile.status == FamilyFileEnum.active.toInt + ? AppColors.lightGreenColor + : AppColors.lightGrayBGColor, chipText: profile.statusDescription ?? " N/A", iconAsset: null, isShowBorder: false, @@ -488,10 +502,10 @@ class _FamilyCardsState extends State { textColor: profile.status == FamilyFileEnum.pending.toInt ? AppColors.alertLightColor : profile.status == FamilyFileEnum.rejected.toInt - ? AppColors.primaryRedColor - : profile.status == FamilyFileEnum.active.toInt - ? AppColors.textGreenColor - : AppColors.alertColor), + ? AppColors.primaryRedColor + : profile.status == FamilyFileEnum.active.toInt + ? AppColors.textGreenColor + : AppColors.alertColor), SizedBox(height: 8.h), Wrap(alignment: WrapAlignment.start, crossAxisAlignment: WrapCrossAlignment.start, runAlignment: WrapAlignment.start, spacing: 0.h, children: [ (profile.patientName ?? "").toText14(isBold: true, isCenter: false, maxlines: 1), @@ -514,6 +528,12 @@ class _FamilyCardsState extends State { ); }, ), + ]) + ], + theme: ExpandableListTheme.custom( + defaultTrailingIcon: Utils.buildSvgWithAssets(icon: AppAssets.arrow_down, height: 22.h, width: 22.w, iconColor: AppColors.textColor), + ), + ).paddingSymmetrical(0.w, 0.0), SizedBox(height: 20.h), ], ); @@ -525,7 +545,7 @@ class _FamilyCardsState extends State { decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 12.r), child: Padding( padding: EdgeInsets.all(16.w), - child: Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( diff --git a/lib/presentation/profile_settings/widgets/update_email_widget.dart b/lib/presentation/profile_settings/widgets/update_email_widget.dart index c1952dd0..e8e313f4 100644 --- a/lib/presentation/profile_settings/widgets/update_email_widget.dart +++ b/lib/presentation/profile_settings/widgets/update_email_widget.dart @@ -1,8 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; -import 'package:hmg_patient_app_new/core/app_state.dart'; -import 'package:hmg_patient_app_new/core/dependencies.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'; @@ -31,17 +29,34 @@ class _UpdateEmailDialogState extends State { void initState() { _textFieldFocusNode = FocusNode(); textController = TextEditingController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + // Get the view model reference + final viewModel = Provider.of(context, listen: false); + + // Clear any previous email error + viewModel.clearEmailError(); + + // Set the text setState(() { - textController!.text = profileSettingsViewModel!.getPatientInfoForUpdate.emailAddress ?? ""; + textController!.text = viewModel.getPatientInfoForUpdate.emailAddress ?? ""; }); }); + super.initState(); } @override void dispose() { _textFieldFocusNode.dispose(); + + // Clear email error when closing + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + profileSettingsViewModel?.clearEmailError(); + } + }); + super.dispose(); } @@ -58,28 +73,48 @@ class _UpdateEmailDialogState extends State { children: [ LocaleKeys.updateEmailAddressTitle.tr().toText16(textAlign: TextAlign.start, isBold: true), SizedBox(height: 12.h), - TextInputWidget( - labelText: LocaleKeys.email.tr(), - hintText: "demo@gmail.com", - controller: textController, - focusNode: _textFieldFocusNode, - autoFocus: true, - padding: EdgeInsets.all(8.h), - keyboardType: TextInputType.emailAddress, - isEnable: true, - isReadOnly: false, - prefix: null, - isBorderAllowed: false, - isAllowLeadingIcon: true, - fontSize: 14.f, - isCountryDropDown: false, - leadingIcon: AppAssets.email, - fontFamily: "Poppins", + Consumer( + builder: (context, viewModel, child) { + return TextInputWidget( + labelText: LocaleKeys.email.tr(), + hintText: "demo@gmail.com", + controller: textController, + focusNode: _textFieldFocusNode, + autoFocus: true, + padding: EdgeInsets.all(8.h), + keyboardType: TextInputType.emailAddress, + isEnable: true, + isReadOnly: false, + prefix: null, + isBorderAllowed: true, + isAllowLeadingIcon: true, + fontSize: 14.f, + isCountryDropDown: false, + leadingIcon: AppAssets.email, + fontFamily: "Poppins", + hasError: viewModel.emailError != null, + errorMessage: viewModel.emailError, + onChange: (value) { + // Clear error when user starts typing + viewModel.clearEmailError(); + }, + ); + }, ), SizedBox(height: 12.h), CustomButton( text: LocaleKeys.submit.tr(context: context), onPressed: () { + // Unfocus keyboard + _textFieldFocusNode.unfocus(); + FocusScope.of(context).unfocus(); + + // Validate email using ViewModel method + if (!profileSettingsViewModel!.validateEmail(textController!.text)) { + // Validation failed, error is already set in viewModel and displayed below field + return; + } + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.updatingEmailAddress.tr(context: context)); profileSettingsViewModel!.updatePatientInfo( patientInfo: { @@ -92,6 +127,7 @@ class _UpdateEmailDialogState extends State { }, onSuccess: (response) { LoaderBottomSheet.hideLoader(); + profileSettingsViewModel!.clearEmailError(); showCommonBottomSheetWithoutHeight(context, title: LocaleKeys.success.tr(context: context), child: Utils.getSuccessWidget(loadingText: LocaleKeys.success.tr()), callBackFunc: () async { Navigator.of(context).pop(); diff --git a/lib/presentation/profile_settings/widgets/update_emergency_contact_widget.dart b/lib/presentation/profile_settings/widgets/update_emergency_contact_widget.dart index 832938da..b9a81afd 100644 --- a/lib/presentation/profile_settings/widgets/update_emergency_contact_widget.dart +++ b/lib/presentation/profile_settings/widgets/update_emergency_contact_widget.dart @@ -1,8 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; -import 'package:hmg_patient_app_new/core/app_state.dart'; -import 'package:hmg_patient_app_new/core/dependencies.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'; @@ -31,17 +29,34 @@ class _UpdateEmergencyContactDialogState extends State(context, listen: false); + + // Clear any previous emergency contact error + viewModel.clearEmergencyContactError(); + + // Set the text setState(() { - textController!.text = profileSettingsViewModel!.getPatientInfoForUpdate.emergencyContactNo!; + textController!.text = viewModel.getPatientInfoForUpdate.emergencyContactNo ?? ""; }); }); + super.initState(); } @override void dispose() { _textFieldFocusNode.dispose(); + + // Clear emergency contact error when closing + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + profileSettingsViewModel?.clearEmergencyContactError(); + } + }); + super.dispose(); } @@ -58,28 +73,48 @@ class _UpdateEmergencyContactDialogState extends State( + builder: (context, viewModel, child) { + return TextInputWidget( + labelText: LocaleKeys.emrgNo.tr(), + hintText: "05xxxxxxxx", + controller: textController, + focusNode: _textFieldFocusNode, + autoFocus: true, + padding: EdgeInsets.all(8.h), + keyboardType: TextInputType.number, + isEnable: true, + isReadOnly: false, + prefix: null, + isBorderAllowed: true, + isAllowLeadingIcon: true, + fontSize: 14.f, + isCountryDropDown: false, + leadingIcon: AppAssets.call_fill, + fontFamily: "Poppins", + hasError: viewModel.emergencyContactError != null, + errorMessage: viewModel.emergencyContactError, + onChange: (value) { + // Clear error when user starts typing + viewModel.clearEmergencyContactError(); + }, + ); + }, ), SizedBox(height: 12.h), CustomButton( text: LocaleKeys.submit.tr(context: context), onPressed: () { + // Unfocus keyboard + _textFieldFocusNode.unfocus(); + FocusScope.of(context).unfocus(); + + // Validate emergency contact using ViewModel method + if (!profileSettingsViewModel!.validateEmergencyContact(textController!.text)) { + // Validation failed, error is already set in viewModel and displayed below field + return; + } + LoaderBottomSheet.showLoader(loadingText: LocaleKeys.loadingText.tr(context: context)); profileSettingsViewModel!.updatePatientInfo( patientInfo: { @@ -92,6 +127,7 @@ class _UpdateEmergencyContactDialogState extends State { isCountryDropDown: widget.isEnableCountryDropdown, leadingIcon: widget.isForEmail ? AppAssets.email : AppAssets.smart_phone, fontFamily: "Poppins", + hasError: widget.isForEmail ? (widget.emailError != null) : (widget.phoneNumberError != null), + errorMessage: widget.isForEmail ? widget.emailError : widget.phoneNumberError, ) : SizedBox(), ], diff --git a/lib/widgets/family_files/family_file_add_widget.dart b/lib/widgets/family_files/family_file_add_widget.dart index 429b7f4e..bd04eae8 100644 --- a/lib/widgets/family_files/family_file_add_widget.dart +++ b/lib/widgets/family_files/family_file_add_widget.dart @@ -4,11 +4,9 @@ import 'package:hmg_patient_app_new/core/app_assets.dart'; import 'package:hmg_patient_app_new/core/dependencies.dart'; import 'package:hmg_patient_app_new/core/enums.dart'; import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; -import 'package:hmg_patient_app_new/core/utils/validation_utils.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; import 'package:hmg_patient_app_new/features/authentication/authentication_view_model.dart'; -import 'package:hmg_patient_app_new/features/medical_file/medical_file_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:hmg_patient_app_new/widgets/buttons/custom_button.dart'; @@ -27,10 +25,37 @@ class FamilyFileAddWidget extends StatefulWidget { } class _FamilyFileAddWidgetState extends State { + late AuthenticationViewModel _authVm; + + @override + void initState() { + super.initState(); + + // Get ViewModel reference early + _authVm = getIt.get(); + + // Clear errors when family file add widget is opened + WidgetsBinding.instance.addPostFrameCallback((_) { + _authVm.clearNationalIdError(); + _authVm.clearPhoneNumberError(); + }); + } + + @override + void dispose() { + // Clear errors when family file add widget is closed + // Use post frame callback to avoid calling notifyListeners during dispose + WidgetsBinding.instance.addPostFrameCallback((_) { + _authVm.clearNationalIdError(); + _authVm.clearPhoneNumberError(); + }); + + super.dispose(); + } + @override Widget build(BuildContext context) { - AuthenticationViewModel authVm = getIt.get(); - // TODO: implement build + // Use the stored reference instead of getting it here return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, @@ -38,20 +63,32 @@ class _FamilyFileAddWidgetState extends State { children: [ widget.message.toText16(color: AppColors.textColor, isBold: true), SizedBox(height: 20.h), - Container( - decoration: BoxDecoration(color: AppColors.whiteColor, borderRadius: BorderRadius.circular(24)), - padding: EdgeInsets.symmetric(horizontal: 16.h, vertical: 8.h), - child: Column( + Selector( + selector: (_, model) => model.hasIdAndPhoneError, + builder: (context, hasError, child) { + return Container( + decoration: BoxDecoration( + color: AppColors.whiteColor, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: hasError + ? AppColors.primaryRedBorderColor + : Colors.transparent, + width: 1, + ), + ), + padding: EdgeInsets.symmetric(horizontal: 16.h, vertical: 8.h), + child: Column( children: [ CustomCountryDropdown( countryList: CountryEnum.values.where((c) => c != CountryEnum.others).toList(), - onCountryChange: authVm.onCountryChange, + onCountryChange: _authVm.onCountryChange, ).paddingOnly(top: 8.h, bottom: 16.h), Divider(height: 1.h, color: AppColors.spacerLineColor), TextInputWidget( labelText: LocaleKeys.nationalIdNumber.tr(), hintText: "xxxxxxxxx", - controller: authVm.nationalIdController, + controller: _authVm.nationalIdController, isEnable: true, prefix: null, isAllowRadius: true, @@ -62,6 +99,12 @@ class _FamilyFileAddWidgetState extends State { fontFamily: "Poppins", padding: EdgeInsets.symmetric(vertical: 8.h), leadingIcon: AppAssets.student_card, + hasError: false, // Don't show individual field border + errorMessage: _authVm.nationalIdError, // Show error message if exists + onChange: (value) { + // Clear error when user starts typing + _authVm.clearNationalIdError(); + }, ).paddingOnly(top: 8.h, bottom: 8.h), Divider(height: 1.h, color: AppColors.spacerLineColor), Selector( @@ -70,7 +113,7 @@ class _FamilyFileAddWidgetState extends State { return TextInputWidget( labelText: LocaleKeys.phoneNumber.tr(), hintText: "", - controller: authVm.phoneNumberController, + controller: _authVm.phoneNumberController, isEnable: true, prefix: countryCode, isAllowRadius: true, @@ -81,11 +124,19 @@ class _FamilyFileAddWidgetState extends State { fontFamily: "Poppins", padding: EdgeInsets.symmetric(vertical: 8.h), leadingIcon: AppAssets.smart_phone, + hasError: false, // Don't show individual field border + errorMessage: _authVm.phoneNumberError, // Show error message if exists + onChange: (value) { + // Clear error when user starts typing + _authVm.clearPhoneNumberError(); + }, ).paddingOnly(top: 8.h, bottom: 4.h); }, ), ], ), + ); + }, ), SizedBox(height: 20.h), CustomButton( @@ -94,15 +145,9 @@ class _FamilyFileAddWidgetState extends State { // Unfocus all text fields and dismiss keyboard FocusManager.instance.primaryFocus?.unfocus(); - if (ValidationUtils.isValidatedIdAndPhoneWithCountryValidation( - nationalId: authVm.nationalIdController.text, - selectedCountry: authVm.selectedCountrySignup, - phoneNumber: authVm.phoneNumberController.text, - onOkPress: () { - Navigator.of(context).pop(); - }, - )) { - // authVm.addFamilyMember(otpTypeEnum: OTPTypeEnum.sms, isExcludedUser: true); + // Use ViewModel validation method + if (_authVm.validateIdAndPhone()) { + // _authVm.addFamilyMember(otpTypeEnum: OTPTypeEnum.sms, isExcludedUser: true); if (widget.onVerificationPress != null) { widget.onVerificationPress!(); } diff --git a/lib/widgets/input_widget.dart b/lib/widgets/input_widget.dart index 9242cfcd..e77cf96d 100644 --- a/lib/widgets/input_widget.dart +++ b/lib/widgets/input_widget.dart @@ -129,6 +129,20 @@ class TextInputWidget extends StatelessWidget { Widget build(BuildContext context) { AppState appState = getIt.get(); final errorColor = AppColors.primaryRedColor; + + // Determine border: always show red border on error, otherwise respect isBorderAllowed + BorderSide? borderSide; + if (hasError) { + // Always show red border when there's an error + borderSide = BorderSide(color: errorColor, width: 1); + } else if (isBorderAllowed) { + // Show normal border when allowed and no error + borderSide = BorderSide(color: const Color(0xffefefef), width: 1); + } else { + // No border + borderSide = null; + } + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -140,7 +154,7 @@ class TextInputWidget extends StatelessWidget { decoration: RoundedRectangleBorder().toSmoothCornerDecoration( color: AppColors.whiteColor, borderRadius: isAllowRadius ? (12.r) : null, - side: isBorderAllowed ? BorderSide(color: hasError ? errorColor : const Color(0xffefefef), width: 1) : null, + side: borderSide, ), child: Row( textDirection: Directionality.of(context), @@ -190,7 +204,7 @@ class TextInputWidget extends StatelessWidget { ], ), ), - if (hasError && errorMessage != null) + if (errorMessage != null && errorMessage!.isNotEmpty) Padding( padding: EdgeInsets.only(top: 4.h, left: 12.h), // Adjust padding as needed child: Text( @@ -297,10 +311,87 @@ class TextInputWidget extends StatelessWidget { return labelText.toText12(isBold: true, color: labelColor ?? AppColors.inputLabelTextColor); } + // Original _buildTextField - kept as backup reference + // Widget _buildTextFieldOriginal(BuildContext context) { + // double fontS = fontSize ?? 14.f; + // final isArabic = getIt.get().isArabic(); + // + // return Builder( + // builder: (context) { + // return Directionality( + // textDirection: isArabic ? ui.TextDirection.rtl : ui.TextDirection.ltr, + // child: Localizations.override( + // context: context, + // locale: const Locale('en', 'US'), // Force English locale for TextField + // child: TextField( + // hintLocales: const [Locale('en', 'US')], + // enabled: isEnable, + // scrollPadding: EdgeInsets.zero, + // keyboardType: isMultiline ? TextInputType.multiline : (isWalletAmountInput! ? const TextInputType.numberWithOptions(decimal: true) : keyboardType), + // controller: controller, + // readOnly: isReadOnly, + // textAlignVertical: TextAlignVertical.top, + // textAlign: isArabic ? TextAlign.right : TextAlign.left, + // textDirection: ui.TextDirection.ltr, + // onChanged: onChange, + // focusNode: focusNode ?? _focusNode, + // autofocus: autoFocus, + // textInputAction: TextInputAction.done, + // cursorHeight: isWalletAmountInput! ? 40.h : 20.h, + // maxLength: isWalletAmountInput! ? 7 : 100, + // inputFormatters: isWalletAmountInput! + // ? [ + // _ThousandSeparatorInputFormatter(), + // ] + // : null, + // onTapOutside: (event) { + // FocusManager.instance.primaryFocus?.unfocus(); + // }, + // onSubmitted: onSubmitted, + // minLines: isMultiline ? minLines : 1, + // maxLines: isMultiline ? maxLines : 1, + // style: TextStyle( + // fontSize: fontS, + // height: isMultiline ? 1.2 : (isWalletAmountInput! ? 1 / 4 : 0), + // fontWeight: FontWeight.w600, + // color: AppColors.textColor, + // letterSpacing: -1, + // fontFamily: fontFamily, + // locale: const Locale('en', 'US'), // Force English locale for text style + // ), + // decoration: InputDecoration( + // counterText: "", + // isDense: true, + // hintText: hintText, + // hintStyle: TextStyle( + // fontFamily: isArabic ? 'CairoArabic' : 'Poppins', + // fontSize: 14.f, + // height: 21 / 16, + // fontWeight: FontWeight.w600, + // color: hintColor != null ? AppColors.textColor : Color(0xff898A8D), + // letterSpacing: -0.75, + // ), + // prefixIconConstraints: BoxConstraints(minWidth: 30.h), + // prefixIcon: prefix == null ? null : "+${prefix!}".toText14(letterSpacing: -1, color: AppColors.textColor, isBold: true), + // contentPadding: EdgeInsets.only(right: isArabic ? 10.w : 0), + // border: InputBorder.none, + // focusedBorder: InputBorder.none, + // enabledBorder: InputBorder.none, + // ), + // ), + // ), + // ); + // }, + // ); + // } + Widget _buildTextField(BuildContext context) { double fontS = fontSize ?? 14.f; final isArabic = getIt.get().isArabic(); + // Note: Error border is already handled by the parent Container in build() method + // The Container shows red border when hasError is true + return Builder( builder: (context) { return Directionality( @@ -362,6 +453,8 @@ class TextInputWidget extends StatelessWidget { border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, + // Show red border when field has error - but we handle this in the parent Container + // The parent Container in build() already shows red border based on hasError flag ), ), ),