diff --git a/lib/core/utils/calender_utils_new.dart b/lib/core/utils/calender_utils_new.dart index dfb400be..347c2ebc 100644 --- a/lib/core/utils/calender_utils_new.dart +++ b/lib/core/utils/calender_utils_new.dart @@ -25,7 +25,7 @@ class CalenderUtilsNew { Future getCalenders() async { CalendarPermissionStatus result = await DeviceCalendar.instance.hasPermissions(); if (result != CalendarPermissionStatus.granted) { - // await DeviceCalendar.instance.requestPermissions(); + await DeviceCalendar.instance.requestPermissions(); showCommonBottomSheetWithoutHeight( title: LocaleKeys.notice.tr(context: GetIt.instance().navigatorKey.currentContext!), GetIt.instance().navigatorKey.currentContext!, diff --git a/lib/features/health_trackers/health_trackers_view_model.dart b/lib/features/health_trackers/health_trackers_view_model.dart index 8f7e0ffa..8fcbee01 100644 --- a/lib/features/health_trackers/health_trackers_view_model.dart +++ b/lib/features/health_trackers/health_trackers_view_model.dart @@ -2,6 +2,8 @@ import 'dart:developer'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.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/enums.dart'; import 'package:hmg_patient_app_new/features/health_trackers/health_trackers_repo.dart'; import 'package:hmg_patient_app_new/features/health_trackers/models/blood_pressure/blood_pressure_result.dart'; @@ -76,6 +78,10 @@ class HealthTrackersViewModel extends ChangeNotifier { "آخر", ]; + List getBloodSugarTimeList(){ + return getIt.get().isArabic()?bloodSugarMeasureTimeArList:bloodSugarMeasureTimeEnList; + } + // Setters with notification void setBloodSugarMeasureTime(String duration) async { _selectedBloodSugarMeasureTime = duration; diff --git a/lib/presentation/appointments/appointment_details_page.dart b/lib/presentation/appointments/appointment_details_page.dart index 5b012872..40ef4644 100644 --- a/lib/presentation/appointments/appointment_details_page.dart +++ b/lib/presentation/appointments/appointment_details_page.dart @@ -80,8 +80,13 @@ class _AppointmentDetailsPageState extends State { String _countdownText = ""; Function(void Function())? _modalSetState; // Callback for modal state updates + // GlobalKey for BellAnimatedSwitch to control it programmatically + final GlobalKey _bellSwitchKey = GlobalKey(); + @override void initState() { + super.initState(); + scheduleMicrotask(() async { if (AppointmentType.isArrived(widget.patientAppointmentHistoryResponseModel)) { myAppointmentsViewModel.getDoctorsRatingCheck( @@ -102,12 +107,12 @@ class _AppointmentDetailsPageState extends State { } }); - // Initialize countdown timer for payment - if (widget.patientAppointmentHistoryResponseModel.nextAction == 15 || widget.patientAppointmentHistoryResponseModel.nextAction == 20) { - _startCountdownTimer(); - } - - super.initState(); + // Initialize countdown timer for payment after first frame to ensure context is available + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _startCountdownTimer(); + } + }); } /// Checks if a reminder exists in the calendar for this appointment @@ -364,56 +369,51 @@ class _AppointmentDetailsPageState extends State { onRescheduleTap: () async { openDoctorScheduleCalendar(); }, - timerWidget: Column( - children: [ - SizedBox( - height: 16.w, - ), - Divider( - height: 1, - color: AppColors.dividerColor, - ), - SizedBox( - height: 16.w, - ), - Directionality( - textDirection: ui.TextDirection.ltr, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, + timerWidget: (_timeRemaining == null || _timeRemaining == Duration.zero) + ? null + : Column( children: [ - Expanded( - child: _buildTimeUnit( - _timeRemaining != null ? _timeRemaining!.inDays.toString().padLeft(2, '0') : '00', - LocaleKeys.days.tr(context: context), - ), - ), - _buildTimeSeparator(), - Expanded( - child: _buildTimeUnit( - _timeRemaining != null ? _timeRemaining!.inHours.remainder(24).toString().padLeft(2, '0') : '00', - LocaleKeys.hours.tr(context: context), - ), - ), - _buildTimeSeparator(), - Expanded( - child: _buildTimeUnit( - _timeRemaining != null ? _timeRemaining!.inMinutes.remainder(60).toString().padLeft(2, '0') : '00', - LocaleKeys.minutes.tr(context: context), - ), - ), - _buildTimeSeparator(), - Expanded( - child: _buildTimeUnit( - _timeRemaining != null ? _timeRemaining!.inSeconds.remainder(60).toString().padLeft(2, '0') : '00', - LocaleKeys.seconds.tr(context: context), + SizedBox(height: 16.w), + Divider(height: 1, color: AppColors.dividerColor), + SizedBox(height: 16.w), + Directionality( + textDirection: ui.TextDirection.ltr, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildTimeUnit( + _timeRemaining!.inDays.toString().padLeft(2, '0'), + LocaleKeys.days.tr(context: context), + ), + ), + _buildTimeSeparator(), + Expanded( + child: _buildTimeUnit( + _timeRemaining!.inHours.remainder(24).toString().padLeft(2, '0'), + LocaleKeys.hours.tr(context: context), + ), + ), + _buildTimeSeparator(), + Expanded( + child: _buildTimeUnit( + _timeRemaining!.inMinutes.remainder(60).toString().padLeft(2, '0'), + LocaleKeys.minutes.tr(context: context), + ), + ), + _buildTimeSeparator(), + Expanded( + child: _buildTimeUnit( + _timeRemaining!.inSeconds.remainder(60).toString().padLeft(2, '0'), + LocaleKeys.seconds.tr(context: context), + ), + ), + ], ), ), ], ), - ), - ], - ), ), SizedBox(height: 16.h), @@ -603,6 +603,7 @@ class _AppointmentDetailsPageState extends State { ), const Spacer(), BellAnimatedSwitch( + key: _bellSwitchKey, initialValue: widget.patientAppointmentHistoryResponseModel.hasReminder ?? false, activeColor: AppColors.successColor.withOpacity(0.2), inactiveColor: AppColors.lightGrayBGColor, @@ -618,7 +619,9 @@ class _AppointmentDetailsPageState extends State { if (newValue == true) { DateTime startDate = DateTime.now(); DateTime endDate = DateUtil.convertStringToDate(widget.patientAppointmentHistoryResponseModel.appointmentDate); - BottomSheetUtils().showReminderBottomSheet( + + // Show reminder bottom sheet and check if permission was granted + bool permissionGranted = await BottomSheetUtils().showReminderBottomSheet( context, endDate, widget.patientAppointmentHistoryResponseModel.doctorNameObj ?? "", @@ -649,6 +652,11 @@ class _AppointmentDetailsPageState extends State { }); }, ); + + // If permission was not granted, revert the switch back to OFF + if (!permissionGranted) { + _bellSwitchKey.currentState?.setSwitchValue(false); + } } else { isEventAddedOrRemoved = await calender.checkAndRemove( id: "${widget.patientAppointmentHistoryResponseModel.appointmentNo}", @@ -1210,8 +1218,7 @@ class _AppointmentDetailsPageState extends State { handleAppointmentNextAction(widget.patientAppointmentHistoryResponseModel.nextAction); }, backgroundColor: AppointmentType.getNextActionButtonColor(widget.patientAppointmentHistoryResponseModel.nextAction), - borderColor: - AppointmentType.getNextActionButtonColor(widget.patientAppointmentHistoryResponseModel.nextAction).withValues(alpha: 0.01), + borderColor: AppointmentType.getNextActionButtonColor(widget.patientAppointmentHistoryResponseModel.nextAction).withValues(alpha: 0.01), textColor: widget.patientAppointmentHistoryResponseModel.nextAction == 15 ? AppColors.textColor : Colors.white, fontSize: 16.f, fontWeight: FontWeight.w600, @@ -1392,9 +1399,10 @@ class _AppointmentDetailsPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Lottie.asset(AppAnimations.warningAnimation, - repeat: false, reverse: false, frameRate: FrameRate(60), width: 100.h, height: 100.h, fit: BoxFit.fill), - SizedBox(height: 12,), + Lottie.asset(AppAnimations.warningAnimation, repeat: false, reverse: false, frameRate: FrameRate(60), width: 100.h, height: 100.h, fit: BoxFit.fill), + SizedBox( + height: 12, + ), LocaleKeys.upcomingPaymentPending.tr(context: context).toText14(color: AppColors.textColor, isCenter: true), SizedBox(height: 24.h), // // Countdown Timer - DD : HH : MM : SS format with labels - Always LTR diff --git a/lib/presentation/book_appointment/widgets/bell_switch.dart b/lib/presentation/book_appointment/widgets/bell_switch.dart index 498ea6aa..ea3746a0 100644 --- a/lib/presentation/book_appointment/widgets/bell_switch.dart +++ b/lib/presentation/book_appointment/widgets/bell_switch.dart @@ -33,10 +33,10 @@ class BellAnimatedSwitch extends StatefulWidget { }); @override - State createState() => _BellAnimatedSwitchState(); + State createState() => BellAnimatedSwitchState(); } -class _BellAnimatedSwitchState extends State with SingleTickerProviderStateMixin { +class BellAnimatedSwitchState extends State with SingleTickerProviderStateMixin { late bool _isActive; late AnimationController _animationController; late Animation _circleAlignment; @@ -51,11 +51,7 @@ class _BellAnimatedSwitchState extends State with SingleTick vsync: this, ); - // Animation for the circle position (left/right) - _circleAlignment = AlignmentTween( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ).animate( + _circleAlignment = AlignmentTween(begin: Alignment.centerLeft, end: Alignment.centerRight).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), ); @@ -78,6 +74,24 @@ class _BellAnimatedSwitchState extends State with SingleTick super.dispose(); } + /// Public method to set switch value programmatically without triggering onChanged callback + void setSwitchValue(bool value, {bool animate = true}) { + if (_isActive == value) return; // No change needed + + setState(() { + _isActive = value; + if (animate) { + if (_isActive) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + } else { + _animationController.value = _isActive ? 1.0 : 0.0; + } + }); + } + void _toggle() { setState(() { _isActive = !_isActive; @@ -106,17 +120,19 @@ class _BellAnimatedSwitchState extends State with SingleTick Widget _getIcon() { if (_isActive) { - return widget.activeIcon ?? Icon( - Icons.check, - size: 18, - color: widget.activeIconColor ?? Colors.green.shade400, - ); + return widget.activeIcon ?? + Icon( + Icons.check, + size: 18, + color: widget.activeIconColor ?? Colors.green.shade400, + ); } - return widget.inactiveIcon ?? Icon( - Icons.close, - size: 18, - color: widget.inactiveIconColor ?? Colors.grey.shade600, - ); + return widget.inactiveIcon ?? + Icon( + Icons.close, + size: 18, + color: widget.inactiveIconColor ?? Colors.grey.shade600, + ); } @override @@ -162,9 +178,7 @@ class _BellAnimatedSwitchState extends State with SingleTick ), ], ), - child: Center( - child: _getIcon(), - ), + child: Center(child: _getIcon()), ), ), ], diff --git a/lib/presentation/e_referral/widget/e_referral_patient_info.dart b/lib/presentation/e_referral/widget/e_referral_patient_info.dart index cbce6919..efbac168 100644 --- a/lib/presentation/e_referral/widget/e_referral_patient_info.dart +++ b/lib/presentation/e_referral/widget/e_referral_patient_info.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'dart:ui' as ui; import 'package:hmg_patient_app_new/core/app_export.dart'; import 'package:hmg_patient_app_new/core/utils/validation_utils.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; @@ -138,30 +139,33 @@ class PatientInformationStepState extends State { } Widget _buildPatientPhoneField(ReferralFormManager formManager) { - return Focus( - focusNode: _phoneFocusNode, - child: TextInputWidget( - labelText: LocaleKeys.phoneNumber.tr(context: context), - hintText: "5xxxxxxxx", - controller: _phoneController, - padding: const EdgeInsets.all(8), - keyboardType: TextInputType.number, - fontFamily: "Poppins", - onChange: (value) { - formManager.updatePatientPhone(value ?? ''); - }, - onCountryChange: (value) { - formManager.updatePatientCountryEnum(value); - }, - prefix: '966', - isBorderAllowed: false, - isAllowLeadingIcon: true, - fontSize: 13, - isCountryDropDown: true, - leadingIcon: AppAssets.smart_phone, - errorMessage: formManager.errors.patientPhone, - hasError: !ValidationUtils.isNullOrEmpty(formManager.errors.patientPhone) - ).paddingSymmetrical(0, 8), + return Directionality( + textDirection: ui.TextDirection.ltr, + child: Focus( + focusNode: _phoneFocusNode, + child: TextInputWidget( + labelText: LocaleKeys.phoneNumber.tr(context: context), + hintText: "5xxxxxxxx", + controller: _phoneController, + padding: const EdgeInsets.all(8), + keyboardType: TextInputType.number, + fontFamily: "Poppins", + onChange: (value) { + formManager.updatePatientPhone(value ?? ''); + }, + onCountryChange: (value) { + formManager.updatePatientCountryEnum(value); + }, + prefix: '966', + isBorderAllowed: false, + isAllowLeadingIcon: true, + fontSize: 13, + isCountryDropDown: true, + leadingIcon: AppAssets.smart_phone, + errorMessage: formManager.errors.patientPhone, + hasError: !ValidationUtils.isNullOrEmpty(formManager.errors.patientPhone) + ).paddingSymmetrical(0, 8), + ), ); } 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 2d0356c3..0305eebf 100644 --- a/lib/presentation/health_trackers/add_health_tracker_entry_page.dart +++ b/lib/presentation/health_trackers/add_health_tracker_entry_page.dart @@ -251,7 +251,7 @@ class _AddHealthTrackerEntryPageState extends State { _showSelectionBottomSheet( context: context, title: LocaleKeys.selectMeasureTime.tr(context: context), - items: viewModel.bloodSugarMeasureTimeEnList, + items: viewModel.getBloodSugarTimeList(), selectedValue: viewModel.selectedBloodSugarMeasureTime, onSelected: viewModel.setBloodSugarMeasureTime, useUpperCase: false, diff --git a/lib/presentation/profile_settings/profile_settings.dart b/lib/presentation/profile_settings/profile_settings.dart index 750ea8c4..be7ddb54 100644 --- a/lib/presentation/profile_settings/profile_settings.dart +++ b/lib/presentation/profile_settings/profile_settings.dart @@ -450,16 +450,16 @@ class _FamilyCardWidgetState extends State { }); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // Also sync when dependencies change (e.g., after user switch) - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _syncProfileImageFromAppState(); - } - }); - } + // @override + // void didChangeDependencies() { + // super.didChangeDependencies(); + // // Also sync when dependencies change (e.g., after user switch) + // WidgetsBinding.instance.addPostFrameCallback((_) { + // if (mounted) { + // _syncProfileImageFromAppState(); + // } + // }); + // } void _syncProfileImageFromAppState() { try { diff --git a/lib/presentation/profile_settings/widgets/profile_picture_widget.dart b/lib/presentation/profile_settings/widgets/profile_picture_widget.dart index c9c5e7b1..e7abcb12 100644 --- a/lib/presentation/profile_settings/widgets/profile_picture_widget.dart +++ b/lib/presentation/profile_settings/widgets/profile_picture_widget.dart @@ -1,8 +1,10 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:hmg_patient_app_new/core/app_assets.dart'; import 'package:hmg_patient_app_new/core/app_export.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; @@ -29,16 +31,24 @@ class _ProfilePictureWidgetState extends State { final AppState _appState = getIt.get(); File? _selectedImage; int? _currentPatientId; + bool _isInitialLoadTriggered = false; + + /// Cache decoded image bytes to avoid decoding base64 on every rebuild + Uint8List? _cachedImageBytes; + String? _cachedImageDataHash; @override void initState() { super.initState(); _currentPatientId = _appState.getAuthenticatedUser()?.patientId; + // Pre-cache existing image data if available (prevents blink from default → loaded) + _tryCacheExistingImage(); // Use addPostFrameCallback to ensure widget is built before loading WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || _isInitialLoadTriggered) return; + _isInitialLoadTriggered = true; final patientID = _appState.getAuthenticatedUser()?.patientId; @@ -47,16 +57,19 @@ class _ProfilePictureWidgetState extends State { return; } - // Always load fresh data from API - do NOT use AppState cache + // Load fresh data from API print('📥 Loading fresh profile image from API for patient: $patientID'); - _loadProfileImage(forceRefresh: true); + _loadProfileImage(forceRefresh: false); }); } @override void didChangeDependencies() { super.didChangeDependencies(); - _checkAndUpdateUserImage(); + // Only check for user switch, NOT on initial load (initState handles that) + if (_isInitialLoadTriggered) { + _checkAndUpdateUserImage(); + } } @override @@ -65,6 +78,20 @@ class _ProfilePictureWidgetState extends State { _checkAndUpdateUserImage(); } + /// Pre-cache already-loaded image bytes so we don't flash default avatar + void _tryCacheExistingImage() { + final imageData = _appState.getProfileImageData; + if (imageData != null && imageData.isNotEmpty) { + try { + _cachedImageBytes = base64Decode(imageData); + _cachedImageDataHash = '${imageData.length}_${imageData.hashCode}'; + } catch (_) { + _cachedImageBytes = null; + _cachedImageDataHash = null; + } + } + } + void _checkAndUpdateUserImage() { // Check if the authenticated user has changed (family member switch) final currentPatientId = _appState.getAuthenticatedUser()?.patientId; @@ -88,6 +115,10 @@ class _ProfilePictureWidgetState extends State { // Then clear ViewModel cache profileVm.clearProfileImageCache(); + // Clear local decoded bytes cache + _cachedImageBytes = null; + _cachedImageDataHash = null; + // Force rebuild to show default avatar immediately if (mounted) { setState(() { @@ -103,6 +134,7 @@ class _ProfilePictureWidgetState extends State { onSuccess: (data) { print('✅ Profile image loaded successfully for user: $currentPatientId'); if (mounted) { + _tryCacheExistingImage(); setState(() {}); // Force rebuild to show new data } }, @@ -136,7 +168,10 @@ class _ProfilePictureWidgetState extends State { forceRefresh: forceRefresh, onSuccess: (data) { print('✅ Profile image loaded successfully'); - // Image loaded successfully + if (mounted) { + _tryCacheExistingImage(); + setState(() {}); // Rebuild with new cached bytes + } }, onError: (error) { print('❌ Error loading profile image: $error'); @@ -437,6 +472,8 @@ class _ProfilePictureWidgetState extends State { imageData: base64String, onSuccess: (data) { if (mounted) { + // Update cached bytes immediately from the uploaded data + _tryCacheExistingImage(); setState(() { _selectedImage = null; // Clear selected image after successful upload }); @@ -481,80 +518,84 @@ class _ProfilePictureWidgetState extends State { ); } - // // IMPORTANT: Verify the cached image belongs to the current user - // // If _currentPatientId doesn't match currentPatientId, don't show cached image - // if (currentPatientId != _currentPatientId) { - // print('⚠️ Patient ID mismatch - showing default avatar (current: $currentPatientId, cached: $_currentPatientId)'); - // return Image.asset( - // defaultImage, - // width: 136.w, - // height: 136.h, - // ); - // } - - // IMPORTANT: Use ONLY ViewModel data, DO NOT fallback to AppState - // This prevents showing stale cached data when switching users - final String? imageData = profileVm.profileImageData; + // Use cached decoded bytes — update cache if source data changed + final String? imageData = GetIt.instance().getProfileImageData; + final String? currentHash = (imageData != null && imageData.isNotEmpty) + ? '${imageData.length}_${imageData.hashCode}' + : null; - print('🖼️ Building profile image - has data: ${imageData != null && imageData.isNotEmpty}, patient: $currentPatientId (ViewModel ONLY)'); - - // Show uploaded image if available - if (imageData != null && imageData.isNotEmpty) { + // Re-decode only if the underlying data actually changed + if (currentHash != null && currentHash != _cachedImageDataHash) { try { - final bytes = base64Decode(imageData); - return ClipOval( - child: Image.memory( - bytes, - width: 136.w, - height: 136.h, - fit: BoxFit.cover, - ), - ); + _cachedImageBytes = base64Decode(imageData!); + _cachedImageDataHash = currentHash; } catch (e) { print('❌ Error decoding profile image: $e'); - // If decoding fails, show default image - return Image.asset( - defaultImage, + _cachedImageBytes = null; + _cachedImageDataHash = null; + } + } else if (currentHash == null) { + _cachedImageBytes = null; + _cachedImageDataHash = null; + } + + // Show cached decoded image if available + if (_cachedImageBytes != null) { + return ClipOval( + child: Image.memory( + _cachedImageBytes!, + key: ValueKey('profile_$currentPatientId'), width: 136.w, height: 136.h, - ); - } + fit: BoxFit.cover, + gaplessPlayback: true, // Prevents blink during image rebuild + ), + ); } // Show default image (no image data or user has no uploaded image) print('📷 Showing default avatar for user $currentPatientId'); return Image.asset( defaultImage, + key: ValueKey('default_$currentPatientId'), width: 136.w, height: 136.h, + gaplessPlayback: true, ); } @override Widget build(BuildContext context) { - // Check for user change on every build (handles navigation scenarios) - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _checkAndUpdateUserImage(); - } - }); + // Removed addPostFrameCallback from build() — it was causing redundant + // _checkAndUpdateUserImage calls on every single rebuild. + // didChangeDependencies + didUpdateWidget already handle user switches. return Consumer( builder: (context, profileVm, _) { + // If we already have cached bytes, show the image even while "loading" + // to prevent the shimmer→image blink on page open + final bool showShimmer = profileVm.isProfileImageLoading && + _cachedImageBytes == null && + _selectedImage == null; + return Center( child: Stack( children: [ - // Profile Image - profileVm.isProfileImageLoading - ? Container( - width: 136.w, - height: 136.h, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.greyTextColor.withValues(alpha: 0.2), - ), - ).toShimmer2(isShow: true) - : _buildProfileImage(profileVm), + // Profile Image — use AnimatedSwitcher to smooth transition + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: showShimmer + ? Container( + key: const ValueKey('shimmer'), + width: 136.w, + height: 136.h, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.greyTextColor.withValues(alpha: 0.2), + ), + ).toShimmer2(isShow: true) + : _buildProfileImage(profileVm), + ), // Edit button Positioned( diff --git a/lib/widgets/common_bottom_sheet.dart b/lib/widgets/common_bottom_sheet.dart index 267fb5c5..069022d1 100644 --- a/lib/widgets/common_bottom_sheet.dart +++ b/lib/widgets/common_bottom_sheet.dart @@ -17,82 +17,92 @@ import 'package:hmg_patient_app_new/theme/colors.dart'; import 'package:permission_handler/permission_handler.dart'; class BottomSheetUtils { - showReminderBottomSheet(BuildContext context, DateTime dateTime, String doctorName, String eventId, String appoDateFormatted, String appoTimeFormatted, + Future showReminderBottomSheet(BuildContext context, DateTime dateTime, String doctorName, String eventId, String appoDateFormatted, String appoTimeFormatted, {required Function() onSuccess, String? title, String? description, Function(int)? onMultiDateSuccess, bool isMultiAllowed = false}) async { + // Check and request permissions based on platform + bool hasPermission = await _checkAndRequestCalendarPermissions(); + + if (hasPermission) { + // Permission granted, show the reminder selection bottom sheet + await _showReminderBottomSheet(context, dateTime, doctorName, eventId, appoDateFormatted, appoTimeFormatted, + onSuccess: onSuccess, title: title, description: description, onMultiDateSuccess: onMultiDateSuccess, isMultiAllowed: isMultiAllowed); + return true; + } else { + await _showPermissionDeniedDialog(); + return false; + } + } + + /// Checks and requests calendar permissions for both Android and iOS + /// Returns true if all required permissions are granted + Future _checkAndRequestCalendarPermissions() async { if (Platform.isAndroid) { + // Android: Check existing permission first using PermissionService bool hasPermission = await PermissionService.isCalendarPermissionEnabled(); - if (!hasPermission) { - PermissionStatus status = await Permission.calendarFullAccess.request(); - hasPermission = status.isGranted; - } if (hasPermission) { - _showReminderBottomSheet(context, dateTime, doctorName, eventId, appoDateFormatted, appoTimeFormatted, - onSuccess: onSuccess, title: title, description: description, onMultiDateSuccess: onMultiDateSuccess, isMultiAllowed: isMultiAllowed); - } else { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: GetIt.instance().navigatorKey.currentContext!), - GetIt.instance().navigatorKey.currentContext!, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.calendarPermissionAlert.tr(), - isShowActionButtons: true, - onCancelTap: () { - GetIt.instance().pop(); - }, - onConfirmTap: () async { - GetIt.instance().pop(); - openAppSettings(); - }), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); + return true; + } + + // Request calendarFullAccess permission (Android 14+ requires this) + // This internally requests both READ_CALENDAR and WRITE_CALENDAR + PermissionStatus status = await Permission.calendarFullAccess.request(); + + // If calendarFullAccess request didn't show dialog or failed, + // fallback to requesting individual permissions + if (!status.isGranted) { + Map statuses = await [ + Permission.calendarWriteOnly, + ].request(); + + return statuses[Permission.calendarWriteOnly]?.isGranted ?? false; } + + return status.isGranted; } else { - if (await Permission.calendarWriteOnly.request().isGranted) { - if (await Permission.calendarFullAccess.request().isGranted) { - _showReminderBottomSheet(context, dateTime, doctorName, eventId, appoDateFormatted, appoTimeFormatted, - onSuccess: onSuccess, title: title, description: description, onMultiDateSuccess: onMultiDateSuccess, isMultiAllowed: isMultiAllowed); - } else { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: GetIt.instance().navigatorKey.currentContext!), - GetIt.instance().navigatorKey.currentContext!, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.calendarPermissionAlert.tr(), - isShowActionButtons: true, - onCancelTap: () { - GetIt.instance().pop(); - }, - onConfirmTap: () async { - GetIt.instance().pop(); - openAppSettings(); - }), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); - } - } else { - showCommonBottomSheetWithoutHeight( - title: LocaleKeys.notice.tr(context: GetIt.instance().navigatorKey.currentContext!), - GetIt.instance().navigatorKey.currentContext!, - child: Utils.getWarningWidget( - loadingText: LocaleKeys.calendarPermissionAlert.tr(), - isShowActionButtons: true, - onCancelTap: () { - GetIt.instance().pop(); - }, - onConfirmTap: () async { - GetIt.instance().pop(); - openAppSettings(); - }), - callBackFunc: () {}, - isFullScreen: false, - isCloseButtonVisible: true, - ); + // iOS: Check if we already have full access + PermissionStatus fullAccessStatus = await Permission.calendarFullAccess.status; + + if (fullAccessStatus.isGranted) { + return true; + } + + // Request write permission first (required before full access on iOS) + PermissionStatus writeStatus = await Permission.calendarWriteOnly.request(); + if (!writeStatus.isGranted) { + return false; } + + // Then request full access permission + PermissionStatus fullStatus = await Permission.calendarFullAccess.request(); + return fullStatus.isGranted; } } + /// Shows permission denied dialog with option to open settings + Future _showPermissionDeniedDialog() async { + BuildContext? context = GetIt.instance().navigatorKey.currentContext; + if (context == null) return; + + showCommonBottomSheetWithoutHeight( + context, + title: LocaleKeys.notice.tr(context: context), + child: Utils.getWarningWidget( + loadingText: LocaleKeys.calendarPermissionAlert.tr(), + isShowActionButtons: true, + onCancelTap: () { + GetIt.instance().pop(); + }, + onConfirmTap: () async { + GetIt.instance().pop(); + await openAppSettings(); + }, + ), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + } + Future _showReminderBottomSheet(BuildContext providedContext, DateTime dateTime, String doctorName, String eventId, String appoDateFormatted, String appoTimeFormatted, {required Function onSuccess, String? title, String? description, Function(int)? onMultiDateSuccess, bool? isMultiAllowed}) async { showCommonBottomSheetWithoutHeight(providedContext, title: LocaleKeys.setTimerOfReminder.tr(context: providedContext), child: PrescriptionReminderView(