|
|
|
|
@ -0,0 +1,400 @@
|
|
|
|
|
import 'dart:ui' as ui;
|
|
|
|
|
|
|
|
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
|
|
|
import 'package:flutter/material.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/date_util.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/features/book_appointments/book_appointments_view_model.dart';
|
|
|
|
|
import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/doctor_schedule_response_model.dart';
|
|
|
|
|
import 'package:hmg_patient_app_new/extensions/string_extensions.dart';
|
|
|
|
|
import 'package:hmg_patient_app_new/generated/locale_keys.g.dart';
|
|
|
|
|
import 'package:hmg_patient_app_new/services/navigation_service.dart';
|
|
|
|
|
import 'package:hmg_patient_app_new/theme/colors.dart';
|
|
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
|
|
|
|
|
|
class DoctorScheduleBottomSheet extends StatefulWidget {
|
|
|
|
|
const DoctorScheduleBottomSheet({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<DoctorScheduleBottomSheet> createState() => _DoctorScheduleBottomSheetState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _DoctorScheduleBottomSheetState extends State<DoctorScheduleBottomSheet>
|
|
|
|
|
with SingleTickerProviderStateMixin {
|
|
|
|
|
late PageController _pageController;
|
|
|
|
|
late AnimationController _fadeController;
|
|
|
|
|
late Animation<double> _fadeAnimation;
|
|
|
|
|
|
|
|
|
|
double currentPage = 0;
|
|
|
|
|
List<List<Map<String, dynamic>>> weeks = [];
|
|
|
|
|
List<DoctorScheduleResponseModel> doctorScheduleResponse = [];
|
|
|
|
|
List<String> freeSlots = [];
|
|
|
|
|
|
|
|
|
|
/// True while weeks are being computed off the build thread
|
|
|
|
|
bool _isInitializing = false;
|
|
|
|
|
|
|
|
|
|
late BookAppointmentsViewModel bookAppointmentsViewModel;
|
|
|
|
|
late AppState appState;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_pageController = PageController();
|
|
|
|
|
_fadeController = AnimationController(
|
|
|
|
|
vsync: this,
|
|
|
|
|
duration: const Duration(milliseconds: 400),
|
|
|
|
|
);
|
|
|
|
|
_fadeAnimation = CurvedAnimation(parent: _fadeController, curve: Curves.easeIn);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_pageController.dispose();
|
|
|
|
|
_fadeController.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
bookAppointmentsViewModel = Provider.of<BookAppointmentsViewModel>(context, listen: true);
|
|
|
|
|
appState = getIt.get<AppState>();
|
|
|
|
|
final isArabic = appState.isArabic();
|
|
|
|
|
|
|
|
|
|
// Trigger initialization after the first frame so the UI never freezes
|
|
|
|
|
final responseReady = bookAppointmentsViewModel.doctorScheduleResponse != null;
|
|
|
|
|
if (responseReady && weeks.isEmpty && !_isInitializing) {
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _triggerInit());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── API loading ──────────────────────────────────────────────────────────
|
|
|
|
|
if (bookAppointmentsViewModel.isDoctorScheduleLoading || _isInitializing) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
color: AppColors.primaryRedColor,
|
|
|
|
|
strokeWidth: 2.5,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── No data ──────────────────────────────────────────────────────────────
|
|
|
|
|
if (weeks.isEmpty) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Utils.getNoDataWidget(
|
|
|
|
|
context,
|
|
|
|
|
noDataText: LocaleKeys.noScheduleAvailable.tr(context: context),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Schedule ready — fade in ─────────────────────────────────────────────
|
|
|
|
|
return FadeTransition(
|
|
|
|
|
opacity: _fadeAnimation,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
_buildHeader(context, isArabic),
|
|
|
|
|
SizedBox(height: 20.h),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: PageView.builder(
|
|
|
|
|
controller: _pageController,
|
|
|
|
|
itemCount: weeks.length,
|
|
|
|
|
onPageChanged: (index) => setState(() => currentPage = index.toDouble()),
|
|
|
|
|
itemBuilder: (context, weekIndex) => _buildWeekView(weekIndex, isArabic),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Initialization ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
void _triggerInit() {
|
|
|
|
|
if (!mounted || _isInitializing || weeks.isNotEmpty) return;
|
|
|
|
|
setState(() => _isInitializing = true);
|
|
|
|
|
|
|
|
|
|
// Defer heavy work to the next microtask so the spinner is painted first
|
|
|
|
|
Future.microtask(() {
|
|
|
|
|
_buildWeeksFromResponse();
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() => _isInitializing = false);
|
|
|
|
|
_fadeController.forward();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _buildWeeksFromResponse() {
|
|
|
|
|
final response = bookAppointmentsViewModel.doctorScheduleResponse;
|
|
|
|
|
if (response == null) return;
|
|
|
|
|
|
|
|
|
|
final scheduleList = response['List_DoctorWorkingHoursTable'] as List?;
|
|
|
|
|
if (scheduleList == null || scheduleList.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
doctorScheduleResponse =
|
|
|
|
|
scheduleList.map((item) => DoctorScheduleResponseModel.fromJson(item)).toList();
|
|
|
|
|
|
|
|
|
|
// Resolve free-slot dates
|
|
|
|
|
if (bookAppointmentsViewModel.doctorFreeSlotDates.isNotEmpty) {
|
|
|
|
|
freeSlots = bookAppointmentsViewModel.doctorFreeSlotDates;
|
|
|
|
|
} else if (bookAppointmentsViewModel.freeSlotsResponse != null &&
|
|
|
|
|
bookAppointmentsViewModel.freeSlotsResponse.isNotEmpty) {
|
|
|
|
|
freeSlots = List<String>.from(bookAppointmentsViewModel.freeSlotsResponse);
|
|
|
|
|
} else if (response['FreeTimeSlots'] != null) {
|
|
|
|
|
freeSlots = List<String>.from(response['FreeTimeSlots']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_generateWeeksSchedules();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Splits ALL API days into 7-day chunks — one chunk per page.
|
|
|
|
|
/// Supports full 3-month schedules (~13 weeks) returned by the API.
|
|
|
|
|
void _generateWeeksSchedules() {
|
|
|
|
|
weeks.clear();
|
|
|
|
|
|
|
|
|
|
// Convert every API day into a flat map and mark free slots
|
|
|
|
|
final allDays = doctorScheduleResponse.map((workDay) {
|
|
|
|
|
return <String, dynamic>{
|
|
|
|
|
'Date': workDay.date ?? '',
|
|
|
|
|
'DayName': workDay.dayName ?? '',
|
|
|
|
|
'WorkingHours': workDay.workingHours ?? '',
|
|
|
|
|
'fullDay': !_hasFreeSlot(workDay.date ?? ''),
|
|
|
|
|
};
|
|
|
|
|
}).toList();
|
|
|
|
|
|
|
|
|
|
// Slice into pages of 7 days each
|
|
|
|
|
const daysPerPage = 7;
|
|
|
|
|
for (var i = 0; i < allDays.length; i += daysPerPage) {
|
|
|
|
|
final end = (i + daysPerPage).clamp(0, allDays.length);
|
|
|
|
|
weeks.add(allDays.sublist(i, end));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Header ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
Widget _buildHeader(BuildContext context, bool isArabic) {
|
|
|
|
|
final currentWeek = weeks[currentPage.round()];
|
|
|
|
|
final firstDate = DateUtil.convertStringToDate(currentWeek.first['Date'] as String);
|
|
|
|
|
final lastDate = DateUtil.convertStringToDate(currentWeek.last['Date'] as String);
|
|
|
|
|
|
|
|
|
|
final dateRangeLabel = isArabic
|
|
|
|
|
? '${firstDate.day} ${DateUtil.getMonthArabic(firstDate.month).trim()} – '
|
|
|
|
|
'${lastDate.day} ${DateUtil.getMonthArabic(lastDate.month).trim()}, ${lastDate.year}'
|
|
|
|
|
: '${DateFormat('MMM dd', 'en_US').format(firstDate)} – '
|
|
|
|
|
'${DateFormat('MMM dd', 'en_US').format(lastDate)}, ${lastDate.year}';
|
|
|
|
|
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 5.w),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
// Text(
|
|
|
|
|
// "Doctor Schedule",
|
|
|
|
|
// style: TextStyle(
|
|
|
|
|
// fontSize: 24.h,
|
|
|
|
|
// fontWeight: FontWeight.bold,
|
|
|
|
|
// color: const Color(0xFF1A1A1A),
|
|
|
|
|
// ),
|
|
|
|
|
// ),
|
|
|
|
|
SizedBox(height: 16.h),
|
|
|
|
|
|
|
|
|
|
// Navigation: < date-pill >
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
|
|
|
children: [
|
|
|
|
|
_buildNavArrow(
|
|
|
|
|
icon: Icons.chevron_left,
|
|
|
|
|
enabled: currentPage.round() > 0,
|
|
|
|
|
onTap: () => _animateToPage(currentPage.round() - 1),
|
|
|
|
|
),
|
|
|
|
|
SizedBox(width: 12.w),
|
|
|
|
|
Container(
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppColors.secondaryLightRedColor,
|
|
|
|
|
borderRadius: BorderRadius.circular(12.r),
|
|
|
|
|
),
|
|
|
|
|
child: dateRangeLabel.toText14(
|
|
|
|
|
weight: FontWeight.w600,
|
|
|
|
|
color: AppColors.primaryRedColor,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
SizedBox(width: 12.w),
|
|
|
|
|
_buildNavArrow(
|
|
|
|
|
icon: Icons.chevron_right,
|
|
|
|
|
enabled: currentPage.round() < weeks.length - 1,
|
|
|
|
|
onTap: () => _animateToPage(currentPage.round() + 1),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
SizedBox(height: 20.h),
|
|
|
|
|
|
|
|
|
|
LocaleKeys.weeklyShift.tr(context: context).toText18(
|
|
|
|
|
weight: FontWeight.w600,
|
|
|
|
|
color: AppColors.textColor,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _animateToPage(int index) {
|
|
|
|
|
_pageController.animateToPage(
|
|
|
|
|
index.clamp(0, weeks.length - 1),
|
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
|
curve: Curves.easeInOut,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildNavArrow({
|
|
|
|
|
required IconData icon,
|
|
|
|
|
required bool enabled,
|
|
|
|
|
required VoidCallback onTap,
|
|
|
|
|
}) {
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: enabled ? onTap : null,
|
|
|
|
|
child: Container(
|
|
|
|
|
width: 34.w,
|
|
|
|
|
height: 34.w,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
|
|
|
|
borderRadius: BorderRadius.circular(8.h),
|
|
|
|
|
color: enabled ? AppColors.secondaryLightRedColor : AppColors.greyF7Color,
|
|
|
|
|
),
|
|
|
|
|
child: Icon(
|
|
|
|
|
icon,
|
|
|
|
|
size: 28.h,
|
|
|
|
|
color: enabled ? AppColors.primaryRedColor : AppColors.checkBoxBorderColor,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Week / day views ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
Widget _buildWeekView(int weekIndex, bool isArabic) {
|
|
|
|
|
final weekSchedule = weeks[weekIndex];
|
|
|
|
|
return Directionality(
|
|
|
|
|
textDirection: isArabic ? ui.TextDirection.rtl : ui.TextDirection.ltr,
|
|
|
|
|
child: ListView.builder(
|
|
|
|
|
// primary: false detaches this ListView from the PrimaryScrollController
|
|
|
|
|
// so it doesn't fight with CollapsingListView's outer CustomScrollView.
|
|
|
|
|
primary: false,
|
|
|
|
|
physics: const ClampingScrollPhysics(),
|
|
|
|
|
padding: EdgeInsets.only(top: 8.h, bottom: 24.h),
|
|
|
|
|
itemCount: weekSchedule.length,
|
|
|
|
|
itemBuilder: (context, dayIndex) => _buildDayCard(context, weekSchedule[dayIndex]),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildDayCard(BuildContext context, Map<String, dynamic> daySchedule) {
|
|
|
|
|
final date = DateUtil.convertStringToDate(daySchedule['Date'] as String);
|
|
|
|
|
final hasFreeSlots = !(daySchedule['fullDay'] as bool? ?? true);
|
|
|
|
|
final workingHours = (daySchedule['WorkingHours'] as String?) ?? '';
|
|
|
|
|
final isOff = workingHours.isEmpty ||
|
|
|
|
|
workingHours.toLowerCase().contains('off') ||
|
|
|
|
|
workingHours.toLowerCase().contains('not available');
|
|
|
|
|
|
|
|
|
|
final shifts = workingHours.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
|
|
|
|
|
|
|
|
|
return Material(
|
|
|
|
|
color: Colors.transparent,
|
|
|
|
|
child: InkWell(
|
|
|
|
|
onTap: hasFreeSlots ? () => getIt.get<NavigationService>().pop() : null,
|
|
|
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
|
|
|
child: Container(
|
|
|
|
|
margin: EdgeInsets.only(bottom: 12.h, left: 5.w, right: 5.w),
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 16.h),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppColors.whiteColor,
|
|
|
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
blurRadius: 16,
|
|
|
|
|
spreadRadius: 0,
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.02),
|
|
|
|
|
offset: const Offset(0, 6),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
// Left — day name + short date
|
|
|
|
|
Expanded(
|
|
|
|
|
flex: 2,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
daySchedule['DayName'].toString().toText16(
|
|
|
|
|
weight: FontWeight.w700,
|
|
|
|
|
color: AppColors.textColor,
|
|
|
|
|
),
|
|
|
|
|
SizedBox(height: 4.h),
|
|
|
|
|
_getDayMonth(date).toText13(
|
|
|
|
|
color: AppColors.greyTextColorLight,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// Right — time slots or "Off"
|
|
|
|
|
if (isOff)
|
|
|
|
|
LocaleKeys.dayOff.tr(context: context).toText14(
|
|
|
|
|
weight: FontWeight.w500,
|
|
|
|
|
color: AppColors.greyTextColorLight,
|
|
|
|
|
)
|
|
|
|
|
else
|
|
|
|
|
Expanded(
|
|
|
|
|
flex: 3,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
|
|
children: [
|
|
|
|
|
for (var i = 0; i < shifts.length; i++) ...[
|
|
|
|
|
shifts[i].toText14(
|
|
|
|
|
weight: FontWeight.w500,
|
|
|
|
|
color: AppColors.textColor,
|
|
|
|
|
),
|
|
|
|
|
if (i < shifts.length - 1) SizedBox(height: 6.h),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
bool _hasFreeSlot(String jsonDate) {
|
|
|
|
|
if (jsonDate.isEmpty) return false;
|
|
|
|
|
final date = DateUtil.convertStringToDate(jsonDate);
|
|
|
|
|
final scheduleDay = DateTime(date.year, date.month, date.day);
|
|
|
|
|
|
|
|
|
|
for (final event in freeSlots) {
|
|
|
|
|
try {
|
|
|
|
|
final eventDate = DateUtil.convertStringToDate(event);
|
|
|
|
|
final nDate = DateTime(eventDate.year, eventDate.month, eventDate.day);
|
|
|
|
|
if (nDate.millisecondsSinceEpoch == scheduleDay.millisecondsSinceEpoch) return true;
|
|
|
|
|
} catch (_) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _getDayMonth(DateTime dateTime) {
|
|
|
|
|
final isArabic = appState.isArabic();
|
|
|
|
|
if (isArabic) {
|
|
|
|
|
return '${dateTime.day} ${DateUtil.getMonthArabic(dateTime.month)}';
|
|
|
|
|
}
|
|
|
|
|
return '${dateTime.day} ${DateUtil.getMonth(dateTime.month).substring(0, 3)}';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|