import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/svg.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:file_picker/file_picker.dart'; import '../../Notifiers/hrmProvider/tourExpensesProvider.dart'; import '../../Utils/app_colors.dart'; import '../../Utils/commonServices.dart'; import '../../Utils/commonWidgets.dart'; import '../../Utils/dropdownTheme.dart'; class AddBillScreen extends StatefulWidget { final String pageTitleName; const AddBillScreen({super.key, required this.pageTitleName}); @override State createState() => _AddBillScreenState(); } class _AddBillScreenState extends State { final Dropdowntheme ddtheme = Dropdowntheme(); final List focusNodes = List.generate(8, (index) => FocusNode()); Map _source = {ConnectivityResult.mobile: true}; final MyConnectivity _connectivity = MyConnectivity.instance; final TextEditingController placeController = TextEditingController(); final TextEditingController noteController = TextEditingController(); // Validation errors String? placeError; String? daAmountError; String? tourTypeError; String? tourDateError; String? noteError; List> travelExpenses = []; List> hotelExpenses = []; List> otherExpenses = []; List travelImages = []; List hotelImages = []; List otherImages = []; String? selectedDAAmount; String? selectedTourType; String? selectedTravelType; @override void initState() { super.initState(); _connectivity.initialise(); _connectivity.myStream.listen((source) { setState(() => _source = source); }); // Add listeners to clear errors when user starts typing placeController.addListener(() { if (placeError != null && placeController.text.isNotEmpty) { setState(() => placeError = null); } }); noteController.addListener(() { if (noteError != null && noteController.text.isNotEmpty) { setState(() => noteError = null); } }); Future.microtask(() { final provider = Provider.of(context, listen: false); provider.fetchTourExpensesAddView(context, "0"); // fresh bill }); } @override void dispose() { placeController.dispose(); noteController.dispose(); for (var node in focusNodes) { node.dispose(); } _connectivity.disposeStream(); super.dispose(); } Future _onBackPressed(BuildContext context) async { Navigator.pop(context, true); return true; } // Function to validate all fields bool validateFields() { String? newPlaceError = placeController.text.isEmpty ? "Place of visit is required" : null; String? newDaAmountError = selectedDAAmount == null ? "DA Amount is required" : null; String? newTourTypeError = selectedTourType == null ? "Tour Type is required" : null; String? newTourDateError = Provider.of(context, listen: false).dateController.text.isEmpty ? "Tour Date is required" : null; String? newNoteError = noteController.text.isEmpty ? "Note is required" : null; // Only update if there are actual changes to avoid unnecessary rebuilds if (placeError != newPlaceError || daAmountError != newDaAmountError || tourTypeError != newTourTypeError || tourDateError != newTourDateError || noteError != newNoteError) { setState(() { placeError = newPlaceError; daAmountError = newDaAmountError; tourTypeError = newTourTypeError; tourDateError = newTourDateError; noteError = newNoteError; }); } return newPlaceError == null && newDaAmountError == null && newTourTypeError == null && newTourDateError == null && newNoteError == null; } // Format date to "02 Sep 2025" format String _formatDate(DateTime date) { const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return '${date.day.toString().padLeft(2, '0')} ${months[date.month - 1]} ${date.year}'; } @override Widget build(BuildContext context) { switch (_source.keys.toList()[0]) { case ConnectivityResult.mobile: case ConnectivityResult.wifi: connection = 'Online'; break; case ConnectivityResult.none: default: connection = 'Offline'; } return (connection == "Online") ? Platform.isAndroid ? WillPopScope( onWillPop: () => _onBackPressed(context), child: SafeArea( top: false, bottom: true, child: _scaffold(context)), ) : _scaffold(context) : NoNetwork(context); } Future pickImage(BuildContext context) async { final ImagePicker picker = ImagePicker(); return showModalBottomSheet( context: context, builder: (ctx) { return SafeArea( child: Wrap( children: [ ListTile( leading: const Icon(Icons.photo_library), title: const Text('Pick from Gallery'), onTap: () async { final picked = await picker.pickImage(source: ImageSource.gallery); Navigator.pop(ctx, picked != null ? File(picked.path) : null); }, ), ListTile( leading: const Icon(Icons.camera_alt), title: const Text('Take a Photo'), onTap: () async { final picked = await picker.pickImage(source: ImageSource.camera); Navigator.pop(ctx, picked != null ? File(picked.path) : null); }, ), ], ), ); }, ); } Widget _scaffold(BuildContext context) { return Consumer( builder: (context, provider, _) { if (provider.isLoading) { return Scaffold( body: Container(child: Center(child: CircularProgressIndicator(color: Colors.blue)),), ); } if (provider.errorMessage != null) { return Scaffold(body: Center(child: Text(provider.errorMessage!))); } return Scaffold( resizeToAvoidBottomInset: true, backgroundColor: AppColors.scaffold_bg_color, appBar: AppBar( backgroundColor: Colors.white, title: Text( widget.pageTitleName, style: TextStyle( fontSize: 18, fontFamily: "Plus Jakarta Sans", fontWeight: FontWeight.w600, color: AppColors.semi_black, ), ), leading: IconButton( icon: SvgPicture.asset( "assets/svg/appbar_back_button.svg", height: 25, ), onPressed: () => Navigator.pop(context), ), ), body: Scrollbar( thumbVisibility: false, child: SingleChildScrollView( child: Container( padding: EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(20), ), margin: EdgeInsets.only(top: 10, left: 10, right: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ textControllerWidget( context, placeController, "Place of Visit", "Enter Place", (value) { // Clear error when user types if (placeError != null && value.isNotEmpty) { setState(() => placeError = null); } }, TextInputType.text, false, null, focusNodes[0], focusNodes[1], TextInputAction.next, ), errorWidget(context, placeError), TextWidget(context, "DA Amount"), DropdownButtonHideUnderline( child: Row( children: [ Expanded( child: DropdownButton2( isExpanded: true, hint: Text( 'Select DA Amount', style: TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis, ), items: provider.daAmountList .map((item) => DropdownMenuItem( value: item, child: Text( item, style: const TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis, ), )) .toList(), value: selectedDAAmount, onChanged: (String? value) { setState(() { selectedDAAmount = value; if (daAmountError != null) daAmountError = null; }); }, buttonStyleData: ddtheme.buttonStyleData, iconStyleData: ddtheme.iconStyleData, menuItemStyleData: ddtheme.menuItemStyleData, dropdownStyleData: ddtheme.dropdownStyleData, ), ), ], ), ), errorWidget(context, daAmountError), TextWidget(context, "Tour Type"), DropdownButtonHideUnderline( child: Row( children: [ Expanded( child: DropdownButton2( isExpanded: true, hint: Text( 'Select Tour Type', style: TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis, ), items: provider.tourTypeList .map((item) => DropdownMenuItem( value: item, child: Text( item, style: const TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis, ), )) .toList(), value: selectedTourType, onChanged: (String? value) { setState(() { selectedTourType = value; if (tourTypeError != null) tourTypeError = null; }); }, buttonStyleData: ddtheme.buttonStyleData, iconStyleData: ddtheme.iconStyleData, menuItemStyleData: ddtheme.menuItemStyleData, dropdownStyleData: ddtheme.dropdownStyleData, ), ), ], ), ), errorWidget(context, tourTypeError), TextWidget(context, "Tour Date"), GestureDetector( onTap: () async { final d = await provider.showDatePickerDialog(context, isFromDate: true); if (d != null) { provider.dateController.text = _formatDate(d); if (tourDateError != null) { setState(() => tourDateError = null); } } }, child: Container( height: 50, decoration: BoxDecoration( color: AppColors.text_field_color, borderRadius: BorderRadius.circular(14), ), child: TextFormField( controller: provider.dateController, enabled: false, decoration: InputDecoration( hintText: "Select Tour Date", hintStyle: TextStyle( fontWeight: FontWeight.w400, color: Color(0xFFB4BEC0), fontSize: 14, ), border: InputBorder.none, contentPadding: EdgeInsets.symmetric(horizontal: 15), ), ), ), ), errorWidget(context, tourDateError), textControllerWidget( context, noteController, "Note", "Enter Note", (value) { // Clear error when user types if (noteError != null && value.isNotEmpty) { setState(() => noteError = null); } }, TextInputType.text, false, null, focusNodes[2], focusNodes[3], TextInputAction.next, 300, // Allow up to 300 characters ), errorWidget(context, noteError), const SizedBox(height: 16), /// Travel Expenses Section sectionHeader("Travel Expenses", onAddTap: () { showAddTravelExpenseSheet( context, travelExpenses, () => setState(() {}), provider.travelTypeList, travelImages, ); }), if (travelExpenses.isNotEmpty) travelExpenseList(travelExpenses), /// Hotel Expenses Section sectionHeader("Hotel Expenses", onAddTap: () { showAddHotelExpenseSheet( context, hotelExpenses, () => setState(() {}), provider, hotelImages, ); }), if (hotelExpenses.isNotEmpty) hotelExpenseList(hotelExpenses), /// Other Expenses Section sectionHeader("Other Expenses", onAddTap: () { showAddOtherExpenseSheet( context, otherExpenses, () => setState(() {}), provider, otherImages, ); }), if (otherExpenses.isNotEmpty) otherExpenseList(otherExpenses), const SizedBox(height: 80), ], ), ), ), ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, bottomNavigationBar: InkResponse( onTap: () async { // Validate all fields first if (!validateFields()) { return; } String formattedDate = ""; final provider = Provider.of(context, listen: false); tourDateError = null; final parsedDate = DateFormat("dd MMM yyyy").parse(provider.dateController.text); formattedDate = DateFormat("yyyy-MM-dd").format(parsedDate); final success = await provider.addTourBill( context: context, placeOfVisit: placeController.text, daAmount: selectedDAAmount ?? "", tourType: selectedTourType ?? "", tourDate: formattedDate, travelExpenses: travelExpenses.map((e) => e.map((k, v) => MapEntry(k, v as dynamic))).toList(), hotelExpenses: hotelExpenses.map((e) => e.map((k, v) => MapEntry(k, v as dynamic))).toList(), otherExpenses: otherExpenses.map((e) => e.map((k, v) => MapEntry(k, v as dynamic))).toList(), travelImages: travelImages, hotelImages: hotelImages, otherImages: otherImages, ); provider.dateController.clear(); print("image================== $travelImages"); if (success) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("Tour Bill Submitted Successfully")), ); Navigator.pop(context, true); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(provider.errorMessage ?? "Failed to submit bill")), ); } }, child: Container( height: 45, alignment: Alignment.center, margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 15), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: AppColors.app_blue, borderRadius: BorderRadius.circular(15), ), child: const Text( "Submit", style: TextStyle( fontSize: 15, fontFamily: "JakartaMedium", color: Colors.white, ), ), ), ), ); }, ); } Widget sectionHeader(String title, {VoidCallback? onAddTap}) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, fontFamily: "JakartaMedium", )), const SizedBox(height: 6), Container( height: 45, decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade400, width: 0.7), borderRadius: BorderRadius.circular(12), ), child: InkWell( onTap: onAddTap, child: const Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.add, color: Colors.blue), SizedBox(width: 6), Text("Add Expenses", style: TextStyle( color: Colors.blue, fontSize: 14, fontFamily: "JakartaMedium", )), ], ), ), ), ), const SizedBox(height: 10), ], ); } String _getTravelIcon(String? travelType) { switch (travelType?.toLowerCase()) { case "flight": return "assets/svg/hrm/airplane_ic.svg"; case "train": return "assets/svg/hrm/train_ic.svg"; case "bus": return "assets/svg/hrm/bus_ic.svg"; case "car": return "assets/svg/hrm/car_ic.svg"; case "auto": return "assets/svg/hrm/truck_ic.svg"; case "bike": return "assets/svg/hrm/motorcycle_ic.svg"; default: return "assets/svg/hrm/travel_ic.svg"; // fallback } } Widget travelExpenseList(List> items) { return Container( height: 90, margin: const EdgeInsets.only(bottom: 12), child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: items.length, itemBuilder: (context, index) { final exp = items[index]; return Container( width: 200, margin: const EdgeInsets.only(right: 12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFE6F6FF), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Container( width: 36, height: 36, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), child: Center( child: SvgPicture.asset( _getTravelIcon(exp["travel_type"]), height: 20, ), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( exp["travel_type"] ?? "Travel", style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.semi_black, fontFamily: "JakartaMedium", ), overflow: TextOverflow.ellipsis, ), if (exp["from"] != null && exp["to"] != null) ...[ const SizedBox(height: 2), Text( "${exp["from"]} → ${exp["to"]}", style: TextStyle( fontSize: 12, color: AppColors.grey_semi, fontFamily: "JakartaMedium", ), overflow: TextOverflow.ellipsis, ), ], const SizedBox(height: 4), Text( "₹${exp["amount"] ?? "0"}", style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.app_blue, fontFamily: "JakartaMedium", ), ), ], ), ), ], ), ); }, ), ); } Widget hotelExpenseList(List> items) { return Container( height: 90, margin: const EdgeInsets.only(bottom: 12), child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: items.length, itemBuilder: (context, index) { final exp = items[index]; return Container( width: 160, margin: const EdgeInsets.only(right: 12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFE6F6FF), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Container( width: 36, height: 36, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), child: Center( child: SvgPicture.asset( "assets/svg/hrm/hotel_ic.svg", height: 20, ), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( exp["hotel_name"] ?? "-", style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.semi_black, fontFamily: "JakartaMedium", ), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( "₹${exp["amount"] ?? "0"}", style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.app_blue, fontFamily: "JakartaMedium", ), ), ], ), ), ], ), ); }, ), ); } Widget otherExpenseList(List> items) { return Container( height: 90, margin: const EdgeInsets.only(bottom: 12), child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: items.length, itemBuilder: (context, index) { final exp = items[index]; return Container( width: 160, margin: const EdgeInsets.only(right: 12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFE6F6FF), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Container( width: 36, height: 36, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), child: Center( child: SvgPicture.asset( "assets/svg/hrm/books_ic.svg", height: 20, ), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( exp["description"] ?? "-", style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.semi_black, fontFamily: "JakartaMedium", ), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( "₹${exp["amount"] ?? "0"}", style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.app_blue, fontFamily: "JakartaMedium", ), ), const SizedBox(height: 2), Text( exp["date"] != null ? exp["date"]!.split("T").first : "-", style: TextStyle( fontSize: 12, fontWeight: FontWeight.w400, color: AppColors.grey_semi, fontFamily: "JakartaMedium", ), ), ], ), ), ], ), ); }, ), ); } Future pickFile() async { FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.any); if (result != null && result.files.isNotEmpty) { return File(result.files.single.path!); } return null; } // --- Travel Expense BottomSheet --- Future showAddTravelExpenseSheet( BuildContext context, List> travelExpenses, VoidCallback onUpdated, List travelTypes, List travelImages, ) { final fromController = TextEditingController(); final toController = TextEditingController(); final fareController = TextEditingController(); String? selectedTravelType; File? billFile; String? fromError, toError, typeError, fareError, billError; // Listeners to clear errors when user starts typing fromController.addListener(() { if (fromError != null && fromController.text.isNotEmpty) { fromError = null; } }); toController.addListener(() { if (toError != null && toController.text.isNotEmpty) { toError = null; } }); fareController.addListener(() { if (fareError != null && fareController.text.isNotEmpty) { fareError = null; } }); return showModalBottomSheet( useSafeArea: true, isDismissible: true, isScrollControlled: true, showDragHandle: true, backgroundColor: Colors.white, enableDrag: true, context: context, builder: (context) { return StatefulBuilder( builder: (context, setState) { // Function to update state and clear errors when fields change void updateState(VoidCallback fn) { setState(() { fn(); }); } // Function to validate fields and show errors if needed bool validateFields() { String? newFromError = fromController.text.isEmpty ? "From is required" : null; String? newToError = toController.text.isEmpty ? "To is required" : null; String? newTypeError = selectedTravelType == null ? "Please select type" : null; String? newFareError = fareController.text.isEmpty ? "Fare is required" : null; String? newBillError = billFile == null ? "Attach bill required" : null; // Only update if there are actual changes to avoid unnecessary rebuilds if (fromError != newFromError || toError != newToError || typeError != newTypeError || fareError != newFareError || billError != newBillError) { updateState(() { fromError = newFromError; toError = newToError; typeError = newTypeError; fareError = newFareError; billError = newBillError; }); } return newFromError == null && newToError == null && newTypeError == null && newFareError == null && newBillError == null; } Widget errorText(String? msg) => msg == null ? const SizedBox() : Padding( padding: const EdgeInsets.only(top: 4, left: 4), child: Text( msg, style: TextStyle( color: Colors.red, fontSize: 12, fontFamily: "JakartaMedium", ) ), ); return SafeArea( child: Container( margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Add Travel Expense", style: TextStyle( fontSize: 16, color: AppColors.app_blue, fontFamily: "JakartaMedium", ), ), const SizedBox(height: 16), textControllerWidget( context, fromController, "From", "Enter Starting Location", (value) { // Clear error when user types if (fromError != null && value.isNotEmpty) { updateState(() => fromError = null); } }, TextInputType.text, false, null, null, null, TextInputAction.next, ), errorText(fromError), const SizedBox(height: 12), textControllerWidget( context, toController, "To", "Enter Destination Location", (value) { // Clear error when user types if (toError != null && value.isNotEmpty) { updateState(() => toError = null); } }, TextInputType.text, false, null, null, null, TextInputAction.next, ), errorText(toError), const SizedBox(height: 12), TextWidget(context, "Travel Type"), DropdownButtonHideUnderline( child: Container( width: double.infinity, child: DropdownButton2( isExpanded: true, hint: Text( "Select Travel Type", style: TextStyle( fontSize: 14, color: Color(0xFFB4BEC0), ) ), items: travelTypes.map((t) => DropdownMenuItem( value: t, child: Text( t, style: TextStyle(fontSize: 14), ) ) ).toList(), value: selectedTravelType, onChanged: (val) { updateState(() { selectedTravelType = val; if (typeError != null) typeError = null; }); }, buttonStyleData: ButtonStyleData( height: 50, width: double.infinity, padding: const EdgeInsets.only(left: 14, right: 14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), color: AppColors.text_field_color, ), ), dropdownStyleData: DropdownStyleData( maxHeight: 200, width: MediaQuery.of(context).size.width - 60, decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), color: Colors.white, ), offset: const Offset(0, -5), scrollbarTheme: ScrollbarThemeData( radius: const Radius.circular(40), thickness: MaterialStateProperty.all(6), thumbVisibility: MaterialStateProperty.all(true), ), ), ), ), ), errorText(typeError), const SizedBox(height: 12), textControllerWidget( context, fareController, "Fare Amount", "Enter Amount", (value) { // Clear error when user types if (fareError != null && value.isNotEmpty) { updateState(() => fareError = null); } }, TextInputType.number, false, FilteringTextInputFormatter.digitsOnly, null, null, TextInputAction.next, ), errorText(fareError), const SizedBox(height: 12), InkResponse( onTap: () async { final f = await pickImage(context); if (f != null) { updateState(() { billFile = f; if (billError != null) billError = null; }); } }, child: Container( height: 45, decoration: BoxDecoration( color: Color(0xFFE6F6FF), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.app_blue, width: 0.5), ), child: Center( child: Text( billFile == null ? "Attach Bill" : "Bill Attached", style: TextStyle( fontFamily: "JakartaMedium", color: AppColors.app_blue, ), ), ), ), ), errorText(billError), if (billFile != null) ...[ const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( flex: 5, child: Text( "${billFile!.path.split('/').last}", maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: AppColors.semi_black, fontSize: 11, fontWeight: FontWeight.w600, ), ), ), Expanded( flex: 1, child: InkResponse( onTap: () => updateState(() => billFile = null), child: SvgPicture.asset( "assets/svg/ic_close.svg", width: 15, height: 15, ), ), ), ], ), ) ], const SizedBox(height: 20), InkResponse( onTap: () { // Validate all fields if (validateFields()) { travelExpenses.add({ "from": fromController.text, "to": toController.text, "travel_type": selectedTravelType!, "amount": fareController.text, }); travelImages.add(billFile!); onUpdated(); Navigator.pop(context); } }, child: Container( height: 45, decoration: BoxDecoration( color: AppColors.app_blue, borderRadius: BorderRadius.circular(15), ), child: Center( child: Text( "Submit", style: TextStyle( color: Colors.white, fontFamily: "JakartaMedium", fontSize: 15, ), ), ), ), ), ], ), ), ), ); }, ); }, ); } // --- Hotel Expense BottomSheet --- Future showAddHotelExpenseSheet( BuildContext context, List> hotelExpenses, VoidCallback onUpdated, TourExpensesProvider provider, List hotelImages, ) { final hotelController = TextEditingController(); final amountController = TextEditingController(); DateTime? fromDate, toDate; File? billFile; String? hotelError, fromDateError, toDateError, amountError, billError; // Listeners to clear errors when user starts typing hotelController.addListener(() { if (hotelError != null && hotelController.text.isNotEmpty) { hotelError = null; } }); amountController.addListener(() { if (amountError != null && amountController.text.isNotEmpty) { amountError = null; } }); return showModalBottomSheet( useSafeArea: true, isDismissible: true, isScrollControlled: true, showDragHandle: true, backgroundColor: Colors.white, enableDrag: true, context: context, builder: (context) { return StatefulBuilder( builder: (context, setState) { // Function to update state and clear errors void updateState(VoidCallback fn) { setState(() { fn(); }); } // Function to validate fields and show errors bool validateFields() { String? newHotelError = hotelController.text.isEmpty ? "Hotel name required" : null; String? newFromDateError = fromDate == null ? "From date required" : null; String? newToDateError = toDate == null ? "To date required" : null; String? newAmountError = amountController.text.isEmpty ? "Amount required" : null; String? newBillError = billFile == null ? "Attach bill required" : null; if (hotelError != newHotelError || fromDateError != newFromDateError || toDateError != newToDateError || amountError != newAmountError || billError != newBillError) { updateState(() { hotelError = newHotelError; fromDateError = newFromDateError; toDateError = newToDateError; amountError = newAmountError; billError = newBillError; }); } return newHotelError == null && newFromDateError == null && newToDateError == null && newAmountError == null && newBillError == null; } Widget errorText(String? msg) => msg == null ? const SizedBox() : Padding( padding: const EdgeInsets.only(top: 4, left: 4), child: Text( msg, style: TextStyle( color: Colors.red, fontSize: 12, fontFamily: "JakartaMedium", ), ), ); return SafeArea( child: Container( margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Add Hotel Expense", style: TextStyle( fontSize: 16, color: AppColors.app_blue, fontFamily: "JakartaMedium", ), ), const SizedBox(height: 16), textControllerWidget( context, hotelController, "Hotel Name", "Enter Hotel Name", (value) { // Clear error if (hotelError != null && value.isNotEmpty) { updateState(() => hotelError = null); } }, TextInputType.text, false, null, null, null, TextInputAction.next, ), errorText(hotelError), const SizedBox(height: 12), Text( "Stay Duration", style: TextStyle( fontSize: 14, fontFamily: "JakartaMedium", ), ), const SizedBox(height: 6), Row( children: [ Expanded( child: GestureDetector( onTap: () async { final d = await provider.showDatePickerDialog(context, isFromDate: true); if (d != null) { updateState(() { fromDate = d; if (fromDateError != null) fromDateError = null; }); } }, child: Container( height: 50, decoration: BoxDecoration( color: AppColors.text_field_color, borderRadius: BorderRadius.circular(14), ), child: Center( child: Text( fromDate == null ? "From Date" : DateFormat("dd MMM yyyy").format(fromDate!), style: TextStyle( fontSize: 14, color: fromDate == null ? Color(0xFFB4BEC0) : Colors.black, fontFamily: "JakartaMedium", ), ), ), ), ), ), const SizedBox(width: 8), Expanded( child: GestureDetector( onTap: () async { final d = await provider.showDatePickerDialog(context, isFromDate: false); if (d != null) { updateState(() { toDate = d; if (toDateError != null) toDateError = null; }); } }, child: Container( height: 50, decoration: BoxDecoration( color: AppColors.text_field_color, borderRadius: BorderRadius.circular(14), ), child: Center( child: Text( toDate == null ? "To Date" : DateFormat("dd MMM yyyy").format(toDate!), style: TextStyle( fontSize: 14, color: toDate == null ? Color(0xFFB4BEC0) : Colors.black, fontFamily: "JakartaMedium", ), ), ), ), ), ), ], ), if (fromDateError != null) errorText(fromDateError), if (toDateError != null) errorText(toDateError), const SizedBox(height: 12), textControllerWidget( context, amountController, "Amount", "Enter Amount", (value) { // Clear error if (amountError != null && value.isNotEmpty) { updateState(() => amountError = null); } }, TextInputType.number, false, FilteringTextInputFormatter.digitsOnly, null, null, TextInputAction.next, ), errorText(amountError), const SizedBox(height: 12), InkResponse( onTap: () async { final f = await pickImage(context); if (f != null) { updateState(() { billFile = f; if (billError != null) billError = null; }); } }, child: Container( height: 45, decoration: BoxDecoration( color: Color(0xFFE6F6FF), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.app_blue, width: 0.5), ), child: Center( child: Text( billFile == null ? "Attach Bill" : "Bill Attached", style: TextStyle( fontFamily: "JakartaMedium", color: AppColors.app_blue, ), ), ), ), ), errorText(billError), if (billFile != null) ...[ const SizedBox(height: 10), Row( children: [ const Icon(Icons.check_circle, color: Colors.green), const SizedBox(width: 8), Expanded( child: Text("Attached: ${billFile!.path.split('/').last}", overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: "JakartaMedium", fontSize: 14))), IconButton( icon: const Icon(Icons.close, color: Colors.red), onPressed: () => updateState(() => billFile = null), ), ], ) ], const SizedBox(height: 20), InkResponse( onTap: () { // Validate all fields if (validateFields()) { hotelExpenses.add({ "hotel_name": hotelController.text, "from_date": fromDate!.toIso8601String(), "to_date": toDate!.toIso8601String(), "amount": amountController.text, }); hotelImages.add(billFile!); onUpdated(); Navigator.pop(context); } }, child: Container( height: 45, decoration: BoxDecoration( color: AppColors.app_blue, borderRadius: BorderRadius.circular(15), ), child: Center( child: Text( "Submit", style: TextStyle( color: Colors.white, fontFamily: "JakartaMedium", fontSize: 15, ), ), ), ), ), ], ), ), ), ); }, ); }, ); } // --- Other Expense BottomSheet --- Future showAddOtherExpenseSheet( BuildContext context, List> otherExpenses, VoidCallback onUpdated, TourExpensesProvider provider, List otherImages, ) { final titleController = TextEditingController(); final amountController = TextEditingController(); File? billFile; DateTime? date; String? titleError, amountError, dateError, billError; // Listeners to clear errors when user starts typing titleController.addListener(() { if (titleError != null && titleController.text.isNotEmpty) { titleError = null; } }); amountController.addListener(() { if (amountError != null && amountController.text.isNotEmpty) { amountError = null; } }); return showModalBottomSheet( useSafeArea: true, isDismissible: true, isScrollControlled: true, showDragHandle: true, backgroundColor: Colors.white, enableDrag: true, context: context, builder: (context) { return StatefulBuilder( builder: (context, setState) { void updateState(VoidCallback fn) { setState(() { fn(); }); } // Function to validate fields and show errors bool validateFields() { String? newDateError = date == null ? "Date required" : null; String? newTitleError = titleController.text.isEmpty ? "Title required" : null; String? newAmountError = amountController.text.isEmpty ? "Amount required" : null; String? newBillError = billFile == null ? "Attach bill required" : null; if (dateError != newDateError || titleError != newTitleError || amountError != newAmountError || billError != newBillError) { updateState(() { dateError = newDateError; titleError = newTitleError; amountError = newAmountError; billError = newBillError; }); } return newDateError == null && newTitleError == null && newAmountError == null && newBillError == null; } Widget errorText(String? msg) => msg == null ? const SizedBox() : Padding( padding: const EdgeInsets.only(top: 4, left: 4), child: Text( msg, style: TextStyle( color: Colors.red, fontSize: 12, fontFamily: "JakartaMedium", ), ), ); return SafeArea( child: Container( margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Add Other Expense", style: TextStyle( fontSize: 16, color: AppColors.app_blue, fontFamily: "JakartaMedium", ), ), const SizedBox(height: 16), TextWidget(context, "Date"), GestureDetector( onTap: () async { final d = await provider.showDatePickerDialog(context, isFromDate: false); if (d != null) { updateState(() { date = d; if (dateError != null) dateError = null; }); } }, child: Container( height: 50, decoration: BoxDecoration( color: AppColors.text_field_color, borderRadius: BorderRadius.circular(14), ), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(16), ), child: Text( date == null ? "Select Date" : DateFormat("dd MMM yyyy").format(date!), style: TextStyle( fontSize: 14, color: date == null ? const Color(0xFFB4BEC0) : Colors.black, fontFamily: "JakartaMedium", ), ), ), ), ), errorText(dateError), const SizedBox(height: 12), textControllerWidget( context, titleController, "Description", "Enter Title", (value) { // Clear error if (titleError != null && value.isNotEmpty) { updateState(() => titleError = null); } }, TextInputType.text, false, null, null, null, TextInputAction.next, ), errorText(titleError), const SizedBox(height: 12), textControllerWidget( context, amountController, "Amount", "Enter Amount", (value) { // Clear error if (amountError != null && value.isNotEmpty) { updateState(() => amountError = null); } }, TextInputType.number, false, FilteringTextInputFormatter.digitsOnly, null, null, TextInputAction.next, ), errorText(amountError), const SizedBox(height: 12), InkResponse( onTap: () async { final f = await pickImage(context); if (f != null) { updateState(() { billFile = f; if (billError != null) billError = null; }); } }, child: Container( height: 45, decoration: BoxDecoration( color: Color(0xFFE6F6FF), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.app_blue, width: 0.5), ), child: Center( child: Text( billFile == null ? "Attach Bill" : "Bill Attached", style: TextStyle( fontFamily: "JakartaMedium", color: AppColors.app_blue, ), ), ), ), ), errorText(billError), if (billFile != null) ...[ const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( flex: 5, child: Text( "${billFile!.path.split('/').last}", maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: AppColors.semi_black, fontSize: 11, fontWeight: FontWeight.w600, ), ), ), Expanded( flex: 1, child: InkResponse( onTap: () => updateState(() => billFile = null), child: SvgPicture.asset( "assets/svg/ic_close.svg", width: 15, height: 15, ), ), ), ], ), ) ], const SizedBox(height: 20), InkResponse( onTap: () { // Validate all fields if (validateFields()) { otherExpenses.add({ "description": titleController.text, "amount": amountController.text, "date": date!.toIso8601String(), }); otherImages.add(billFile!); onUpdated(); Navigator.pop(context); } }, child: Container( height: 45, decoration: BoxDecoration( color: AppColors.app_blue, borderRadius: BorderRadius.circular(15), ), child: Center( child: Text( "Submit", style: TextStyle( color: Colors.white, fontFamily: "JakartaMedium", fontSize: 15, ), ), ), ), ), ], ), ), ), ); }, ); }, ); } }