From 19365375f610de991decc1e205f12a9a3e2dce15 Mon Sep 17 00:00:00 2001 From: aamir-csol Date: Mon, 8 Dec 2025 10:11:13 +0300 Subject: [PATCH] medical file changes --- .../medical_file/medical_file_page.dart | 223 ++++++++----- .../medical_file/widgets/lab_rad_card.dart | 73 +++-- lib/widgets/expandable_list_widget.dart | 301 ++++++++++++++++++ 3 files changed, 478 insertions(+), 119 deletions(-) create mode 100644 lib/widgets/expandable_list_widget.dart diff --git a/lib/presentation/medical_file/medical_file_page.dart b/lib/presentation/medical_file/medical_file_page.dart index 883ca37..e1d4ce7 100644 --- a/lib/presentation/medical_file/medical_file_page.dart +++ b/lib/presentation/medical_file/medical_file_page.dart @@ -53,6 +53,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/common_bottom_sheet.dart'; import 'package:hmg_patient_app_new/widgets/custom_tab_bar.dart'; +import 'package:hmg_patient_app_new/widgets/expandable_list_widget.dart'; import 'package:hmg_patient_app_new/widgets/input_widget.dart'; import 'package:hmg_patient_app_new/widgets/loader/bottomsheet_loader.dart'; import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart'; @@ -148,20 +149,6 @@ class _MedicalFilePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(height: 16.h), - TextInputWidget( - labelText: LocaleKeys.search.tr(context: context), - hintText: "Type any record".needTranslation, - controller: TextEditingController(), - keyboardType: TextInputType.number, - isEnable: true, - prefix: null, - autoFocus: false, - isBorderAllowed: false, - isAllowLeadingIcon: true, - padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 8.h), - leadingIcon: AppAssets.student_card, - ).paddingSymmetrical(24.w, 0.0), SizedBox(height: 16.h), Container( width: double.infinity, @@ -236,8 +223,7 @@ class _MedicalFilePageState extends State { iconColor: insuranceVM.isInsuranceExpired ? AppColors.primaryRedColor : AppColors.successColor, textColor: insuranceVM.isInsuranceExpired ? AppColors.primaryRedColor : AppColors.successColor, iconSize: 12.w, - backgroundColor: - insuranceVM.isInsuranceExpired ? AppColors.primaryRedColor.withOpacity(0.1) : AppColors.successColor.withOpacity(0.1), + backgroundColor: insuranceVM.isInsuranceExpired ? AppColors.primaryRedColor.withOpacity(0.1) : AppColors.successColor.withOpacity(0.1), labelPadding: EdgeInsetsDirectional.only(end: 8.w), ); }), @@ -248,27 +234,108 @@ class _MedicalFilePageState extends State { ), ).paddingSymmetrical(24.w, 0.0), SizedBox(height: 16.h), - Consumer(builder: (context, medicalFileVM, child) { - return Column( - children: [ - CustomTabBar( - activeTextColor: AppColors.primaryRedColor, - activeBackgroundColor: AppColors.primaryRedColor.withValues(alpha: .1), - tabs: [ - CustomTabBarModel(AppAssets.myFilesBottom, LocaleKeys.general.tr(context: context).needTranslation), - CustomTabBarModel(AppAssets.insurance, LocaleKeys.insurance.tr(context: context)), - CustomTabBarModel(AppAssets.requests, LocaleKeys.request.tr(context: context).needTranslation), - CustomTabBarModel(AppAssets.more, "More".needTranslation), - ], - onTabChange: (index) { - medicalFileVM.onTabChanged(index); - }, - ).paddingSymmetrical(24.w, 0.0), - SizedBox(height: 24.h), - getSelectedTabData(medicalFileVM.selectedTabIndex), - ], - ); - }), + TextInputWidget( + labelText: LocaleKeys.search.tr(context: context), + hintText: "Type any record".needTranslation, + controller: TextEditingController(), + keyboardType: TextInputType.number, + isEnable: true, + prefix: null, + autoFocus: false, + isBorderAllowed: false, + isAllowLeadingIcon: true, + padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 8.h), + leadingIcon: AppAssets.student_card, + ).paddingSymmetrical(24.w, 0.0), + SizedBox(height: 16.h), + // Using CustomExpandableList + CustomExpandableList( + expansionMode: ExpansionMode.exactlyOne, + dividerColor: Color(0xFF2B353E1A), + + items: [ + ExpandableListItem( + title: "Medical Services".toText20(weight: FontWeight.w500), + children: [ + SizedBox(height: 10.h), + getSelectedTabData(0), + ], + ), + ExpandableListItem( + title: "Medical Reports".toText20( + weight: FontWeight.w500, + ), + children: [ + SizedBox(height: 10.h), + getSelectedTabData(2), + ]), + ExpandableListItem( + title: "Insurance & Payments".toText20(weight: FontWeight.w500), + children: [ + SizedBox(height: 10.h), + getSelectedTabData(1), + ], + ), + ExpandableListItem( + title: "Tracker & Others".toText20(weight: FontWeight.w500), + children: [ + Text("Blood Report"), + SizedBox(height: 8), + Text("X-Ray Report"), + ], + ), + ], + theme: ExpandableListTheme.custom( + defaultTrailingIcon: Utils.buildSvgWithAssets(icon: AppAssets.arrow_down, height: 22.h, width: 22.w, iconColor: AppColors.textColor), + ), + ).paddingSymmetrical(16.w, 0.0), + + // ExpansionTileList( + // shrinkWrap: true, + // trailing: Icon(Icons.arrow_drop_down), + // expansionMode: ExpansionMode.exactlyOne, + // children: [ + // ExpansionTile( + // title: "Medical Services".toText20(weight: FontWeight.w600, color: AppColors.textColor), + // iconColor: Color(0xFF2E3039), + // trailing: Utils.buildSvgWithAssets(icon: AppAssets.arrow_down, height: 22.h, width: 22.w), + // children: [Text('Child 1')], + // ), + // ExpansionTile( + // title: "Medical Reports".toText20(weight: FontWeight.w600, color: AppColors.textColor), + // children: [Text('Child 2')], + // ), + // ExpansionTile( + // title: "Insurance & Payments".toText20(weight: FontWeight.w600, color: AppColors.textColor), + // children: [Text('Child 3')], + // ), + // ExpansionTile( + // title: "Tracker & Others".toText20(weight: FontWeight.w600, color: AppColors.textColor), + // children: [Text('Child 4')], + // ), + // ], + // ), + // Consumer(builder: (context, medicalFileVM, child) { + // return Column( + // children: [ + // CustomTabBar( + // activeTextColor: AppColors.primaryRedColor, + // activeBackgroundColor: AppColors.primaryRedColor.withValues(alpha: .1), + // tabs: [ + // CustomTabBarModel(AppAssets.myFilesBottom, LocaleKeys.general.tr(context: context).needTranslation), + // CustomTabBarModel(AppAssets.insurance, LocaleKeys.insurance.tr(context: context)), + // CustomTabBarModel(AppAssets.requests, LocaleKeys.request.tr(context: context).needTranslation), + // CustomTabBarModel(AppAssets.more, "More".needTranslation), + // ], + // onTabChange: (index) { + // medicalFileVM.onTabChanged(index); + // }, + // ).paddingSymmetrical(24.w, 0.0), + // SizedBox(height: 24.h), + // getSelectedTabData(medicalFileVM.selectedTabIndex), + // ], + // ); + // }), ], ), ); @@ -324,7 +391,7 @@ class _MedicalFilePageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - "Appointments & visits".needTranslation.toText18(isBold: true), + "Appointments & visits".needTranslation.toText16(weight: FontWeight.w500, letterSpacing: -0.2), Row( children: [ LocaleKeys.viewAll.tr().toText12(color: AppColors.primaryRedColor, fontWeight: FontWeight.w500), @@ -333,7 +400,7 @@ class _MedicalFilePageState extends State { ], ), ], - ).paddingSymmetrical(24.w, 0.h).onPress(() { + ).paddingSymmetrical(0.w, 0.h).onPress(() { Navigator.of(context).push( CustomPageRoute( page: MyAppointmentsPage(), @@ -356,8 +423,7 @@ class _MedicalFilePageState extends State { ? Container( padding: EdgeInsets.all(12.w), width: MediaQuery.of(context).size.width, - decoration: - RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 12.r, hasShadow: true), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 12.r, hasShadow: true), child: Column( children: [ Utils.buildSvgWithAssets(icon: AppAssets.home_calendar_icon, width: 32.h, height: 32.h), @@ -415,10 +481,10 @@ class _MedicalFilePageState extends State { }, separatorBuilder: (BuildContext cxt, int index) => SizedBox(width: 12.h), ), - ).paddingSymmetrical(24.w, 0); + ).paddingSymmetrical(0.w, 0); }), SizedBox(height: 24.h), - "Lab & Radiology".needTranslation.toText18(isBold: true).paddingSymmetrical(24.w, 0.h), + "Lab & Radiology".needTranslation.toText16(weight: FontWeight.w500, letterSpacing: -0.2), SizedBox(height: 16.h), Row( children: [ @@ -437,11 +503,12 @@ class _MedicalFilePageState extends State { ); }), ), - SizedBox(width: 16.h), + SizedBox(width: 8.w), Expanded( child: LabRadCard( icon: AppAssets.radiology_icon, - labelText: LocaleKeys.radiology.tr(context: context), + labelText: "${LocaleKeys.radiology.tr(context: context)} Results".needTranslation, + // labOrderTests: ["Complete blood count", "Creatinine", "Blood Sugar", // labOrderTests: ["Chest X-ray", "Abdominal Ultrasound", "Dental X-ray"], labOrderTests: [], isLoading: false, @@ -454,13 +521,13 @@ class _MedicalFilePageState extends State { }), ), ], - ).paddingSymmetrical(24.w, 0.h), + ).paddingSymmetrical(0.w, 0.h), SizedBox(height: 24.h), - "Active Medications & Prescriptions".needTranslation.toText18(isBold: true).paddingSymmetrical(24.w, 0.h), + "Active Medications & Prescriptions".needTranslation.toText16(weight: FontWeight.w500, letterSpacing: -0.2), SizedBox(height: 16.h), Consumer(builder: (context, prescriptionVM, child) { return prescriptionVM.isPrescriptionsOrdersLoading - ? const CommonShimmerWidget().paddingSymmetrical(24.w, 0.h) + ? const CommonShimmerWidget().paddingSymmetrical(0.w, 0.h) : prescriptionVM.patientPrescriptionOrders.isNotEmpty ? Container( decoration: RoundedRectangleBorder().toSmoothCornerDecoration( @@ -503,13 +570,11 @@ class _MedicalFilePageState extends State { spacing: 3.w, runSpacing: 4.w, children: [ - AppCustomChipWidget( - labelText: prescriptionVM.patientPrescriptionOrders[index].clinicDescription!), + AppCustomChipWidget(labelText: prescriptionVM.patientPrescriptionOrders[index].clinicDescription!), AppCustomChipWidget( icon: AppAssets.doctor_calendar_icon, labelText: DateUtil.formatDateToDate( - DateUtil.convertStringToDate( - prescriptionVM.patientPrescriptionOrders[index].appointmentDate), + DateUtil.convertStringToDate(prescriptionVM.patientPrescriptionOrders[index].appointmentDate), false, ), ), @@ -522,19 +587,13 @@ class _MedicalFilePageState extends State { Transform.flip( flipX: appState.isArabic(), child: Utils.buildSvgWithAssets( - icon: AppAssets.forward_arrow_icon_small, - width: 15.w, - height: 15.h, - fit: BoxFit.contain, - iconColor: AppColors.textColor)), + icon: AppAssets.forward_arrow_icon_small, width: 15.w, height: 15.h, fit: BoxFit.contain, iconColor: AppColors.textColor)), ], ).onPress(() { prescriptionVM.setPrescriptionsDetailsLoading(); Navigator.of(context).push( CustomPageRoute( - page: PrescriptionDetailPage( - isFromAppointments: false, - prescriptionsResponseModel: prescriptionVM.patientPrescriptionOrders[index]), + page: PrescriptionDetailPage(isFromAppointments: false, prescriptionsResponseModel: prescriptionVM.patientPrescriptionOrders[index]), ), ); }), @@ -607,14 +666,14 @@ class _MedicalFilePageState extends State { width: 62.w, height: 62.h, ), - ).paddingSymmetrical(24.w, 0.h); + ).paddingSymmetrical(0.w, 0.h); }), SizedBox(height: 24.h), //My Doctor Section Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - LocaleKeys.myDoctor.tr(context: context).toText18(isBold: true), + LocaleKeys.myDoctor.tr(context: context).toText16(weight: FontWeight.w500, letterSpacing: -0.2), Row( children: [ LocaleKeys.viewAll.tr().toText12(color: AppColors.primaryRedColor, fontWeight: FontWeight.w500), @@ -630,7 +689,7 @@ class _MedicalFilePageState extends State { ); }), ], - ).paddingSymmetrical(24.w, 0.h), + ).paddingSymmetrical(0.w, 0.h), SizedBox(height: 16.h), Consumer(builder: (context, myAppointmentsVM, child) { return myAppointmentsVM.isPatientMyDoctorsLoading @@ -644,10 +703,7 @@ class _MedicalFilePageState extends State { fit: BoxFit.cover, ).circle(100).toShimmer2(isShow: true, radius: 50.r), SizedBox(height: 8.h), - ("Dr. John Smith Smith Smith") - .toString() - .toText12(fontWeight: FontWeight.w500, isCenter: true, maxLine: 2) - .toShimmer2(isShow: true), + ("Dr. John Smith Smith Smith").toString().toText12(fontWeight: FontWeight.w500, isCenter: true, maxLine: 2).toShimmer2(isShow: true), ], ) : myAppointmentsVM.patientMyDoctorsList.isEmpty @@ -665,7 +721,7 @@ class _MedicalFilePageState extends State { width: 62.w, height: 62.h, ), - ).paddingSymmetrical(24.w, 0.h) + ).paddingSymmetrical(0.w, 0.h) : SizedBox( height: 100.h, child: ListView.separated( @@ -730,10 +786,10 @@ class _MedicalFilePageState extends State { }, separatorBuilder: (BuildContext cxt, int index) => SizedBox(width: 8.h), ), - ).paddingSymmetrical(24.w, 0); + ).paddingSymmetrical(0.w, 0); }), SizedBox(height: 24.h), - "Others".needTranslation.toText18(isBold: true).paddingSymmetrical(24.w, 0.h), + "Others".needTranslation.toText16(weight: FontWeight.w500, letterSpacing: -0.2), SizedBox(height: 16.h), GridView( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -786,7 +842,7 @@ class _MedicalFilePageState extends State { ); }), ], - ).paddingSymmetrical(24.w, 0.0), + ).paddingSymmetrical(0.w, 0.0), SizedBox(height: 24.h), ], ); @@ -801,7 +857,7 @@ class _MedicalFilePageState extends State { labOrder: null, index: index, isLoading: true, - ).paddingSymmetrical(24.w, 0.0) + ).paddingSymmetrical(0.w, 0.0) : insuranceVM.patientInsuranceList.isNotEmpty ? PatientInsuranceCard( insuranceCardDetailsModel: insuranceVM.patientInsuranceList.first, @@ -828,14 +884,9 @@ class _MedicalFilePageState extends State { text: "${LocaleKeys.updateInsurance.tr(context: context)} ${LocaleKeys.updateInsuranceSubtitle.tr(context: context)}", onPressed: () { insuranceViewModel.setIsInsuranceUpdateDetailsLoading(true); - insuranceViewModel.getPatientInsuranceDetailsForUpdate(appState.getAuthenticatedUser()!.patientId.toString(), - appState.getAuthenticatedUser()!.patientIdentificationNo.toString()); - showCommonBottomSheetWithoutHeight(context, - child: PatientInsuranceCardUpdateCard(), - callBackFunc: () {}, - title: "", - isCloseButtonVisible: false, - isFullScreen: false); + insuranceViewModel.getPatientInsuranceDetailsForUpdate( + appState.getAuthenticatedUser()!.patientId.toString(), appState.getAuthenticatedUser()!.patientIdentificationNo.toString()); + showCommonBottomSheetWithoutHeight(context, child: PatientInsuranceCardUpdateCard(), callBackFunc: () {}, title: "", isCloseButtonVisible: false, isFullScreen: false); }, backgroundColor: AppColors.bgGreenColor.withOpacity(0.20), borderColor: AppColors.bgGreenColor.withOpacity(0.0), @@ -847,7 +898,7 @@ class _MedicalFilePageState extends State { height: isFoldable ? 50.h : 40.h, ).paddingOnly(left: 12.w, right: 12.w, bottom: 12.h), ), - ).paddingSymmetrical(24.w, 0.h); + ).paddingSymmetrical(0.w, 0.h); }), SizedBox(height: 10.h), GridView( @@ -902,7 +953,7 @@ class _MedicalFilePageState extends State { iconSize: 36.w, ), ], - ).paddingSymmetrical(24.w, 0.0), + ).paddingSymmetrical(0.w, 0.0), SizedBox(height: 16.h), ], ); @@ -915,12 +966,12 @@ class _MedicalFilePageState extends State { ? PatientSickLeaveCard( patientSickLeavesResponseModel: PatientSickLeavesResponseModel(), isLoading: true, - ).paddingSymmetrical(24.w, 0.0) + ).paddingSymmetrical(0.w, 0.0) : medicalFileVM.patientSickLeaveList.isNotEmpty ? PatientSickLeaveCard( patientSickLeavesResponseModel: medicalFileVM.patientSickLeaveList.first, isLoading: false, - ).paddingSymmetrical(24.w, 0.0) + ).paddingSymmetrical(0.w, 0.0) : Container( decoration: RoundedRectangleBorder().toSmoothCornerDecoration( color: AppColors.whiteColor, @@ -934,7 +985,7 @@ class _MedicalFilePageState extends State { width: 62.w, height: 62.h, ), - ).paddingSymmetrical(24.w, 0.h); + ).paddingSymmetrical(0.w, 0.h); }), SizedBox(height: 16.h), GridView( @@ -987,7 +1038,7 @@ class _MedicalFilePageState extends State { ); }), ], - ).paddingSymmetrical(24.w, 0.0), + ).paddingSymmetrical(0.w, 0.0), SizedBox(height: 24.h), ], ); diff --git a/lib/presentation/medical_file/widgets/lab_rad_card.dart b/lib/presentation/medical_file/widgets/lab_rad_card.dart index 1a56baf..eef73eb 100644 --- a/lib/presentation/medical_file/widgets/lab_rad_card.dart +++ b/lib/presentation/medical_file/widgets/lab_rad_card.dart @@ -27,55 +27,62 @@ class LabRadCard extends StatelessWidget { Widget build(BuildContext context) { AppState appState = getIt.get(); return Container( - decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 12.r, hasShadow: false), + decoration: RoundedRectangleBorder().toSmoothCornerDecoration(color: AppColors.whiteColor, borderRadius: 18.r, hasShadow: false), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + mainAxisAlignment: MainAxisAlignment.start, children: [ Utils.buildSvgWithAssets( icon: icon, width: 40.w, height: 40.h, - fit: BoxFit.contain, + fit: BoxFit.cover, ).toShimmer2(isShow: false, radius: 12.r), SizedBox(width: 8.w), - Flexible(child: labelText.toText14(isBold: true).toShimmer2(isShow: false, radius: 6.r, height: 32.h)), - ], - ), - SizedBox(height: 16.h), - labOrderTests.isNotEmpty - ? ListView.separated( - scrollDirection: Axis.vertical, - padding: EdgeInsets.zero, - physics: NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemBuilder: (cxt, index) { - return labOrderTests[index] - .toText12(isBold: true, maxLine: 1) - .toShimmer2(isShow: false, radius: 6.r, height: 24.h, width: 120.w) - .toShimmer2(isShow: isLoading); - }, - separatorBuilder: (cxt, index) => SizedBox(height: 8.h), - itemCount: 3, - ) - : "You don't have any records yet".needTranslation.toText13( - color: AppColors.greyTextColor, isCenter: true), - SizedBox(height: 16.h), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox.shrink(), + Flexible( + child: labelText.toText12(isBold: true, maxLine: 2), + ), Transform.flip( flipX: appState.isArabic(), - child: Utils.buildSvgWithAssets( - icon: AppAssets.forward_arrow_icon_small, width: 15.w, height: 15.h, fit: BoxFit.contain, iconColor: AppColors.textColor) - .toShimmer2(isShow: false, radius: 12.r), + child: Utils.buildSvgWithAssets(icon: AppAssets.forward_arrow_icon_small, width: 10.w, height: 10.h, fit: BoxFit.contain, iconColor: AppColors.textColor), ), ], - ) + ), + // SizedBox(height: 16.h), + // labOrderTests.isNotEmpty + // ? ListView.separated( + // scrollDirection: Axis.vertical, + // padding: EdgeInsets.zero, + // physics: NeverScrollableScrollPhysics(), + // shrinkWrap: true, + // itemBuilder: (cxt, index) { + // return labOrderTests[index] + // .toText12(isBold: true, maxLine: 1) + // .toShimmer2(isShow: false, radius: 6.r, height: 24.h, width: 120.w) + // .toShimmer2(isShow: isLoading); + // }, + // separatorBuilder: (cxt, index) => SizedBox(height: 8.h), + // itemCount: 3, + // ) + // : "You don't have any records yet".needTranslation.toText13( + // color: AppColors.greyTextColor, isCenter: true), + // SizedBox(height: 16.h), + // Row( + // mainAxisAlignment: MainAxisAlignment.spaceBetween, + // children: [ + // SizedBox.shrink(), + // Transform.flip( + // flipX: appState.isArabic(), + // child: Utils.buildSvgWithAssets( + // icon: AppAssets.forward_arrow_icon_small, width: 15.w, height: 15.h, fit: BoxFit.contain, iconColor: AppColors.textColor) + // .toShimmer2(isShow: false, radius: 12.r), + // ), + // ], + // ) ], - ).paddingAll(16.w), + ).paddingAll(12.w), ); } } diff --git a/lib/widgets/expandable_list_widget.dart b/lib/widgets/expandable_list_widget.dart new file mode 100644 index 0000000..21bbdf6 --- /dev/null +++ b/lib/widgets/expandable_list_widget.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; +import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; + +// ==================== MAIN CUSTOM WIDGET ==================== + +class CustomExpandableList extends StatefulWidget { + final List items; + final ExpansionMode expansionMode; + final EdgeInsetsGeometry? itemPadding; + final EdgeInsetsGeometry? contentPadding; + final Color? dividerColor; + final double dividerHeight; + final bool showDividers; + final Duration animationDuration; + final Curve animationCurve; + final ExpandableListTheme? theme; + final Function(int index, bool isExpanded)? onItemToggled; + + const CustomExpandableList({ + Key? key, + required this.items, + this.expansionMode = ExpansionMode.multiple, + this.itemPadding, + this.contentPadding, + this.dividerColor, + this.dividerHeight = 1.0, + this.showDividers = true, + this.animationDuration = const Duration(milliseconds: 300), + this.animationCurve = Curves.easeInOut, + this.theme, + this.onItemToggled, + }) : super(key: key); + + @override + _CustomExpandableListState createState() => _CustomExpandableListState(); +} + +class _CustomExpandableListState extends State { + late List _expandedStates; + + @override + void initState() { + super.initState(); + _expandedStates = List.generate( + widget.items.length, + (index) => widget.items[index].initiallyExpanded, + ); + } + + void _toggleItem(int index) { + setState(() { + if (widget.expansionMode == ExpansionMode.exactlyOne) { + // Close all others, open this one + for (int i = 0; i < _expandedStates.length; i++) { + _expandedStates[i] = (i == index) ? !_expandedStates[i] : false; + } + } else if (widget.expansionMode == ExpansionMode.atMostOne) { + // Toggle this one, close if opening another + bool wasExpanded = _expandedStates[index]; + for (int i = 0; i < _expandedStates.length; i++) { + if (i == index) { + _expandedStates[i] = !wasExpanded; + } else if (!wasExpanded) { + _expandedStates[i] = false; + } + } + } else { + // Multiple - just toggle the clicked item + _expandedStates[index] = !_expandedStates[index]; + } + }); + + widget.onItemToggled?.call(index, _expandedStates[index]); + } + + @override + Widget build(BuildContext context) { + final theme = widget.theme ?? ExpandableListTheme.defaultTheme(context); + + return Column( + children: List.generate(widget.items.length, (index) { + final item = widget.items[index]; + final isExpanded = _expandedStates[index]; + final isLast = index == widget.items.length - 1; + + return Column( + children: [ + _ExpandableListItemWidget( + title: item.title, + isExpanded: isExpanded, + onTap: () => _toggleItem(index), + padding: widget.itemPadding, + contentPadding: widget.contentPadding, + theme: theme, + leading: item.leading, + trailing: item.trailing, + backgroundColor: item.backgroundColor, + expandedBackgroundColor: item.expandedBackgroundColor, + animationDuration: widget.animationDuration, + animationCurve: widget.animationCurve, + children: item.children, + ), + if (widget.showDividers && !isLast) + Divider( + height: widget.dividerHeight, + color: widget.dividerColor ?? theme.dividerColor, + ).paddingSymmetrical(12.h,0), + ], + ); + }), + ); + } +} + +// ==================== SUPPORTING WIDGETS ==================== + +class _ExpandableListItemWidget extends StatelessWidget { + final Widget title; + final List children; + final bool isExpanded; + final VoidCallback onTap; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? contentPadding; + final ExpandableListTheme theme; + final Widget? leading; + final Widget? trailing; + final Color? backgroundColor; + final Color? expandedBackgroundColor; + final Duration animationDuration; + final Curve animationCurve; + + const _ExpandableListItemWidget({ + super.key, + required this.title, + required this.children, + required this.isExpanded, + required this.onTap, + this.padding, + this.contentPadding, + required this.theme, + this.leading, + this.trailing, + this.backgroundColor, + this.expandedBackgroundColor, + required this.animationDuration, + required this.animationCurve, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: isExpanded + ? (expandedBackgroundColor ?? theme.expandedBackgroundColor) + : (backgroundColor ?? theme.backgroundColor), + child: Column( + children: [ + // Header + InkWell( + onTap: onTap, + child: Padding( + padding: padding ?? theme.itemPadding, + child: Row( + children: [ + if (leading != null) ...[ + leading!, + SizedBox(width: theme.leadingSpacing), + ], + Expanded(child: title), + if (trailing != null) + trailing! + else + AnimatedRotation( + turns: isExpanded ? 0.5 : 0.0, + duration: animationDuration, + curve: animationCurve, + child: theme.defaultTrailingIcon, + ), + ], + ), + ), + ), + + // Content + AnimatedSize( + duration: animationDuration, + curve: animationCurve, + child: Container( + constraints: BoxConstraints( + minHeight: isExpanded ? 0.0 : 0.0, + maxHeight: isExpanded ? double.infinity : 0.0, + ), + child: isExpanded + ? Padding( + padding: contentPadding ?? theme.contentPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ) + : SizedBox.shrink(), + ), + ), + ], + ), + ); + } +} + +// ==================== DATA MODELS ==================== + +class ExpandableListItem { + final Widget title; + final List children; + final Widget? leading; + final Widget? trailing; + final bool initiallyExpanded; + final Color? backgroundColor; + final Color? expandedBackgroundColor; + + const ExpandableListItem({ + required this.title, + required this.children, + this.leading, + this.trailing, + this.initiallyExpanded = false, + this.backgroundColor, + this.expandedBackgroundColor, + }); +} + +// ==================== THEME ==================== + +class ExpandableListTheme { + final Color backgroundColor; + final Color expandedBackgroundColor; + final Color dividerColor; + final Widget defaultTrailingIcon; + final EdgeInsetsGeometry itemPadding; + final EdgeInsetsGeometry contentPadding; + final double leadingSpacing; + + const ExpandableListTheme({ + required this.backgroundColor, + required this.expandedBackgroundColor, + required this.dividerColor, + required this.defaultTrailingIcon, + required this.itemPadding, + required this.contentPadding, + this.leadingSpacing = 12.0, + }); + + factory ExpandableListTheme.defaultTheme(BuildContext context) { + final textColor = Theme.of(context).textTheme.bodyLarge?.color ?? Colors.black; + + return ExpandableListTheme( + backgroundColor: Colors.transparent, + expandedBackgroundColor: Colors.grey.shade50, + dividerColor: Colors.grey.shade300, + defaultTrailingIcon: Icon( + Icons.keyboard_arrow_down, + color: textColor, + size: 24, + ), + itemPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + contentPadding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 16.0), + ); + } + + factory ExpandableListTheme.custom({ + Color? backgroundColor, + Color? expandedBackgroundColor, + Color? dividerColor, + Widget? defaultTrailingIcon, + EdgeInsetsGeometry? itemPadding, + EdgeInsetsGeometry? contentPadding, + double? leadingSpacing, + }) { + return ExpandableListTheme( + backgroundColor: backgroundColor ?? Colors.transparent, + expandedBackgroundColor: expandedBackgroundColor ?? Colors.grey.shade50, + dividerColor: dividerColor ?? Colors.grey.shade300, + defaultTrailingIcon: defaultTrailingIcon ?? + Icon(Icons.keyboard_arrow_down, color: Colors.black, size: 24), + itemPadding: itemPadding ?? const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + contentPadding: contentPadding ?? const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 16.0), + leadingSpacing: leadingSpacing ?? 12.0, + ); + } +} + +// ==================== ENUMS ==================== + +enum ExpansionMode { + multiple, // Multiple items can be expanded + exactlyOne, // Exactly one item expanded at a time + atMostOne, // Zero or one item expanded at a time +} + + +